add setting
add frigate configs
This commit is contained in:
parent
ece046d2fd
commit
d68edcf6f2
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,6 +21,7 @@
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.idea/*
|
||||
.vscode
|
||||
|
||||
#docker
|
||||
docker-compose.*
|
||||
|
||||
@ -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",
|
||||
|
||||
29
src/App.tsx
29
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<ColorScheme>(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 <CenterLoader />
|
||||
// }
|
||||
// if ((!auth.isAuthenticated && !auth.isLoading) || auth.error) {
|
||||
// return <Forbidden />
|
||||
// }
|
||||
if (auth.activeNavigator || auth.isLoading) {
|
||||
return <CenterLoader />
|
||||
}
|
||||
if ((!auth.isAuthenticated && !auth.isLoading) || auth.error) {
|
||||
console.error(`auth.error:`, auth.error)
|
||||
return <Forbidden />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
|
||||
@ -1,28 +1,33 @@
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppShell
|
||||
styles={{
|
||||
main: {
|
||||
@ -37,12 +42,13 @@ const AppBody = () => {
|
||||
header={
|
||||
<HeaderAction links={testHeaderLinks.links} />
|
||||
}
|
||||
navbar={
|
||||
<SideBar isHidden={leftSideBarIsHidden} side="left" />
|
||||
aside={
|
||||
<SideBar isHidden={rightSideBarIsHidden} side="right" />
|
||||
}
|
||||
>
|
||||
<AppRouter />
|
||||
</AppShell>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@ -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>(rootStore)
|
||||
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const AdminPage = () => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
26
src/pages/FrigateHostsPage.tsx
Normal file
26
src/pages/FrigateHostsPage.tsx
Normal file
@ -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 <CenterLoader />
|
||||
|
||||
if (hostsError) return <RetryError />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FrigateHostsTable data={data} showAddButton/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrigateHostsPage;
|
||||
@ -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';
|
||||
|
||||
122
src/pages/SettingsPage.tsx
Normal file
122
src/pages/SettingsPage.tsx
Normal file
@ -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<HTMLFormElement>) => {
|
||||
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 <CenterLoader />
|
||||
|
||||
if (configError) return <RetryError />
|
||||
|
||||
return (
|
||||
<Flex h='100%'>
|
||||
{!isMobile ?
|
||||
< Space w='20%' />
|
||||
: <></>
|
||||
}
|
||||
<Flex direction='column' h='100%' w='100%' justify='stretch'>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{!configs ? <></>
|
||||
:
|
||||
configs.map((config) => (
|
||||
<FloatingLabelInput
|
||||
key={`${config.key}-${new Date().getTime()}`}
|
||||
name={config.key}
|
||||
label={config.description}
|
||||
value={config.value}
|
||||
placeholder={config.description}
|
||||
ecryptedValue={ecryptedValue}
|
||||
/>
|
||||
))}
|
||||
<Space h='2%' />
|
||||
<Flex w='100%' justify='stretch' wrap='nowrap' align='center'>
|
||||
<Button w='100%' onClick={handleDiscard} m='0.5rem'>{strings.discard}</Button>
|
||||
<Button w='100%' type="submit" m='0.5rem'>{strings.confirm}</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Flex>
|
||||
{!isMobile ?
|
||||
<Space w='20%' />
|
||||
: <></>
|
||||
}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@ -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}`
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: <Test />,
|
||||
},
|
||||
{
|
||||
path: pathRoutes.ADMIN_PATH,
|
||||
component: <AdminPage />,
|
||||
path: pathRoutes.SETTINGS_PATH,
|
||||
component: <SettingsPage />,
|
||||
},
|
||||
{
|
||||
path: pathRoutes.HOST_CONFIG_PATH,
|
||||
component: <FrigateHostsPage />,
|
||||
},
|
||||
{
|
||||
path: pathRoutes.MAIN_PATH,
|
||||
|
||||
36
src/services/frigate.proxy/frigate.api.ts
Normal file
36
src/services/frigate.proxy/frigate.api.ts
Normal file
@ -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<Config[]>('apiv1/config').then(res => res.data),
|
||||
putConfig: (config: PutConfig[]) => instance.put('apiv1/config', config).then(res => res.data),
|
||||
getHosts: () => instance.get<FrigateHost[]>('apiv1/frigate-hosts').then(res => {
|
||||
// return new Map(res.data.map(item => [item.id, item]))
|
||||
return res.data
|
||||
}),
|
||||
}
|
||||
48
src/shared/components/FloatingLabelInput.tsx
Normal file
48
src/shared/components/FloatingLabelInput.tsx
Normal file
@ -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<HTMLInputElement>) => {
|
||||
setFocused(true)
|
||||
event.target.select()
|
||||
}
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.currentTarget.value);
|
||||
if (onChangeValue && props.name) {
|
||||
onChangeValue(props.name, event.currentTarget.value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
classNames={classes}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocused}
|
||||
onBlur={() => setFocused(false)}
|
||||
mt='2rem'
|
||||
autoComplete="nope"
|
||||
data-floating={floating}
|
||||
labelProps={{ 'data-floating': floating }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
44
src/shared/components/css/FloatingLabelInput.module.css
Normal file
44
src/shared/components/css/FloatingLabelInput.module.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 (
|
||||
<th style={{paddingLeft: 5, paddingRight: 5}}>
|
||||
<Center onClick={() => onSort(title)}>
|
||||
<Center onClick={handleClick}>
|
||||
<Tooltip label={title} transitionProps={{ transition: 'slide-up', duration: 300 }} openDelay={500}>
|
||||
<Text {...textProps}>{title}</Text>
|
||||
</Tooltip>
|
||||
|
||||
36
src/shared/components/table.aps/SwitchCell.tsx
Normal file
36
src/shared/components/table.aps/SwitchCell.tsx
Normal file
@ -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<HTMLInputElement>) => {
|
||||
if (id && toggle && propertyName) toggle(id, propertyName, event.target.value)
|
||||
}
|
||||
return (
|
||||
<td style={{ width: width }}>
|
||||
<Flex w='100%' justify='center'>
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={handleChange}
|
||||
size="lg"
|
||||
onLabel={<IconBulbFilled color={theme.colors.green[5]} size="1.25rem" stroke={1.5} />}
|
||||
offLabel={<IconBulbOff color={theme.colors.gray[6]} size="1.25rem" stroke={1.5} />}
|
||||
/>
|
||||
</Flex>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export default SwitchCell;
|
||||
@ -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 '../../..';
|
||||
|
||||
28
src/shared/components/table.aps/TextInputCell.tsx
Normal file
28
src/shared/components/table.aps/TextInputCell.tsx
Normal file
@ -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<HTMLInputElement>) => {
|
||||
if (id && propertyName && onChange)
|
||||
onChange(id, propertyName, event.currentTarget.value)
|
||||
}
|
||||
return (
|
||||
<td style={{ width: width, textAlign: 'center' }}>
|
||||
<TextInput onChange={handleChange} size='sm' value={String(text)} />
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextInputCell;
|
||||
30
src/shared/components/table.aps/useSortedData.ts
Normal file
30
src/shared/components/table.aps/useSortedData.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
type SortDirection = 'asc' | 'desc' | null;
|
||||
|
||||
function useSortedData<T, K extends keyof T>(items: T[], initialSortKey: K | null = null) {
|
||||
const [sortKey, setSortKey] = useState<K | null>(initialSortKey);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>(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;
|
||||
@ -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()
|
||||
|
||||
export const oidpSettings = {
|
||||
server: process.env.REACT_APP_OPENID_SERVER,
|
||||
clientId: process.env.REACT_APP_CLIENT_ID,
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
src/shared/stores/settings.store.ts
Normal file
4
src/shared/stores/settings.store.ts
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
export class SettingsStore {
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
export const headerMenu = {
|
||||
home:"На главную",
|
||||
test:"Тест",
|
||||
settings:"Настройки",
|
||||
rolesAcess:"Доступ",
|
||||
hostsConfig:"Серверы Frigate",
|
||||
}
|
||||
|
||||
@ -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: "Метод оплаты",
|
||||
|
||||
13
src/shared/utils/debounce.ts
Normal file
13
src/shared/utils/debounce.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...funcArgs: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
return function(...args: Parameters<T>): void {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
func(...args);
|
||||
};
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
19
src/shared/utils/sort.array.ts
Normal file
19
src/shared/utils/sort.array.ts
Normal file
@ -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<T extends object>(
|
||||
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)
|
||||
}
|
||||
@ -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<keyof DeliveryPoint>
|
||||
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<keyof DeliveryPoint>
|
||||
return deliveryPoints.sort((a, b) => {
|
||||
const valueA = a[keys[keyIndex]].toLowerCase();
|
||||
const valueB = b[keys[keyIndex]].toLowerCase();
|
||||
function sortByKey<T, K extends keyof T>(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} />
|
||||
)
|
||||
})
|
||||
144
src/widgets/FrigateHostsTable.tsx
Normal file
144
src/widgets/FrigateHostsTable.tsx
Normal file
@ -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<string | null>(null)
|
||||
|
||||
function sortByKey<T, K extends keyof T>(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 (
|
||||
<SortedTh
|
||||
key={uuidv4()}
|
||||
title={head.title}
|
||||
reversed={reversed}
|
||||
sortedName={sortedName}
|
||||
onSort={() => 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 (
|
||||
<tr key={item.id}>
|
||||
<TextInputCell text={item.name} width='40%' id={item.id} propertyName='name' onChange={handleTextChange} />
|
||||
<TextInputCell text={item.host} width='40%' id={item.id} propertyName='host' onChange={handleTextChange} />
|
||||
{/* {textInputCell(item.host, '40%', item.id, 'host', handleTextChange)} */}
|
||||
<SwitchCell value={item.enabled} width='10%' id={item.id} propertyName='enabled' toggle={handleSwitchChange} />
|
||||
<td align='right' style={{ width: '10%', padding: '0', }}>
|
||||
<Flex justify='center'>
|
||||
<Button size='xs' ><IconSettings /></Button>
|
||||
<Button size='xs' ><IconDeviceFloppy /></Button>
|
||||
<Button size='xs' onClick={() => handleDeleteRow(item.id)}><IconTrash /></Button>
|
||||
</Flex>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table >
|
||||
<thead>
|
||||
<tr>
|
||||
{tableHead}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</Table>
|
||||
{showAddButton ?
|
||||
<Flex w='100%' justify='end'>
|
||||
<Button size='xs' onClick={() => handleAddRow()}><IconPlus /></Button>
|
||||
</Flex>
|
||||
: <></>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrigateHostsTable;
|
||||
@ -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 = {
|
||||
@ -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: []},
|
||||
]
|
||||
}
|
||||
12
yarn.lock
12
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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user