diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c0e257d..7d58142 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { Admin, Resource, memoryStore } from 'react-admin'; import { ThemeProvider, StyledEngineProvider } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; import { LoginPage } from '@semapps/auth-provider'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient } from 'react-query'; @@ -14,7 +15,7 @@ import theme from './config/theme'; import resources from './resources'; import { Layout } from './common/layout'; -import { LayoutProvider } from './layouts/LayoutContext'; +import { LayoutProvider } from './layouts/LayoutProvider'; const queryClient = new QueryClient({ defaultOptions: { @@ -28,10 +29,11 @@ const App = () => ( + ({ + width: '100vw', + height: '100vh', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +})); + const BaseLayout = (props: LayoutProps) => { const { Layout } = useLayoutContext(); - return ; + + return ( + + + + } + > + + + ); }; export default BaseLayout; diff --git a/frontend/src/common/list/MobileMapPopupContent.jsx b/frontend/src/common/list/MobileMapPopupContent.jsx new file mode 100644 index 0000000..d3977f9 --- /dev/null +++ b/frontend/src/common/list/MobileMapPopupContent.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ShowButton, EditButton, useResourceDefinition, useRecordContext } from 'react-admin'; +import { Box, Typography } from '@mui/material'; +import { useLayoutContext } from '../../layouts/LayoutContext'; + +const MobileMapPopupContent = () => { + const record = useRecordContext(); + const resourceDefinition = useResourceDefinition({}); + const layout = useLayoutContext(); + + if (!record) return null; + + return ( + + {record.label && {record.label}} + {record.description && ( + + {record.description.length > 150 ? `${record.description.substring(0, 150)}...` : record.description} + + )} + {resourceDefinition.hasShow && } + {resourceDefinition.hasEdit && } + + ); +}; + +export default MobileMapPopupContent; diff --git a/frontend/src/config/config.ts b/frontend/src/config/config.ts index 7c3adc5..aee2f6a 100644 --- a/frontend/src/config/config.ts +++ b/frontend/src/config/config.ts @@ -1,4 +1,14 @@ -const config = { +import { LayoutOptions } from "../layouts/LayoutContext"; + +interface ConfigInterface { + middlewareUrl: string; + mapboxAccessToken: string; + importableResources: string[]; + title: string; + layout: LayoutOptions; +} + +const config: ConfigInterface = { // Middleware API url (ex: https://:/). Should contain a trailing slash. middlewareUrl: import.meta.env.VITE_MIDDLEWARE_URL, @@ -17,6 +27,9 @@ const config = { "Skill", ], + // Application title + title: 'Archipelago', + // UI layout configuration layout: { name: 'leftMenu', diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index f4850cc..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,18 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - background-color: #fafafa; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -body, .layout, .mde-text { - background-color: #efefef; -} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 3ce59e3..d701334 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import './index.css'; import App from './App'; const root = createRoot( diff --git a/frontend/src/layouts/LayoutContext.tsx b/frontend/src/layouts/LayoutContext.tsx index 4b7b214..fb7543c 100644 --- a/frontend/src/layouts/LayoutContext.tsx +++ b/frontend/src/layouts/LayoutContext.tsx @@ -1,6 +1,7 @@ -import React, { createContext, FunctionComponent, PropsWithChildren, ReactNode, useContext } from 'react'; +import { createContext, FunctionComponent, PropsWithChildren, ReactNode, useContext } from 'react'; import { LayoutProps } from 'react-admin'; -import leftMenu, { LayoutOptions as LeftMenuOptions } from './leftMenu'; +import { LayoutOptions as LeftMenuOptions } from './leftMenu'; +import { LayoutOptions as TopMenuOptions } from './topMenu'; type BaseViewProps = PropsWithChildren<{ title: string | ReactNode; @@ -15,30 +16,13 @@ export type LayoutComponents = { }; export type LeftMenuLayoutType = LayoutComponents & LeftMenuOptions; +export type TopMenuLayoutType = LayoutComponents & TopMenuOptions; -type LayoutOptions = LeftMenuOptions; -type LayoutContextType = LeftMenuLayoutType; +export type LayoutOptions = LeftMenuOptions | TopMenuOptions; +type LayoutContextType = LeftMenuLayoutType | TopMenuLayoutType; export const LayoutContext = createContext(undefined); -const layoutsComponents = { - leftMenu, -}; - -type LayoutProviderProps = PropsWithChildren<{ - layoutOptions: LayoutOptions; -}>; - -export const LayoutProvider = ({ children, layoutOptions }: LayoutProviderProps) => { - const layoutComponents = layoutsComponents[layoutOptions.name]; - - if (!layoutComponents) { - return null; - } - - return {children}; -}; - export const useLayoutContext = () => { const layout = useContext(LayoutContext); return layout as T & LayoutComponents; diff --git a/frontend/src/layouts/LayoutProvider.tsx b/frontend/src/layouts/LayoutProvider.tsx new file mode 100644 index 0000000..b9ed3dd --- /dev/null +++ b/frontend/src/layouts/LayoutProvider.tsx @@ -0,0 +1,24 @@ +import React, { PropsWithChildren } from 'react'; +import { LayoutContext, LayoutOptions } from './LayoutContext'; + +import leftMenu from './leftMenu'; +import topMenu from './topMenu'; + +const layoutsComponents = { + leftMenu, + topMenu, +}; + +type LayoutProviderProps = PropsWithChildren<{ + layoutOptions: LayoutOptions; +}>; + +export const LayoutProvider = ({ children, layoutOptions }: LayoutProviderProps) => { + const layoutComponents = layoutsComponents[layoutOptions.name]; + + if (!layoutComponents) { + return null; + } + + return {children}; +}; diff --git a/frontend/src/layouts/topMenu/AppBar.tsx b/frontend/src/layouts/topMenu/AppBar.tsx new file mode 100644 index 0000000..b094f6a --- /dev/null +++ b/frontend/src/layouts/topMenu/AppBar.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { AppBar as MuiAppBar, Box, Button, Stack, Toolbar, Typography, Link } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Link as RRLink } from 'react-router-dom'; +import config from '../../config/config'; +import UserMenu from './UserMenu'; +import ResourcesMenu from './ResourcesMenu'; +import { useLayoutContext } from '../LayoutContext'; +import { LayoutOptions } from '.'; + +const AppTitle = styled(Typography)(({ theme }) => ({ + textWrap: 'balance', + lineHeight: '1.1', + [theme.breakpoints.down('lg')]: { + fontSize: '1rem', + textAlign: 'center', + }, +})) as typeof Typography; + +const AppBar = () => { + const layout = useLayoutContext(); + const logo = layout.options.logo; + + return ( + theme.zIndex.drawer + 1 }}> + + {logo && ( + + + + + + )} + + {!(layout.options.title === false) && ( + <> + {(typeof layout.options.title === 'function' && layout.options.title?.()) || ( + + + {config.title} + + + )} + + )} + + + + + {(layout.options.mainMenu || []).map((item) => ( + + ))} + + + + + + + + + + ); +}; + +export default AppBar; diff --git a/frontend/src/layouts/topMenu/Aside.tsx b/frontend/src/layouts/topMenu/Aside.tsx new file mode 100644 index 0000000..2ae76da --- /dev/null +++ b/frontend/src/layouts/topMenu/Aside.tsx @@ -0,0 +1,65 @@ +import React, { PropsWithChildren, useState } from 'react'; +import { Box, Drawer, Fab, Toolbar, useMediaQuery } from '@mui/material'; +import { styled, useTheme } from '@mui/material/styles'; +import TuneIcon from '@mui/icons-material/Tune'; +import { useLayoutContext } from '../LayoutContext'; +import { LayoutOptions } from '.'; + +const asideWidth = '300px'; + +const ContentBox = styled(Box)(({ theme }) => ({ + minWidth: asideWidth, + padding: 16, + boxSizing: 'border-box', + [theme.breakpoints.down('md')]: { + paddingBottom: 56 + 16, + }, +})); + +const Aside = ({ children }: PropsWithChildren) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [isAsideOpen, setAsideOpen] = useState(false); + + const layout = useLayoutContext(); + const side = layout.options.sideBarPlacement; + + return ( + <> + {isMobile && ( + setAsideOpen(true)} + > + + Filtres + + )} + setAsideOpen(false)} + > + + {children} + + + ); +}; + +export default Aside; diff --git a/frontend/src/layouts/topMenu/BaseView.tsx b/frontend/src/layouts/topMenu/BaseView.tsx new file mode 100644 index 0000000..f588abd --- /dev/null +++ b/frontend/src/layouts/topMenu/BaseView.tsx @@ -0,0 +1,65 @@ +import React, { PropsWithChildren, ReactNode } from 'react'; +import { Grid, Typography, Box, useMediaQuery } from '@mui/material'; +import { styled, useTheme } from '@mui/material/styles'; +import { LayoutOptions } from './index'; +import { useLayoutContext } from '../LayoutContext'; + +const Title = styled(Typography)(({ theme }) => ({ + [theme.breakpoints.down('sm')]: { + fontSize: '1.8rem', + }, +})) as typeof Typography; + +const ActionsGrid = styled(Grid)(({ theme }) => ({ + [theme.breakpoints.down('sm')]: { + '& .MuiToolbar-root': { + backgroundColor: theme.palette.background.default, + minHeight: 0, + paddingTop: 0, + alignItems: 'center', + height: '100%', + }, + '& .MuiButtonBase-root': { + padding: 0, + }, + }, +})); + +type Props = { + title: string | ReactNode; + actions: JSX.Element; + aside?: JSX.Element; +}; + +const BaseView = ({ title, actions, aside, children }: PropsWithChildren) => { + const layout = useLayoutContext(); + + const side = layout.options.sideBarPlacement || 'left'; + const asideWidth = '300px'; + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + return ( + <> + + + + + {title} + + + + {actions} + + + {children} + + + + {aside} + + ); +}; + +export default BaseView; diff --git a/frontend/src/layouts/topMenu/BottomNavigation.tsx b/frontend/src/layouts/topMenu/BottomNavigation.tsx new file mode 100644 index 0000000..4c89c4a --- /dev/null +++ b/frontend/src/layouts/topMenu/BottomNavigation.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import HomeIcon from '@mui/icons-material/Home'; +import { BottomNavigation as MuiBottomNavigation, BottomNavigationAction, Paper } from '@mui/material'; +import { Link as RRLink } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; +import { useLayoutContext } from '../LayoutContext'; +import { LayoutOptions } from '.'; + +const BottomNavigation = () => { + const layout = useLayoutContext(); + const location = useLocation(); + + const [value, setValue] = useState(); + + useEffect(() => { + if (location.pathname === '/') { + setValue(0); + return; + } + + const match = (layout.options.mainMenu || []).findIndex( + (item) => item.resource && location.pathname.startsWith(`/${item.resource}`), + ); + + setValue(match > -1 ? match + 1 : undefined); + }, [location, layout]); + + return ( + theme.zIndex.drawer + 1, + }} + elevation={5} + > + + } component={RRLink} to="/" /> + {(layout.options.mainMenu || []).map((item) => ( + : undefined} + component={RRLink} + to={item.link} + /> + ))} + + + ); +}; + +export default BottomNavigation; diff --git a/frontend/src/layouts/topMenu/Layout.tsx b/frontend/src/layouts/topMenu/Layout.tsx new file mode 100644 index 0000000..bb20301 --- /dev/null +++ b/frontend/src/layouts/topMenu/Layout.tsx @@ -0,0 +1,20 @@ +import React, { PropsWithChildren } from 'react'; +import AppBar from './AppBar'; +import { Box } from '@mui/material'; +import BottomNavigation from './BottomNavigation'; + +const Layout = ({ children }: PropsWithChildren) => { + return ( + + + + + {children} + + + + + ); +}; + +export default Layout; diff --git a/frontend/src/layouts/topMenu/ResourcesMenu.tsx b/frontend/src/layouts/topMenu/ResourcesMenu.tsx new file mode 100644 index 0000000..e155f5f --- /dev/null +++ b/frontend/src/layouts/topMenu/ResourcesMenu.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import { IconButton, Menu } from '@mui/material'; +import MenuIcon from '@mui/icons-material/Menu'; +import { MenuItemLink, useGetIdentity, useResourceDefinitions } from 'react-admin'; +import { useLayoutContext } from '../LayoutContext'; +import { LayoutOptions } from '.'; + +const ResourcesMenu = () => { + const layout = useLayoutContext(); + const { data: identity } = useGetIdentity(); + const isLogged = Boolean(identity?.id); + + const [anchorEl, setAnchorEl] = React.useState(null); + + const resourceDefinitions = useResourceDefinitions<{ label: string }>(); + const hiddenResources = useMemo(() => (layout.options.mainMenu || []).map(i => i.resource), [layout]); + const resources = useMemo( + () => Object.values(resourceDefinitions).filter((r) => r.hasList && !hiddenResources.includes(r.name)), + [resourceDefinitions, hiddenResources], + ); + + if (!isLogged) { + return null; + } + + return ( + <> + setAnchorEl(event.currentTarget)}> + + + setAnchorEl(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + > + {resources.map(({ name, icon: Icon, options }) => ( + } + to={`/${name}`} + onPointerEnterCapture={undefined} + onPointerLeaveCapture={undefined} + onClick={() => setAnchorEl(null)} + /> + ))} + + + ); +}; + +export default ResourcesMenu; diff --git a/frontend/src/layouts/topMenu/UserMenu.tsx b/frontend/src/layouts/topMenu/UserMenu.tsx new file mode 100644 index 0000000..e1b017c --- /dev/null +++ b/frontend/src/layouts/topMenu/UserMenu.tsx @@ -0,0 +1,58 @@ +import React, { forwardRef } from 'react'; +import { Logout, UserMenu as RaUserMenu, useCreatePath, useGetIdentity, useUserMenu } from 'react-admin'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import EditIcon from '@mui/icons-material/Edit'; +import { Link, ListItemIcon, ListItemText, MenuItem, MenuItemProps } from '@mui/material'; + +type Props = MenuItemProps & { + label: string; + icon?: React.ElementType; + to: string; +}; + +const UserMenuItem = forwardRef(function UserMenuItem({ label, icon: IconComponent, to, ...rest }: Props, ref) { + const { onClose } = useUserMenu(); + + return ( + + {IconComponent && ( + + + + )} + {label} + + ); +}); + +const UserMenu = () => { + const { data: identity } = useGetIdentity(); + const createPath = useCreatePath(); + + return ( + + {identity?.id + ? [ + , + , + , + ] + : [ + , + , + ]} + + ); +}; + +export default UserMenu; diff --git a/frontend/src/layouts/topMenu/index.ts b/frontend/src/layouts/topMenu/index.ts new file mode 100644 index 0000000..ff08fee --- /dev/null +++ b/frontend/src/layouts/topMenu/index.ts @@ -0,0 +1,25 @@ +import { lazy, ReactNode } from 'react'; +import { SvgIconComponent } from "@mui/icons-material" +import { LayoutComponents } from '../LayoutContext'; + +export type LayoutOptions = { + name: 'topMenu'; + options: Partial<{ + sideBarPlacement: 'left' | 'right'; + logo: string | { url: string; alt: string }; + title: boolean | (() => ReactNode); + mainMenu: { + resource?: string; + label: string; + mobileLabel?: string; + link: string; + icon: SvgIconComponent; + }[] + }>; +}; + +export default { + Layout: lazy(() => import('./Layout')), + BaseView: lazy(() => import('./BaseView')), + Aside: lazy(() => import('./Aside')), +} as LayoutComponents; diff --git a/frontend/src/resources/Agent/Activity/Event/EventFilterSidebar.tsx b/frontend/src/resources/Agent/Activity/Event/EventFilterSidebar.tsx index 799a007..c97e974 100644 --- a/frontend/src/resources/Agent/Activity/Event/EventFilterSidebar.tsx +++ b/frontend/src/resources/Agent/Activity/Event/EventFilterSidebar.tsx @@ -1,12 +1,17 @@ import React from 'react'; +import { FilterLiveSearch, useTranslate } from 'react-admin'; import { ReferenceFilter } from '@semapps/list-components'; import { useLayoutContext } from '../../../../layouts/LayoutContext'; const EventFilterSidebar = () => { const Layout = useLayoutContext(); + const translate = useTranslate(); return ( + {Layout.name === 'topMenu' && ( + + )} { const Layout = useLayoutContext(); + const translate = useTranslate(); return ( + {Layout.name === 'topMenu' && ( + + )} { const Layout = useLayoutContext(); + const translate = useTranslate(); return ( + {Layout.name === 'topMenu' && ( + + )} { const Layout = useLayoutContext(); + const translate = useTranslate(); return ( + {Layout.name === 'topMenu' && ( + + )} ( ( label={record => record['pair:label']} description={record => record['pair:comment']} scrollWheelZoom + popupContent={MobileMapPopupContent} /> ) } diff --git a/frontend/src/resources/Agent/Actor/Organization/index.js b/frontend/src/resources/Agent/Actor/Organization/index.js index 2223ccb..b142c92 100644 --- a/frontend/src/resources/Agent/Actor/Organization/index.js +++ b/frontend/src/resources/Agent/Actor/Organization/index.js @@ -30,6 +30,7 @@ const resource = { translations: { fr: { name: 'Organisation |||| Organisations', + searchLabel: 'Rechercher une organisation', fields: { 'pair:label': 'Nom', 'pair:comment': 'Courte description', diff --git a/frontend/src/resources/Agent/Actor/Person/PersonFilterSidebar.tsx b/frontend/src/resources/Agent/Actor/Person/PersonFilterSidebar.tsx index 9f22b10..ec6b0e3 100644 --- a/frontend/src/resources/Agent/Actor/Person/PersonFilterSidebar.tsx +++ b/frontend/src/resources/Agent/Actor/Person/PersonFilterSidebar.tsx @@ -1,12 +1,17 @@ import React from 'react'; +import { FilterLiveSearch, useTranslate } from 'react-admin'; import { ReferenceFilter } from '@semapps/list-components'; import { useLayoutContext } from '../../../../layouts/LayoutContext'; const PersonFilterSidebar = () => { const Layout = useLayoutContext(); + const translate = useTranslate(); return ( + {Layout.name === 'topMenu' && ( + + )} ( ( label={record => record['pair:label']} description={record => record['pair:comment']} scrollWheelZoom + popupContent={MobileMapPopupContent} /> ) } diff --git a/frontend/src/resources/Agent/Actor/Person/index.js b/frontend/src/resources/Agent/Actor/Person/index.js index 275fe35..75da8d1 100644 --- a/frontend/src/resources/Agent/Actor/Person/index.js +++ b/frontend/src/resources/Agent/Actor/Person/index.js @@ -28,6 +28,7 @@ const resource = { translations: { fr: { name: 'Personne |||| Personnes', + searchLabel: 'Rechercher une personne', fields: { 'pair:firstName': 'Prénom', 'pair:lastName': 'Nom de famille', diff --git a/frontend/src/resources/Idea/IdeaFilterSidebar.tsx b/frontend/src/resources/Idea/IdeaFilterSidebar.tsx index f1362e2..950f08f 100644 --- a/frontend/src/resources/Idea/IdeaFilterSidebar.tsx +++ b/frontend/src/resources/Idea/IdeaFilterSidebar.tsx @@ -1,12 +1,17 @@ import React from 'react'; +import { FilterLiveSearch, useTranslate } from 'react-admin'; import { ReferenceFilter } from '@semapps/list-components'; import { useLayoutContext } from '../../layouts/LayoutContext'; const IdeaFilterSidebar = () => { const Layout = useLayoutContext(); + const translate = useTranslate(); return ( + {Layout.name === 'topMenu' && ( + + )}