diff --git a/frontend/svalyn-studio-app/src/app/App.tsx b/frontend/svalyn-studio-app/src/app/App.tsx index d29cdc6..95aa61f 100644 --- a/frontend/svalyn-studio-app/src/app/App.tsx +++ b/frontend/svalyn-studio-app/src/app/App.tsx @@ -20,6 +20,7 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider } from '@mui/material/styles'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { PaletteProvider } from '../palette/PaletteProvider'; import { ChangeProposalView } from '../views/changeproposal/ChangeProposalView'; import { DomainView } from '../views/domain/DomainView'; import { DomainsView } from '../views/domains/DomainsView'; @@ -49,37 +50,39 @@ export const App = () => { - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/frontend/svalyn-studio-app/src/navbars/Navbar.tsx b/frontend/svalyn-studio-app/src/navbars/Navbar.tsx index 12377f1..77d270e 100644 --- a/frontend/svalyn-studio-app/src/navbars/Navbar.tsx +++ b/frontend/svalyn-studio-app/src/navbars/Navbar.tsx @@ -24,11 +24,13 @@ import LogoutIcon from '@mui/icons-material/Logout'; import MailOutlineIcon from '@mui/icons-material/MailOutline'; import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; import PersonIcon from '@mui/icons-material/Person'; +import SearchIcon from '@mui/icons-material/Search'; import SettingsIcon from '@mui/icons-material/Settings'; import AppBar from '@mui/material/AppBar'; import Avatar from '@mui/material/Avatar'; import Badge from '@mui/material/Badge'; import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; import ListItem from '@mui/material/ListItem'; @@ -37,10 +39,12 @@ import ListItemText from '@mui/material/ListItemText'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Toolbar from '@mui/material/Toolbar'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { Navigate, Link as RouterLink } from 'react-router-dom'; import { getCookie } from '../cookies/getCookie'; import { Svalyn } from '../icons/Svalyn'; +import { PaletteContext } from '../palette/PaletteContext'; +import { PaletteContextValue } from '../palette/PaletteContext.types'; import { ErrorSnackbar } from '../snackbar/ErrorSnackbar'; import { GetViewerData, GetViewerVariables, NavbarProps, NavbarState } from './Navbar.types'; const { VITE_BACKEND_URL } = import.meta.env; @@ -79,6 +83,10 @@ export const Navbar = ({ children }: NavbarProps) => { const handleCloseSnackbar = () => setState((prevState) => ({ ...prevState, message: null })); + const { openPalette }: PaletteContextValue = useContext(PaletteContext); + + const handleOnSearchClick: React.MouseEventHandler = () => openPalette(); + const handleOpenUserMenu: React.MouseEventHandler = (event) => { const { currentTarget } = event; setState((prevState) => ({ ...prevState, anchorElement: currentTarget })); @@ -104,6 +112,8 @@ export const Navbar = ({ children }: NavbarProps) => { return ; } + var isApple = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); + return ( <> @@ -123,6 +133,32 @@ export const Navbar = ({ children }: NavbarProps) => { marginLeft: 'auto', }} > + diff --git a/frontend/svalyn-studio-app/src/palette/Palette.tsx b/frontend/svalyn-studio-app/src/palette/Palette.tsx new file mode 100644 index 0000000..464a5d1 --- /dev/null +++ b/frontend/svalyn-studio-app/src/palette/Palette.tsx @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 Stéphane Bégaudeau. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import CorporateFareIcon from '@mui/icons-material/CorporateFare'; +import HomeIcon from '@mui/icons-material/Home'; +import HubIcon from '@mui/icons-material/Hub'; +import SearchIcon from '@mui/icons-material/Search'; +import Box from '@mui/material/Box'; +import Dialog from '@mui/material/Dialog'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import Input from '@mui/material/Input'; +import InputAdornment from '@mui/material/InputAdornment'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import { useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PaletteAction, PaletteProps, PaletteState } from './Palette.types'; + +export const Palette = ({ open, onClose }: PaletteProps) => { + const inputRef = useRef(null); + const listRef = useRef(null); + + const navigate = useNavigate(); + + const goToHome: PaletteAction = { + id: 'go-to-home', + icon: , + label: 'Home', + handle: () => navigate(`/`), + }; + const goToDomains: PaletteAction = { + id: 'go-to-domains', + icon: , + label: 'Domains', + handle: () => navigate(`/domains`), + }; + const goToNewOrganization: PaletteAction = { + id: 'go-to-new-organization', + icon: , + label: 'New organization', + handle: () => navigate(`/new/organization`), + }; + + const defaultPaletteActions: PaletteAction[] = [goToHome, goToDomains, goToNewOrganization]; + + const [state, setState] = useState({ + query: '', + actions: defaultPaletteActions, + selectedActionId: null, + }); + + const handleChange: React.ChangeEventHandler = (event) => { + const { + target: { value }, + } = event; + setState((prevState) => ({ ...prevState, query: value })); + }; + + const handleKeyDown: React.KeyboardEventHandler = (event) => { + if (event.key === 'Enter') { + navigate(`/search?q=${encodeURIComponent(state.query)}`); + onClose(); + } else if (event.key === 'ArrowDown' && listRef.current) { + const firstListItem = listRef.current.childNodes[0]; + if (firstListItem instanceof HTMLElement) { + const firstListItemButton = firstListItem.childNodes[0]; + if (firstListItemButton instanceof HTMLElement) { + firstListItemButton.focus(); + } + } + } + }; + + const handleListItemKeyDown: React.KeyboardEventHandler = (event) => { + if (event.key === 'ArrowDown') { + const nextListItemButton = event.currentTarget.parentElement?.nextSibling?.childNodes[0]; + if (nextListItemButton instanceof HTMLElement) { + nextListItemButton.focus(); + } + } else if (event.key === 'ArrowUp') { + const previousListItemButton = event.currentTarget.parentElement?.previousSibling?.childNodes[0]; + if (previousListItemButton instanceof HTMLElement) { + previousListItemButton.focus(); + } else { + const input = inputRef.current?.querySelector('input'); + if (input) { + input.focus(); + } + } + } + }; + + const handleOnActionClick = (action: PaletteAction) => { + action.handle(); + onClose(); + }; + + return ( + + + theme.spacing(1), paddingY: (theme) => theme.spacing(0.5) }} + > + + + + } + inputProps={{ + style: { + fontSize: '1.5rem', + }, + }} + /> + + + + {state.actions.map((action) => ( + + handleOnActionClick(action)} onKeyDown={handleListItemKeyDown}> + {action.icon} + {action.label} + + + ))} + + + + ); +}; diff --git a/frontend/svalyn-studio-app/src/palette/Palette.types.ts b/frontend/svalyn-studio-app/src/palette/Palette.types.ts new file mode 100644 index 0000000..91f2b38 --- /dev/null +++ b/frontend/svalyn-studio-app/src/palette/Palette.types.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 Stéphane Bégaudeau. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export interface PaletteProps { + open: boolean; + onClose: () => void; +} + +export interface PaletteState { + query: string; + actions: PaletteAction[]; + selectedActionId: string | null; +} + +export interface PaletteAction { + id: string; + icon: JSX.Element; + label: string; + handle: () => void; +} diff --git a/frontend/svalyn-studio-app/src/palette/PaletteContext.tsx b/frontend/svalyn-studio-app/src/palette/PaletteContext.tsx new file mode 100644 index 0000000..dc05e3e --- /dev/null +++ b/frontend/svalyn-studio-app/src/palette/PaletteContext.tsx @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Stéphane Bégaudeau. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from 'react'; +import { PaletteContextValue } from './PaletteContext.types'; + +export const PaletteContext = React.createContext({ + openPalette: () => {}, +}); diff --git a/frontend/svalyn-studio-app/src/palette/PaletteContext.types.ts b/frontend/svalyn-studio-app/src/palette/PaletteContext.types.ts new file mode 100644 index 0000000..67ff743 --- /dev/null +++ b/frontend/svalyn-studio-app/src/palette/PaletteContext.types.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 Stéphane Bégaudeau. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export interface PaletteContextValue { + openPalette: () => void; +} diff --git a/frontend/svalyn-studio-app/src/palette/PaletteProvider.tsx b/frontend/svalyn-studio-app/src/palette/PaletteProvider.tsx new file mode 100644 index 0000000..5cdfa37 --- /dev/null +++ b/frontend/svalyn-studio-app/src/palette/PaletteProvider.tsx @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 Stéphane Bégaudeau. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { useEffect, useState } from 'react'; +import { Palette } from './Palette'; +import { PaletteContext } from './PaletteContext'; +import { PaletteContextValue } from './PaletteContext.types'; +import { PaletteProviderProps, PaletteProviderState } from './PaletteProvider.types'; + +export const PaletteProvider = ({ children }: PaletteProviderProps) => { + const [state, setState] = useState({ open: false }); + + useEffect(() => { + const keyDownEventListener = (event: KeyboardEvent) => { + if (event.key === 'k' && (event.ctrlKey || event.metaKey)) { + setState((prevState) => ({ ...prevState, open: !prevState.open })); + } else if (event.key === 'Esc') { + setState((prevState) => ({ ...prevState, open: false })); + } + }; + + document.addEventListener('keydown', keyDownEventListener); + return () => document.removeEventListener('keydown', keyDownEventListener); + }, []); + + const handleClose = () => setState((prevState) => ({ ...prevState, open: false })); + + const paletteContextValue: PaletteContextValue = { + openPalette: () => setState((prevState) => ({ ...prevState, open: true })), + }; + + return ( + + {children} + {state.open ? : null} + + ); +}; diff --git a/frontend/svalyn-studio-app/src/palette/PaletteProvider.types.ts b/frontend/svalyn-studio-app/src/palette/PaletteProvider.types.ts new file mode 100644 index 0000000..ee86f76 --- /dev/null +++ b/frontend/svalyn-studio-app/src/palette/PaletteProvider.types.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 Stéphane Bégaudeau. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export interface PaletteProviderProps { + children?: React.ReactNode; +} + +export interface PaletteProviderState { + open: boolean; +}