diff --git a/.gitignore b/.gitignore index 90ba67d..8be2e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ .env.test.local .env.production.local .idea/* +.vscode #docker docker-compose.* diff --git a/package.json b/package.json index 83971f1..8c72745 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@mantine/dates": "^6.0.16", "@mantine/hooks": "^6.0.16", "@tabler/icons-react": "^2.24.0", + "@tanstack/react-query": "^5.21.2", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", diff --git a/src/App.tsx b/src/App.tsx index da362b8..746074b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,9 +9,10 @@ import FullImageModal from './shared/components/FullImageModal'; import AppBody from './AppBody'; import FullProductModal from './shared/components/FullProductModal'; import Forbidden from './pages/403'; +import { QueryClient } from '@tanstack/react-query'; + function App() { - // const auth = useAuth(); const systemColorScheme = useColorScheme() const [colorScheme, setColorScheme] = useState(getCookie('mantine-color-scheme') as ColorScheme || systemColorScheme); const toggleColorScheme = (value?: ColorScheme) => { @@ -20,20 +21,22 @@ function App() { setCookie('mantine-color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 }); } + const auth = useAuth(); // automatically sign-in - // useEffect(() => { - // if (!hasAuthParams() && - // !auth.isAuthenticated && !auth.activeNavigator && !auth.isLoading) { - // auth.signinRedirect(); - // } - // }, [auth, auth.isAuthenticated, auth.activeNavigator, auth.isLoading, auth.signinRedirect]); + useEffect(() => { + if (!hasAuthParams() && + !auth.isAuthenticated && !auth.activeNavigator && !auth.isLoading) { + auth.signinRedirect(); + } + }, [auth, auth.isAuthenticated, auth.activeNavigator, auth.isLoading, auth.signinRedirect]); - // if (auth.activeNavigator || auth.isLoading) { - // return - // } - // if ((!auth.isAuthenticated && !auth.isLoading) || auth.error) { - // return - // } + if (auth.activeNavigator || auth.isLoading) { + return + } + if ((!auth.isAuthenticated && !auth.isLoading) || auth.error) { + console.error(`auth.error:`, auth.error) + return + } return (
diff --git a/src/AppBody.tsx b/src/AppBody.tsx index e033b0e..0d38697 100644 --- a/src/AppBody.tsx +++ b/src/AppBody.tsx @@ -1,48 +1,54 @@ import React, { useEffect, useState } from 'react'; import { AppShell, useMantineTheme, } from "@mantine/core" -import LeftSideBar from './widgets/LeftSideBar'; -import RightSideBar from './widgets/RightSideBar'; import { HeaderAction } from './widgets/header/HeaderAction'; import { testHeaderLinks } from './widgets/header/header.links'; import AppRouter from './router/AppRouter'; import { SideBar } from './shared/components/SideBar'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient() const AppBody = () => { useEffect(() => { console.log("render Main") }) - const [leftSideBar, setLeftSidebar] = useState(true) - const [rightSideBar, setRightSidebar] = useState(true) + const [leftSideBar, setLeftSidebar] = useState(false) + const [rightSideBar, setRightSidebar] = useState(false) const leftSideBarIsHidden = (isHidden: boolean) => { setLeftSidebar(!isHidden) } + const rightSideBarIsHidden = (isHidden: boolean) => { + setRightSidebar(!isHidden) + } const theme = useMantineTheme(); return ( - + - } - navbar={ - - } - > - - + header={ + + } + aside={ + + } + > + + + ) }; diff --git a/src/index.tsx b/src/index.tsx index 7df20e3..f3cdbf1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,13 +3,28 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import reportWebVitals from './reportWebVitals'; import RootStore from './shared/stores/root.store'; -import { AuthProvider } from 'react-oidc-context'; -import { keycloakConfig } from './shared/services/keycloack'; +import { AuthProvider, AuthProviderProps } from 'react-oidc-context'; +import { oidpSettings } from './shared/env.const'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); +export const hostURL = new URL(window.location.href) + +export const keycloakConfig: AuthProviderProps = { + authority: oidpSettings.server, + client_id: oidpSettings.clientId, + redirect_uri: hostURL.toString(), + onSigninCallback: () => { + window.history.replaceState( + {}, + document.title, + window.location.pathname + ) + } +} + const rootStore = new RootStore() export const Context = createContext(rootStore) diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx deleted file mode 100644 index b316e63..0000000 --- a/src/pages/Admin.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -const AdminPage = () => { - return ( -
- -
- ); -}; - -export default AdminPage; \ No newline at end of file diff --git a/src/pages/FrigateHostsPage.tsx b/src/pages/FrigateHostsPage.tsx new file mode 100644 index 0000000..4e02b66 --- /dev/null +++ b/src/pages/FrigateHostsPage.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import FrigateHostsTable from '../widgets/FrigateHostsTable'; +import { useQuery } from '@tanstack/react-query'; +import { frigateApi } from '../services/frigate.proxy/frigate.api'; +import CenterLoader from '../shared/components/CenterLoader'; +import RetryError from './RetryError'; + +const FrigateHostsPage = () => { + + const { isPending: hostsPending, error: hostsError, data, refetch } = useQuery({ + queryKey: ['frigate-hosts'], + queryFn: frigateApi.getHosts, + }) + + if (hostsPending) return + + if (hostsError) return + + return ( +
+ +
+ ); +}; + +export default FrigateHostsPage; \ No newline at end of file diff --git a/src/pages/MainBody.tsx b/src/pages/MainBody.tsx index 843a538..0519215 100644 --- a/src/pages/MainBody.tsx +++ b/src/pages/MainBody.tsx @@ -1,5 +1,5 @@ import { Container, Flex, Group, Text } from '@mantine/core'; -import ProductTable, { TableAdapter } from '../shared/components/table.aps/ProductTable'; +import ProductTable, { TableAdapter } from '../widgets/ProductTable'; import HeadSearch from '../shared/components/HeadSearch'; import ViewSelector, { SelectorViewState } from '../shared/components/ViewSelector'; import { useContext, useState, useEffect } from 'react'; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..0fc43d4 --- /dev/null +++ b/src/pages/SettingsPage.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; +import { + useQuery, + useMutation, + useQueryClient, +} from '@tanstack/react-query' +import { Config, frigateApi } from '../services/frigate.proxy/frigate.api'; +import CenterLoader from '../shared/components/CenterLoader'; +import RetryError from './RetryError'; +import { Button, Flex, Space } from '@mantine/core'; +import { FloatingLabelInput } from '../shared/components/FloatingLabelInput'; +import { strings } from '../shared/strings/strings'; +import { dimensions } from '../shared/dimensions/dimensions'; +import { useMediaQuery } from '@mantine/hooks'; + +const SettingsPage = () => { + const queryClient = useQueryClient() + const { isPending: configPending, error: configError, data, refetch } = useQuery({ + queryKey: ['config'], + queryFn: frigateApi.getConfig, + }) + + const ecryptedValue = '**********' + const mapEncryptedToView = (data: Config[] | undefined): Config[] | undefined => { + return data?.map(item => { + const { value, encrypted, ...rest } = item + if (encrypted) return { value: ecryptedValue, encrypted, ...rest } + return item + }) + } + + const [configs, setConfigs] = useState(data) + const isMobile = useMediaQuery(dimensions.mobileSize) + + const mutation = useMutation({ + mutationFn: frigateApi.putConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['config'] }) + }, + }) + + const handleDiscard = () => { + console.log('Discard changes') + refetch() + setConfigs(data ? mapEncryptedToView(data) : []) + } + useEffect(() => { + console.log('data changed') + setConfigs(mapEncryptedToView(data)) + }, [data]) + + useEffect(() => { + console.log('configs changed') + }, [configs]) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + const formData = new FormData(event.currentTarget); + const formDataObj: any = Array.from(formData.entries()).reduce((acc, [key, value]) => ({ + ...acc, + [key]: value, + }), {}); + + const configsToUpdate = Object.keys(formDataObj).map(key => { + const value = formDataObj[key] + const currData = data?.find( val => val.key === key) + const isEncrypted = value === ecryptedValue + if (currData && currData.encrypted && isEncrypted) { + return { + key, + value: currData.value + } + } + return { + key, + value: value, + } + }); + console.log('configsToUpdate', configsToUpdate) + mutation.mutate(configsToUpdate); + } + + if (configPending) return + + if (configError) return + + return ( + + {!isMobile ? + < Space w='20%' /> + : <> + } + +
+ {!configs ? <> + : + configs.map((config) => ( + + ))} + + + + + + +
+ {!isMobile ? + + : <> + } +
+ ); +}; + +export default SettingsPage; \ No newline at end of file diff --git a/src/router/frigate.routes.ts b/src/router/frigate.routes.ts index 6a383f6..bea10a7 100644 --- a/src/router/frigate.routes.ts +++ b/src/router/frigate.routes.ts @@ -1,4 +1,4 @@ -import { hostURL } from "../shared/env.const" +import { hostURL } from ".." export const cameraLiveViewURL = (host: string, cameraName: string) => { return `ws://${hostURL.host}/proxy-ws/live/jsmpeg/${cameraName}?hostName=${host}` diff --git a/src/router/routes.path.ts b/src/router/routes.path.ts index e62865b..210975c 100644 --- a/src/router/routes.path.ts +++ b/src/router/routes.path.ts @@ -1,6 +1,12 @@ export const pathRoutes = { MAIN_PATH: '/', - ADMIN_PATH: '/admin', + BIRDSEYE_PATH: '/birdseye', + EVENTS_PATH: '/events', + RECORDINGS_PATH: '/recordings', + SETTINGS_PATH: '/settings', + HOST_CONFIG_PATH: '/host-config', + ROLES_PATH: '/roles', + LIVE_PATH: '/live', THANKS_PATH: '/thanks', USER_DETAILED_PATH: '/user', RETRY_ERROR_PATH: '/retry_error', diff --git a/src/router/routes.tsx b/src/router/routes.tsx index bde54d1..c6136a1 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -5,7 +5,8 @@ import {pathRoutes} from "./routes.path"; import RetryError from "../pages/RetryError"; import Forbidden from "../pages/403"; import NotFound from "../pages/404"; -import AdminPage from "../pages/Admin"; +import SettingsPage from "../pages/SettingsPage"; +import FrigateHostsPage from "../pages/FrigateHostsPage"; interface IRoute { path: string, @@ -18,8 +19,12 @@ export const routes: IRoute[] = [ component: , }, { - path: pathRoutes.ADMIN_PATH, - component: , + path: pathRoutes.SETTINGS_PATH, + component: , + }, + { + path: pathRoutes.HOST_CONFIG_PATH, + component: , }, { path: pathRoutes.MAIN_PATH, diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts new file mode 100644 index 0000000..cbd2fb3 --- /dev/null +++ b/src/services/frigate.proxy/frigate.api.ts @@ -0,0 +1,36 @@ +import axios from "axios" +import { proxyURL } from "../../shared/env.const" + +export interface Config { + key: string, + value: string, + description: string, + encrypted: boolean, +} +export interface PutConfig { + key: string, + value: string, +} + +export interface FrigateHost { + id: string + createAt: string + updateAt: string + name: string + host: string + enabled: boolean +} + +const instance = axios.create({ + baseURL: proxyURL.toString(), + timeout: 30000, +}); + +export const frigateApi = { + getConfig: () => instance.get('apiv1/config').then(res => res.data), + putConfig: (config: PutConfig[]) => instance.put('apiv1/config', config).then(res => res.data), + getHosts: () => instance.get('apiv1/frigate-hosts').then(res => { + // return new Map(res.data.map(item => [item.id, item])) + return res.data + }), +} \ No newline at end of file diff --git a/src/shared/components/FloatingLabelInput.tsx b/src/shared/components/FloatingLabelInput.tsx new file mode 100644 index 0000000..ce20a82 --- /dev/null +++ b/src/shared/components/FloatingLabelInput.tsx @@ -0,0 +1,48 @@ +import { TextInput, TextInputProps, createStyles } from '@mantine/core'; +import { useEffect, useState } from 'react'; +import classes from './css/FloatingLabelInput.module.css'; + +interface FloatingLabelInputProps extends TextInputProps { + value?: string, + ecryptedValue?: string, + onChangeValue?: (key: string, value: string) => void +} + + +export const FloatingLabelInput = (props: FloatingLabelInputProps) => { + const { value: propVal, onChangeValue, ecryptedValue, ...rest } = props + const [focused, setFocused] = useState(false); + const [value, setValue] = useState(propVal || ''); + const floating = value?.trim().length !== 0 || focused || undefined; + + useEffect(() => { + setValue(propVal || '') + }, [propVal]); + + const handleFocused = (event: React.FocusEvent) => { + setFocused(true) + event.target.select() + } + + const handleChange = (event: React.ChangeEvent) => { + setValue(event.currentTarget.value); + if (onChangeValue && props.name) { + onChangeValue(props.name, event.currentTarget.value); + } + } + + return ( + setFocused(false)} + mt='2rem' + autoComplete="nope" + data-floating={floating} + labelProps={{ 'data-floating': floating }} + {...rest} + /> + ); +} \ No newline at end of file diff --git a/src/shared/components/UserMenu.tsx b/src/shared/components/UserMenu.tsx index c904124..ae7c5ef 100644 --- a/src/shared/components/UserMenu.tsx +++ b/src/shared/components/UserMenu.tsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; import { Avatar, createStyles, Group, Menu, UnstyledButton, Text, Button, Flex } from "@mantine/core"; import { useAuth } from 'react-oidc-context'; -import { keycloakConfig } from '../services/keycloack'; import { strings } from '../strings/strings'; import { useMediaQuery } from '@mantine/hooks'; import { dimensions } from '../dimensions/dimensions'; import ColorSchemeToggle from './ColorSchemeToggle'; import { useNavigate } from 'react-router-dom'; +import { keycloakConfig } from '../..'; interface UserMenuProps { user: { name: string; image: string } diff --git a/src/shared/components/css/FloatingLabelInput.module.css b/src/shared/components/css/FloatingLabelInput.module.css new file mode 100644 index 0000000..cc5cd57 --- /dev/null +++ b/src/shared/components/css/FloatingLabelInput.module.css @@ -0,0 +1,44 @@ +.root { + position: relative; + } + + .label { + position: absolute; + z-index: 2; + top: rem(7px); + left: 0.5rem; + pointer-events: none; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + transition: + transform 150ms ease, + font-size 150ms ease, + color 150ms ease; + + &[data-floating] { + transform: translate(-0.5rem, -1.625rem); + font-size: var(--mantine-font-size-xm); + font-weight: 500; + } + } + + .required { + transition: opacity 150ms ease; + opacity: 0; + + [data-floating] & { + opacity: 1; + } + } + + .input { + &::placeholder { + transition: color 150ms ease; + color: transparent; + } + + &[data-floating] { + &::placeholder { + color: var(--mantine-color-placeholder); + } + } + } \ No newline at end of file diff --git a/src/shared/components/table.aps/SortedTh.tsx b/src/shared/components/table.aps/SortedTh.tsx index 6e4e15b..1075feb 100644 --- a/src/shared/components/table.aps/SortedTh.tsx +++ b/src/shared/components/table.aps/SortedTh.tsx @@ -14,9 +14,12 @@ interface SortedThProps { const SortedTh = ({ sortedName, reversed, title, onSort, textProps, sorting=true }: SortedThProps) => { const sorted = sortedName === title const Icon = sorted ? (reversed ? IconChevronUp : IconChevronDown) : IconSelector; + const handleClick = () => { + if (sorting) onSort(title) + } return ( -
onSort(title)}> +
{title} diff --git a/src/shared/components/table.aps/SwitchCell.tsx b/src/shared/components/table.aps/SwitchCell.tsx new file mode 100644 index 0000000..14b2bbc --- /dev/null +++ b/src/shared/components/table.aps/SwitchCell.tsx @@ -0,0 +1,36 @@ +import { Flex, Switch, useMantineTheme } from '@mantine/core'; +import { IconBulbFilled, IconBulbOff } from '@tabler/icons-react'; +import React from 'react'; + +interface SwithCellProps { + value?: boolean, + defaultValue?: boolean, + width?: string, + id?: string | number, + propertyName?: string, + toggle?: (id: string | number, propertyName: string, value: string) => void, +} + +export const SwitchCell = ( { value, defaultValue, width, id, propertyName, toggle }: SwithCellProps ) => { + const theme = useMantineTheme(); + + if (typeof value === undefined && typeof defaultValue !== undefined) value = defaultValue + const handleChange = (event: React.ChangeEvent) => { + if (id && toggle && propertyName) toggle(id, propertyName, event.target.value) + } + return ( + + + } + offLabel={} + /> + + + ) +} + +export default SwitchCell; \ No newline at end of file diff --git a/src/shared/components/table.aps/TableRow.tsx b/src/shared/components/table.aps/TableRow.tsx index b5f2aa5..a4fe74a 100644 --- a/src/shared/components/table.aps/TableRow.tsx +++ b/src/shared/components/table.aps/TableRow.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useRef, useState } from 'react'; import RowCounter from './RowCounter'; import { Badge, Center, Flex, Group, Text, createStyles } from '@mantine/core'; -import { TableAdapter } from './ProductTable'; +import { TableAdapter } from '../../../widgets/ProductTable'; import ImageWithPlaceHolder from '../ImageWithPlaceHolder'; import Currency from '../Currency'; import { Context } from '../../..'; diff --git a/src/shared/components/table.aps/TextInputCell.tsx b/src/shared/components/table.aps/TextInputCell.tsx new file mode 100644 index 0000000..1fd6707 --- /dev/null +++ b/src/shared/components/table.aps/TextInputCell.tsx @@ -0,0 +1,28 @@ +import { TextInput } from '@mantine/core'; +import React from 'react'; + +interface TextImputCellProps { + text?: string | number | boolean, + width?: string, + id?: string | number, + propertyName?: string, + onChange?: ( + id: string | number, + propertyName: string, + value: string, + ) => void, +} + +const TextInputCell = ({ text, width, id, propertyName, onChange }: TextImputCellProps) => { + const handleChange = (event: React.ChangeEvent) => { + if (id && propertyName && onChange) + onChange(id, propertyName, event.currentTarget.value) + } + return ( + + + + ) +} + +export default TextInputCell; \ No newline at end of file diff --git a/src/shared/components/table.aps/useSortedData.ts b/src/shared/components/table.aps/useSortedData.ts new file mode 100644 index 0000000..058c993 --- /dev/null +++ b/src/shared/components/table.aps/useSortedData.ts @@ -0,0 +1,30 @@ +import { useState, useMemo } from 'react'; + +type SortDirection = 'asc' | 'desc' | null; + +function useSortedData(items: T[], initialSortKey: K | null = null) { + const [sortKey, setSortKey] = useState(initialSortKey); + const [sortDirection, setSortDirection] = useState(null); + + const sortedItems = useMemo(() => { + if (!sortKey) return items; + return [...items].sort((a, b) => { + if (a[sortKey] < b[sortKey]) return sortDirection === 'asc' ? -1 : 1; + if (a[sortKey] > b[sortKey]) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + }, [items, sortKey, sortDirection]); + + const requestSort = (key: K) => { + if (key === sortKey) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDirection('asc'); + } + }; + + return { items: sortedItems, requestSort, sortKey, sortDirection }; +} + +export default useSortedData; \ No newline at end of file diff --git a/src/shared/env.const.ts b/src/shared/env.const.ts index cb3b09f..507b789 100644 --- a/src/shared/env.const.ts +++ b/src/shared/env.const.ts @@ -3,13 +3,12 @@ const isProduction = appMode === "production" if (isProduction && typeof process.env.REACT_APP_HOST === 'undefined') { throw new Error('REACT_APP_HOST environment variable is undefined'); } -export const host = process.env.REACT_APP_HOST || 'localhost' +export const host = process.env.REACT_APP_HOST if (isProduction && typeof process.env.REACT_APP_PORT === 'undefined') { throw new Error('REACT_APP_PORT environment variable is undefined'); } -export const port = process.env.REACT_APP_PORT || '4000' -export const hostURL = new URL('http://' + host + ':' + port) +export const port = process.env.REACT_APP_PORT if (typeof process.env.REACT_APP_FRIGATE_PROXY === 'undefined') { throw new Error('REACT_APP_FRIGATE_PROXY environment variable is undefined'); @@ -19,11 +18,11 @@ export const proxyURL = new URL(process.env.REACT_APP_FRIGATE_PROXY) if (typeof process.env.REACT_APP_OPENID_SERVER === 'undefined') { throw new Error('REACT_APP_OPENID_SERVER environment variable is undefined'); } -export const openIdServer = process.env.REACT_APP_OPENID_SERVER - - if (typeof process.env.REACT_APP_CLIENT_ID === 'undefined') { throw new Error('REACT_APP_CLIENT_ID environment variable is undefined'); } -export const clientId = process.env.REACT_APP_CLIENT_ID -export const redirectURL = hostURL.toString() \ No newline at end of file + +export const oidpSettings = { + server: process.env.REACT_APP_OPENID_SERVER, + clientId: process.env.REACT_APP_CLIENT_ID, +} \ No newline at end of file diff --git a/src/shared/services/keycloack.ts b/src/shared/services/keycloack.ts deleted file mode 100644 index 8bb4c84..0000000 --- a/src/shared/services/keycloack.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AuthProviderProps } from "react-oidc-context"; -import { openIdServer, clientId, redirectURL } from "../env.const"; - -export const keycloakConfig: AuthProviderProps = { - authority: openIdServer, - client_id: clientId, - redirect_uri: redirectURL, - onSigninCallback: () => { - window.history.replaceState( - {}, - document.title, - window.location.pathname - ) - } -} \ No newline at end of file diff --git a/src/shared/stores/cart.store.ts b/src/shared/stores/cart.store.ts index 1200096..e1ebcb2 100644 --- a/src/shared/stores/cart.store.ts +++ b/src/shared/stores/cart.store.ts @@ -1,7 +1,7 @@ import { makeAutoObservable, runInAction } from "mobx"; import { Product } from "./product.store"; import { sleep } from "../utils/async.sleep"; -import { TableAdapter } from "../components/table.aps/ProductTable"; +import { TableAdapter } from "../../widgets/ProductTable"; import { DeliveryMethod, DeliveryMethods, PaymentMethod, PaymentMethods } from "./orders.store"; import { addItem, removeItemById } from "../utils/array.helper"; import { strings } from "../strings/strings"; diff --git a/src/shared/stores/product.store.ts b/src/shared/stores/product.store.ts index 1c92539..7ca6ac5 100644 --- a/src/shared/stores/product.store.ts +++ b/src/shared/stores/product.store.ts @@ -1,5 +1,5 @@ import { makeAutoObservable, runInAction } from "mobx"; -import { TableAdapter } from "../components/table.aps/ProductTable"; +import { TableAdapter } from "../../widgets/ProductTable"; import { GridAdapter } from "../components/grid.aps/ProductGrid"; import { sleep } from "../utils/async.sleep"; import { CartProduct } from "./cart.store"; diff --git a/src/shared/stores/root.store.ts b/src/shared/stores/root.store.ts index 04f3ead..6da48ff 100644 --- a/src/shared/stores/root.store.ts +++ b/src/shared/stores/root.store.ts @@ -4,6 +4,7 @@ import { FiltersStore } from "./filters/filters.store"; import { ModalStore } from "./modal.store"; import { OrdersStore } from "./orders.store"; import { ProductStore } from "./product.store"; +import { SettingsStore } from "./settings.store"; import { SideBarsStore } from "./sidebars.store"; import PostStore from "./test.store"; import { UserStore } from "./user.store"; @@ -18,6 +19,7 @@ class RootStore { filtersStore: FiltersStore sideBarsStore: SideBarsStore ordersStore: OrdersStore + settingsStore: SettingsStore constructor() { this.userStore = new UserStore() this.productStore = new ProductStore(this) @@ -28,6 +30,7 @@ class RootStore { this.filtersStore = new FiltersStore() this.sideBarsStore = new SideBarsStore() this.ordersStore = new OrdersStore() + this.settingsStore = new SettingsStore() } } diff --git a/src/shared/stores/settings.store.ts b/src/shared/stores/settings.store.ts new file mode 100644 index 0000000..1e28765 --- /dev/null +++ b/src/shared/stores/settings.store.ts @@ -0,0 +1,4 @@ + + +export class SettingsStore { +} \ No newline at end of file diff --git a/src/shared/stores/user.store.ts b/src/shared/stores/user.store.ts index 41ff2c3..2665e1a 100644 --- a/src/shared/stores/user.store.ts +++ b/src/shared/stores/user.store.ts @@ -1,9 +1,9 @@ import { makeAutoObservable, runInAction } from "mobx" import { User } from "oidc-client-ts"; -import { keycloakConfig } from "../services/keycloack"; import { Resource } from "../utils/resource" import { sleep } from "../utils/async.sleep"; import { z } from 'zod' +import { keycloakConfig } from "../.."; export interface UserServer { diff --git a/src/shared/strings/header.menu.strings.ts b/src/shared/strings/header.menu.strings.ts index e4bceb8..c945373 100644 --- a/src/shared/strings/header.menu.strings.ts +++ b/src/shared/strings/header.menu.strings.ts @@ -1,4 +1,7 @@ export const headerMenu = { home:"На главную", test:"Тест", + settings:"Настройки", + rolesAcess:"Доступ", + hostsConfig:"Серверы Frigate", } diff --git a/src/shared/strings/strings.ts b/src/shared/strings/strings.ts index a5b7f34..0bc32e8 100644 --- a/src/shared/strings/strings.ts +++ b/src/shared/strings/strings.ts @@ -1,4 +1,9 @@ export const strings = { + host: { + name: 'Имя хоста', + url: 'Адрес', + enabled: 'Включен', + }, // user section aboutMe: "Обо мне", settings: "Настройки", @@ -20,6 +25,7 @@ export const strings = { schedule: "Расписание", address: "Адрес", edit: "Изменить", + delete: "Удалить", error: "Ошибка", discounts: "Скидки", delivery: "Доставка", @@ -54,6 +60,7 @@ export const strings = { weight: "Вес:", total: "Итого:", confirm: "Подтвердить", + discard: "Отменить", next: "Далее", back: "Назад", paymentMethod: "Метод оплаты", diff --git a/src/shared/utils/debounce.ts b/src/shared/utils/debounce.ts new file mode 100644 index 0000000..a8078fb --- /dev/null +++ b/src/shared/utils/debounce.ts @@ -0,0 +1,13 @@ +export function debounce any>(func: T, wait: number): (...funcArgs: Parameters) => void { + let timeout: ReturnType | null = null; + return function(...args: Parameters): void { + const later = () => { + timeout = null; + func(...args); + }; + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(later, wait); + }; +} \ No newline at end of file diff --git a/src/shared/utils/sort.array.ts b/src/shared/utils/sort.array.ts new file mode 100644 index 0000000..07ecc01 --- /dev/null +++ b/src/shared/utils/sort.array.ts @@ -0,0 +1,19 @@ + /** + * Function get array and sort it by index of key + * @param uniqueValue Name of table head, to change image on it + * @param objectIndex Index of head, must be equal to idex of array object + * @param arrayData Array data + * @param reverse If you need to reverse array + * @returns uniqueValue and sorted array + */ +export function sortArrayByObjectIndex( + objectIndex: number, + arrayData: T[], + callBack: (arrayData: T[], key: string | number | symbol) => void, + reverse?: boolean, + ) { + if (arrayData.length === 0) throw Error('handleSort failed, array is empty') + const keys = Object.keys(arrayData[0]) + const key = keys[objectIndex] + callBack(arrayData, key as keyof T) + } \ No newline at end of file diff --git a/src/shared/components/table.aps/DeliveryPointsTable.tsx b/src/widgets/DeliveryPointsTable.tsx similarity index 60% rename from src/shared/components/table.aps/DeliveryPointsTable.tsx rename to src/widgets/DeliveryPointsTable.tsx index 4e51f33..4e2ded5 100644 --- a/src/shared/components/table.aps/DeliveryPointsTable.tsx +++ b/src/widgets/DeliveryPointsTable.tsx @@ -1,8 +1,8 @@ import { Button, Table } from '@mantine/core'; import React, { useState } from 'react'; -import SortedTh from './SortedTh'; -import { DeliveryPoint } from '../../stores/user.store'; -import { strings } from '../../strings/strings'; +import SortedTh from '../shared/components/table.aps/SortedTh'; +import { DeliveryPoint } from '../shared/stores/user.store'; +import { strings } from '../shared/strings/strings'; import { v4 as uuidv4 } from 'uuid' @@ -20,34 +20,34 @@ const DeliveryPointsTable = ({ data }: DeliveryPointsTableProps) => { const handleSort = (headName: string, dataIndex: number) => { const reverse = headName === sortedName ? !reversed : false; setReversed(reverse) + const keys = Object.keys(data[0]) as Array + const key = keys[dataIndex] if (reverse) { - setData(sortByKey(data, dataIndex).reverse()) + setData(sortByKey(data, key).reverse()) } else { - setData(sortByKey(data, dataIndex)) + setData(sortByKey(data, key)) } setSortedName(headName) } - const sortByKey = (deliveryPoints: DeliveryPoint[], keyIndex: number): DeliveryPoint[] => { - const keys = Object.keys(deliveryPoints[0]) as Array - return deliveryPoints.sort((a, b) => { - const valueA = a[keys[keyIndex]].toLowerCase(); - const valueB = b[keys[keyIndex]].toLowerCase(); + function sortByKey(array: T[], key: K): T[] { + return array.sort((a, b) => { + let valueA = a[key]; + let valueB = b[key]; - if (valueA < valueB) { - return -1; - } - if (valueA > valueB) { - return 1; - } + const stringValueA = String(valueA).toLowerCase(); + const stringValueB = String(valueB).toLowerCase(); + + if (stringValueA < stringValueB) return -1; + if (stringValueA > stringValueB) return 1; return 0; }); } const headTitle = [ - { dataIndex:1, title: strings.name }, - { dataIndex:2, title: strings.schedule }, - { dataIndex:3, title: strings.address }, + { propertyIndex: 1, title: strings.name }, + { propertyIndex: 2, title: strings.schedule }, + { propertyIndex: 3, title: strings.address }, { title: '', sorting: false }, ] @@ -58,7 +58,7 @@ const DeliveryPointsTable = ({ data }: DeliveryPointsTableProps) => { title={head.title} reversed={reversed} sortedName={sortedName} - onSort={() => handleSort(head.title, head.dataIndex ? head.dataIndex : 0)} + onSort={() => handleSort(head.title, head.propertyIndex ? head.propertyIndex : 0)} sorting={head.sorting} /> ) }) diff --git a/src/widgets/FrigateHostsTable.tsx b/src/widgets/FrigateHostsTable.tsx new file mode 100644 index 0000000..2cf7d26 --- /dev/null +++ b/src/widgets/FrigateHostsTable.tsx @@ -0,0 +1,144 @@ +import { Button, Flex, Switch, Table, Text, TextInput, useMantineTheme } from '@mantine/core'; +import React, { useState } from 'react'; +import SortedTh from '../shared/components/table.aps/SortedTh'; +import { strings } from '../shared/strings/strings'; +import { v4 as uuidv4 } from 'uuid' +import { FrigateHost } from '../services/frigate.proxy/frigate.api'; +import { IconBulbFilled, IconBulbOff, IconDeviceFloppy, IconPencil, IconPlus, IconSettings, IconTrash } from '@tabler/icons-react'; +import SwitchCell from '../shared/components/table.aps/SwitchCell'; +import TextInputCell from '../shared/components/table.aps/TextInputCell'; + +interface FrigateHostsTableProps { + data: FrigateHost[], + showAddButton?: boolean, + handleInputChange?: () => void, + handleSwtitchToggle?: () => void, +} + +const FrigateHostsTable = ({ data, showAddButton = false, handleInputChange, handleSwtitchToggle }: FrigateHostsTableProps) => { + const [tableData, setData] = useState(data) + const [reversed, setReversed] = useState(false) + const [sortedName, setSortedName] = useState(null) + + function sortByKey(array: T[], key: K): T[] { + return array.sort((a, b) => { + let valueA = a[key]; + let valueB = b[key]; + + const stringValueA = String(valueA).toLowerCase(); + const stringValueB = String(valueB).toLowerCase(); + + if (stringValueA < stringValueB) return -1; + if (stringValueA > stringValueB) return 1; + return 0; + }); + } + + const handleSort = (headName: string, propertyName: string,) => { + const reverse = headName === sortedName ? !reversed : false; + setReversed(reverse) + const arr = sortByKey(tableData, propertyName as keyof FrigateHost) + if (reverse) arr.reverse() + setData(arr) + setSortedName(headName) + } + + const headTitle = [ + { propertyName: 'name', title: strings.host.name }, + { propertyName: 'host', title: strings.host.url }, + { propertyName: 'enabled', title: strings.host.enabled }, + { title: '', sorting: false }, + ] + + const tableHead = headTitle.map(head => { + return ( + handleSort(head.title, head.propertyName ? head.propertyName : '')} + sorting={head.sorting} /> + ) + }) + + const handleTextChange = (id: string | number, propertyName: string, value: string,) => { + setData(tableData.map(item => { + if (item.id === id) { + return { + ...item, + [propertyName]: value, + }; + } + return item; + })); + } + const handleSwitchChange = (id: string | number, propertyName: string, value: string,) => { + setData(tableData.map(item => { + if (item.id === id) { + return { + ...item, + [propertyName]: !item.enabled, + }; + } + return item; + })); + } + + const handleDeleteRow = (id: string | number) => { + setData(tableData.filter(item => item.id !== id)) + } + + const handleAddRow = () => { + const newHost: FrigateHost = { + id: String(Math.random()), + createAt: '', + updateAt: '', + host: '', + name: '', + enabled: true + } + setData(prevTableData => [...prevTableData, newHost]) + } + + const rows = tableData.map(item => { + return ( + + + + {/* {textInputCell(item.host, '40%', item.id, 'host', handleTextChange)} */} + + + + + + + + + + ) + }) + + return ( +
+ + + + {tableHead} + + + + {rows} + +
+ {showAddButton ? + + + + : <> + } +
+ ); +}; + +export default FrigateHostsTable; \ No newline at end of file diff --git a/src/shared/components/table.aps/ProductTable.tsx b/src/widgets/ProductTable.tsx similarity index 94% rename from src/shared/components/table.aps/ProductTable.tsx rename to src/widgets/ProductTable.tsx index 58a5840..dc41b5c 100644 --- a/src/shared/components/table.aps/ProductTable.tsx +++ b/src/widgets/ProductTable.tsx @@ -1,11 +1,11 @@ import { Table, } from '@mantine/core'; import { useContext, useEffect, useState } from 'react'; import { useDisclosure, useHotkeys, } from '@mantine/hooks'; -import TableRow from './TableRow'; -import InputModal from '../InputModal'; -import { Context } from '../../..'; +import TableRow from '../shared/components/table.aps/TableRow'; +import InputModal from '../shared/components/InputModal'; +import { Context } from '..'; import { v4 as uuidv4 } from 'uuid' -import ProductsTableHead from './ProductsTableHead'; +import ProductsTableHead from '../shared/components/table.aps/ProductsTableHead'; import { observer } from 'mobx-react-lite'; export type TableAdapter = { diff --git a/src/widgets/header/header.links.ts b/src/widgets/header/header.links.ts index 28c1441..dbd2460 100644 --- a/src/widgets/header/header.links.ts +++ b/src/widgets/header/header.links.ts @@ -7,5 +7,8 @@ export const testHeaderLinks: HeaderActionProps = links: [ {link: pathRoutes.MAIN_PATH, label: headerMenu.home, links: []}, {link: pathRoutes.TEST_PATH, label: headerMenu.test, links: []}, + {link: pathRoutes.SETTINGS_PATH, label: headerMenu.settings, links: []}, + {link: pathRoutes.HOST_CONFIG_PATH, label: headerMenu.hostsConfig, links: []}, + {link: pathRoutes.ROLES_PATH, label: headerMenu.rolesAcess, links: []}, ] } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ff368c2..bebb0d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2150,6 +2150,18 @@ dependencies: remove-accents "0.4.2" +"@tanstack/query-core@5.21.2": + version "5.21.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.21.2.tgz#543bc44f09274d965b54640567de4cd8ccbe2831" + integrity sha512-jg7OcDG44oLT3uuGQQ9BM65ZBIdAq9xNZXPEk7Gr6w1oM1wo2/95H3dPDjLVs0yZKwrmE/ORUOC2Pyi1sp2fDA== + +"@tanstack/react-query@^5.21.2": + version "5.21.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.21.2.tgz#6bdf30f8afa7623989ce6b7bfda1a9fefe6b1811" + integrity sha512-/Vv1qTumNDDVA5EYk40kivHZ2kICs1w38GBLRvV6A/lrixUJR5bfqZMKqHA1S6ND3gR9hvSyAAYejBbjLrQnSA== + dependencies: + "@tanstack/query-core" "5.21.2" + "@tanstack/react-table@8.9.3": version "8.9.3" resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.9.3.tgz#03a52e9e15f65c82a8c697a445c42bfca0c5cfc4"