diff options
Diffstat (limited to 'packages/renderer/src/components/locationBar')
10 files changed, 564 insertions, 24 deletions
diff --git a/packages/renderer/src/components/locationBar/ButtonAdornment.tsx b/packages/renderer/src/components/locationBar/ButtonAdornment.tsx new file mode 100644 index 0000000..2cf230b --- /dev/null +++ b/packages/renderer/src/components/locationBar/ButtonAdornment.tsx | |||
@@ -0,0 +1,69 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import InputAdornment from '@mui/material/InputAdornment'; | ||
22 | import { styled } from '@mui/material/styles'; | ||
23 | |||
24 | export const NO_LABEL_BUTTON_CLASS_NAME = 'ButtonAdornment-NoLabel'; | ||
25 | |||
26 | const ButtonAdornment = styled(InputAdornment, { | ||
27 | name: 'ButtonAdornment', | ||
28 | })(({ theme, position }) => { | ||
29 | const { direction } = theme; | ||
30 | const left = direction === 'ltr' ? 'start' : 'end'; | ||
31 | return { | ||
32 | ...(position === left | ||
33 | ? { | ||
34 | marginRight: 2, | ||
35 | marginLeft: -8, | ||
36 | } | ||
37 | : { | ||
38 | marginLeft: 2, | ||
39 | marginRight: -8, | ||
40 | }), | ||
41 | '.MuiButton-root': { | ||
42 | minWidth: 32, | ||
43 | height: 32, | ||
44 | paddingLeft: 6, | ||
45 | paddingRight: 6, | ||
46 | borderRadius: 16, | ||
47 | }, | ||
48 | ...(direction === 'ltr' | ||
49 | ? { | ||
50 | '.MuiButton-startIcon': { | ||
51 | marginLeft: 0, | ||
52 | }, | ||
53 | [`.${NO_LABEL_BUTTON_CLASS_NAME} .MuiButton-startIcon`]: { | ||
54 | marginRight: 0, | ||
55 | }, | ||
56 | } | ||
57 | : { | ||
58 | '.MuiButton-startIcon': { | ||
59 | marginRight: 0, | ||
60 | marginLeft: theme.spacing(1), | ||
61 | }, | ||
62 | [`.${NO_LABEL_BUTTON_CLASS_NAME} .MuiButton-startIcon`]: { | ||
63 | marginLeft: 0, | ||
64 | }, | ||
65 | }), | ||
66 | }; | ||
67 | }); | ||
68 | |||
69 | export default ButtonAdornment; | ||
diff --git a/packages/renderer/src/components/locationBar/GoAdornment.tsx b/packages/renderer/src/components/locationBar/GoAdornment.tsx new file mode 100644 index 0000000..43c8b7b --- /dev/null +++ b/packages/renderer/src/components/locationBar/GoAdornment.tsx | |||
@@ -0,0 +1,38 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import IconGo from '@mui/icons-material/Send'; | ||
22 | import Button from '@mui/material/Button'; | ||
23 | import React from 'react'; | ||
24 | |||
25 | import ButtonAdornment, { NO_LABEL_BUTTON_CLASS_NAME } from './ButtonAdornment'; | ||
26 | |||
27 | export default function GoAdornment(): JSX.Element { | ||
28 | return ( | ||
29 | <ButtonAdornment position="end"> | ||
30 | <Button | ||
31 | aria-label="Go" | ||
32 | color="inherit" | ||
33 | startIcon={<IconGo />} | ||
34 | className={NO_LABEL_BUTTON_CLASS_NAME} | ||
35 | /> | ||
36 | </ButtonAdornment> | ||
37 | ); | ||
38 | } | ||
diff --git a/packages/renderer/src/components/locationBar/IconAdornment.tsx b/packages/renderer/src/components/locationBar/IconAdornment.tsx new file mode 100644 index 0000000..1a2fa83 --- /dev/null +++ b/packages/renderer/src/components/locationBar/IconAdornment.tsx | |||
@@ -0,0 +1,41 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import InputAdornment from '@mui/material/InputAdornment'; | ||
22 | import { styled } from '@mui/material/styles'; | ||
23 | |||
24 | const IconAdornment = styled(InputAdornment, { | ||
25 | name: 'IconAdornment', | ||
26 | })(({ theme: { direction }, position }) => { | ||
27 | const left = direction === 'ltr' ? 'start' : 'end'; | ||
28 | return { | ||
29 | ...(position === left | ||
30 | ? { | ||
31 | marginLeft: -2, | ||
32 | marginRight: 8, | ||
33 | } | ||
34 | : { | ||
35 | marginLeft: 8, | ||
36 | marginRight: -2, | ||
37 | }), | ||
38 | }; | ||
39 | }); | ||
40 | |||
41 | export default IconAdornment; | ||
diff --git a/packages/renderer/src/components/locationBar/LocationBar.tsx b/packages/renderer/src/components/locationBar/LocationBar.tsx index e1f470d..8b079f0 100644 --- a/packages/renderer/src/components/locationBar/LocationBar.tsx +++ b/packages/renderer/src/components/locationBar/LocationBar.tsx | |||
@@ -27,6 +27,8 @@ import { useStore } from '../StoreProvider'; | |||
27 | import LocationTextField from './LocationTextField'; | 27 | import LocationTextField from './LocationTextField'; |
28 | import NavigationButtons from './NavigationButtons'; | 28 | import NavigationButtons from './NavigationButtons'; |
29 | 29 | ||
30 | export const LOCATION_BAR_ID = 'Sophie-LocationBar'; | ||
31 | |||
30 | const LocationBarRoot = styled('header', { | 32 | const LocationBarRoot = styled('header', { |
31 | name: 'LocationBar', | 33 | name: 'LocationBar', |
32 | slot: 'Root', | 34 | slot: 'Root', |
@@ -34,6 +36,8 @@ const LocationBarRoot = styled('header', { | |||
34 | display: hidden ? 'none' : 'flex', | 36 | display: hidden ? 'none' : 'flex', |
35 | flexDirection: 'row', | 37 | flexDirection: 'row', |
36 | padding: theme.spacing(1), | 38 | padding: theme.spacing(1), |
39 | // Align the bottom border with the service switcher in the sidebar. | ||
40 | paddingBottom: `calc(${theme.spacing(1)} - 1px)`, | ||
37 | gap: theme.spacing(1), | 41 | gap: theme.spacing(1), |
38 | borderBottom: `1px solid ${theme.palette.divider}`, | 42 | borderBottom: `1px solid ${theme.palette.divider}`, |
39 | })); | 43 | })); |
@@ -44,7 +48,7 @@ function LocationBar(): JSX.Element { | |||
44 | } = useStore(); | 48 | } = useStore(); |
45 | 49 | ||
46 | return ( | 50 | return ( |
47 | <LocationBarRoot id="locationBar" hidden={!showLocationBar}> | 51 | <LocationBarRoot id={LOCATION_BAR_ID} hidden={!showLocationBar}> |
48 | <NavigationButtons service={selectedService} /> | 52 | <NavigationButtons service={selectedService} /> |
49 | <LocationTextField service={selectedService} /> | 53 | <LocationTextField service={selectedService} /> |
50 | </LocationBarRoot> | 54 | </LocationBarRoot> |
diff --git a/packages/renderer/src/components/locationBar/LocationOverlayInput.tsx b/packages/renderer/src/components/locationBar/LocationOverlayInput.tsx new file mode 100644 index 0000000..55c1fb8 --- /dev/null +++ b/packages/renderer/src/components/locationBar/LocationOverlayInput.tsx | |||
@@ -0,0 +1,110 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { styled } from '@mui/material/styles'; | ||
22 | import React, { forwardRef, ForwardedRef } from 'react'; | ||
23 | |||
24 | const inputClassName = 'LocationOverlayInput-Input'; | ||
25 | |||
26 | const overlayClassName = 'LocationOverlayInput-Overlay'; | ||
27 | |||
28 | const clipClassName = 'LocationOverlayInput-Clip'; | ||
29 | |||
30 | const LocationOverlayInputRoot = styled('div', { | ||
31 | name: 'LocationOverlayInput', | ||
32 | slot: 'Root', | ||
33 | shouldForwardProp: (prop) => prop !== 'overlayVisible', | ||
34 | })<{ overlayVisible: boolean }>(({ theme, overlayVisible }) => { | ||
35 | const itemStyle = { | ||
36 | paddingLeft: 0, | ||
37 | paddingRight: 0, | ||
38 | }; | ||
39 | return { | ||
40 | display: 'flex', | ||
41 | position: 'relative', | ||
42 | flex: 1, | ||
43 | [`.${inputClassName}`]: { | ||
44 | ...itemStyle, | ||
45 | color: overlayVisible ? 'transparent' : 'inherit', | ||
46 | }, | ||
47 | [`.${overlayClassName}`]: { | ||
48 | ...itemStyle, | ||
49 | display: 'flex', | ||
50 | position: 'absolute', | ||
51 | left: 0, | ||
52 | right: 0, | ||
53 | top: 0, | ||
54 | bottom: 0, | ||
55 | // Text rendering with selected transparent text works better on the bottom in light mode. | ||
56 | zIndex: theme.palette.mode === 'dark' ? 999 : -999, | ||
57 | pointerEvents: 'none', | ||
58 | width: 'auto', | ||
59 | }, | ||
60 | [`.${clipClassName}`]: { | ||
61 | flex: 1, | ||
62 | overflow: 'hidden', | ||
63 | whiteSpace: 'nowrap', | ||
64 | textOverflow: 'clip', | ||
65 | }, | ||
66 | }; | ||
67 | }); | ||
68 | |||
69 | export interface LocationOverlayInputProps | ||
70 | extends React.HTMLProps<HTMLInputElement> { | ||
71 | className?: string | undefined; | ||
72 | |||
73 | overlayVisible?: boolean; | ||
74 | |||
75 | overlay?: JSX.Element | undefined; | ||
76 | } | ||
77 | |||
78 | const LocationOverlayInput = forwardRef( | ||
79 | ( | ||
80 | { overlayVisible, overlay, className, ...props }: LocationOverlayInputProps, | ||
81 | ref: ForwardedRef<HTMLInputElement>, | ||
82 | ) => ( | ||
83 | <LocationOverlayInputRoot overlayVisible={overlayVisible ?? false}> | ||
84 | {/* eslint-disable react/jsx-props-no-spreading -- | ||
85 | Deliberately passing props through to the actual input element. | ||
86 | */} | ||
87 | <input | ||
88 | ref={ref} | ||
89 | className={`${className ?? ''} ${inputClassName}`} | ||
90 | {...props} | ||
91 | /> | ||
92 | {/* eslint-enable react/jsx-props-no-spreading */} | ||
93 | {overlayVisible && ( | ||
94 | <div aria-hidden className={`${className ?? ''} ${overlayClassName}`}> | ||
95 | <div className={clipClassName}>{overlay}</div> | ||
96 | </div> | ||
97 | )} | ||
98 | </LocationOverlayInputRoot> | ||
99 | ), | ||
100 | ); | ||
101 | |||
102 | LocationOverlayInput.displayName = 'LocationOverlayInput'; | ||
103 | |||
104 | LocationOverlayInput.defaultProps = { | ||
105 | className: undefined, | ||
106 | overlayVisible: false, | ||
107 | overlay: undefined, | ||
108 | }; | ||
109 | |||
110 | export default LocationOverlayInput; | ||
diff --git a/packages/renderer/src/components/locationBar/LocationTextField.tsx b/packages/renderer/src/components/locationBar/LocationTextField.tsx index a618bf6..f436bf0 100644 --- a/packages/renderer/src/components/locationBar/LocationTextField.tsx +++ b/packages/renderer/src/components/locationBar/LocationTextField.tsx | |||
@@ -18,40 +18,95 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import IconGlobe from '@mui/icons-material/Public'; | ||
22 | import FilledInput from '@mui/material/FilledInput'; | 21 | import FilledInput from '@mui/material/FilledInput'; |
23 | import InputAdornment from '@mui/material/InputAdornment'; | 22 | import { styled } from '@mui/material/styles'; |
24 | import { Service } from '@sophie/shared'; | 23 | import { Service } from '@sophie/shared'; |
24 | import { autorun } from 'mobx'; | ||
25 | import { observer } from 'mobx-react-lite'; | 25 | import { observer } from 'mobx-react-lite'; |
26 | import React from 'react'; | 26 | import React, { useCallback, useEffect, useState } from 'react'; |
27 | |||
28 | import GoAdornment from './GoAdornment'; | ||
29 | import LocationOverlayInput from './LocationOverlayInput'; | ||
30 | import UrlAdornment from './UrlAdornment'; | ||
31 | import UrlOverlay from './UrlOverlay'; | ||
32 | import splitUrl from './splitUrl'; | ||
33 | |||
34 | const LocationTextFieldRoot = styled(FilledInput, { | ||
35 | name: 'LocationTextField', | ||
36 | slot: 'Root', | ||
37 | })(({ theme }) => ({ | ||
38 | paddingLeft: 12, | ||
39 | paddingRight: 12, | ||
40 | borderRadius: 20, | ||
41 | '&.Mui-focused': { | ||
42 | outline: `2px solid ${theme.palette.primary.main}`, | ||
43 | }, | ||
44 | })); | ||
27 | 45 | ||
28 | function LocationTextField({ | 46 | function LocationTextField({ |
29 | service, | 47 | service, |
30 | }: { | 48 | }: { |
31 | service: Service | undefined; | 49 | service: Service | undefined; |
32 | }): JSX.Element { | 50 | }): JSX.Element { |
51 | const [inputFocused, setInputFocused] = useState(false); | ||
52 | const [changed, setChanged] = useState(false); | ||
53 | const [value, setValue] = useState(''); | ||
54 | |||
55 | const resetValue = useCallback(() => { | ||
56 | setValue(service?.currentUrl ?? ''); | ||
57 | setChanged(false); | ||
58 | }, [service, setChanged, setValue]); | ||
59 | |||
60 | useEffect( | ||
61 | () => | ||
62 | autorun(() => { | ||
63 | resetValue(); | ||
64 | }), | ||
65 | [resetValue], | ||
66 | ); | ||
67 | |||
68 | const inputRefCallback = useCallback( | ||
69 | (input: HTMLInputElement) => { | ||
70 | setInputFocused( | ||
71 | document.activeElement !== null && document.activeElement === input, | ||
72 | ); | ||
73 | }, | ||
74 | [setInputFocused], | ||
75 | ); | ||
76 | |||
77 | const splitResult = splitUrl(service?.currentUrl); | ||
78 | |||
33 | return ( | 79 | return ( |
34 | <FilledInput | 80 | <LocationTextFieldRoot |
35 | aria-label="Location" | 81 | inputComponent={LocationOverlayInput} |
36 | inputProps={{ | 82 | inputProps={{ |
83 | 'aria-label': 'Location', | ||
37 | spellCheck: false, | 84 | spellCheck: false, |
85 | overlayVisible: !inputFocused && !changed, | ||
86 | overlay: <UrlOverlay splitResult={splitResult} />, | ||
87 | }} | ||
88 | inputRef={inputRefCallback} | ||
89 | onFocus={() => setInputFocused(true)} | ||
90 | onBlur={() => setInputFocused(false)} | ||
91 | onChange={({ target: { value: newValue } }) => { | ||
92 | setValue(newValue); | ||
93 | setChanged(true); | ||
94 | }} | ||
95 | onKeyUp={(event) => { | ||
96 | if (event.key === 'Escape') { | ||
97 | resetValue(); | ||
98 | event.preventDefault(); | ||
99 | } | ||
38 | }} | 100 | }} |
39 | size="small" | 101 | size="small" |
40 | fullWidth | 102 | fullWidth |
41 | hiddenLabel | 103 | hiddenLabel |
42 | disableUnderline | 104 | disableUnderline |
43 | sx={(theme) => ({ | ||
44 | borderRadius: 1, | ||
45 | '&.Mui-focused': { | ||
46 | outline: `2px solid ${theme.palette.primary.main}`, | ||
47 | }, | ||
48 | })} | ||
49 | startAdornment={ | 105 | startAdornment={ |
50 | <InputAdornment position="start"> | 106 | <UrlAdornment changed={changed} splitResult={splitResult} /> |
51 | <IconGlobe fontSize="small" /> | ||
52 | </InputAdornment> | ||
53 | } | 107 | } |
54 | value={service?.currentUrl ?? ''} | 108 | endAdornment={changed ? <GoAdornment /> : undefined} |
109 | value={value} | ||
55 | /> | 110 | /> |
56 | ); | 111 | ); |
57 | } | 112 | } |
diff --git a/packages/renderer/src/components/locationBar/NavigationButtons.tsx b/packages/renderer/src/components/locationBar/NavigationButtons.tsx index 82ed657..77b02b6 100644 --- a/packages/renderer/src/components/locationBar/NavigationButtons.tsx +++ b/packages/renderer/src/components/locationBar/NavigationButtons.tsx | |||
@@ -23,6 +23,7 @@ import IconArrowForward from '@mui/icons-material/ArrowForward'; | |||
23 | import IconStop from '@mui/icons-material/Close'; | 23 | import IconStop from '@mui/icons-material/Close'; |
24 | import IconHome from '@mui/icons-material/HomeOutlined'; | 24 | import IconHome from '@mui/icons-material/HomeOutlined'; |
25 | import IconRefresh from '@mui/icons-material/Refresh'; | 25 | import IconRefresh from '@mui/icons-material/Refresh'; |
26 | import { useTheme } from '@mui/material'; | ||
26 | import Box from '@mui/material/Box'; | 27 | import Box from '@mui/material/Box'; |
27 | import IconButton from '@mui/material/IconButton'; | 28 | import IconButton from '@mui/material/IconButton'; |
28 | import { Service } from '@sophie/shared'; | 29 | import { Service } from '@sophie/shared'; |
@@ -34,6 +35,8 @@ function NavigationButtons({ | |||
34 | }: { | 35 | }: { |
35 | service: Service | undefined; | 36 | service: Service | undefined; |
36 | }): JSX.Element { | 37 | }): JSX.Element { |
38 | const { direction } = useTheme(); | ||
39 | |||
37 | return ( | 40 | return ( |
38 | <Box | 41 | <Box |
39 | sx={{ | 42 | sx={{ |
@@ -42,16 +45,16 @@ function NavigationButtons({ | |||
42 | }} | 45 | }} |
43 | > | 46 | > |
44 | <IconButton | 47 | <IconButton |
45 | aria-label="Go back" | 48 | aria-label="Back" |
46 | disabled={service === undefined || !service.canGoBack} | 49 | disabled={service === undefined || !service.canGoBack} |
47 | > | 50 | > |
48 | <IconArrowBack /> | 51 | {direction === 'ltr' ? <IconArrowBack /> : <IconArrowForward />} |
49 | </IconButton> | 52 | </IconButton> |
50 | <IconButton | 53 | <IconButton |
51 | aria-label="Go forward" | 54 | aria-label="Forward" |
52 | disabled={service === undefined || !service.canGoForward} | 55 | disabled={service === undefined || !service.canGoForward} |
53 | > | 56 | > |
54 | <IconArrowForward /> | 57 | {direction === 'ltr' ? <IconArrowForward /> : <IconArrowBack />} |
55 | </IconButton> | 58 | </IconButton> |
56 | {service?.state === 'loading' ? ( | 59 | {service?.state === 'loading' ? ( |
57 | <IconButton aria-label="Stop"> | 60 | <IconButton aria-label="Stop"> |
@@ -62,10 +65,7 @@ function NavigationButtons({ | |||
62 | <IconRefresh /> | 65 | <IconRefresh /> |
63 | </IconButton> | 66 | </IconButton> |
64 | )} | 67 | )} |
65 | <IconButton | 68 | <IconButton aria-label="Home" disabled={service === undefined}> |
66 | aria-label="Go to service homepage" | ||
67 | disabled={service === undefined} | ||
68 | > | ||
69 | <IconHome /> | 69 | <IconHome /> |
70 | </IconButton> | 70 | </IconButton> |
71 | </Box> | 71 | </Box> |
diff --git a/packages/renderer/src/components/locationBar/UrlAdornment.tsx b/packages/renderer/src/components/locationBar/UrlAdornment.tsx new file mode 100644 index 0000000..6ede378 --- /dev/null +++ b/packages/renderer/src/components/locationBar/UrlAdornment.tsx | |||
@@ -0,0 +1,91 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import IconHttps from '@mui/icons-material/HttpsOutlined'; | ||
22 | import IconGlobe from '@mui/icons-material/Public'; | ||
23 | import IconWarning from '@mui/icons-material/Warning'; | ||
24 | import { styled } from '@mui/material'; | ||
25 | import Button from '@mui/material/Button'; | ||
26 | import React from 'react'; | ||
27 | |||
28 | import ButtonAdornment, { NO_LABEL_BUTTON_CLASS_NAME } from './ButtonAdornment'; | ||
29 | import IconAdornment from './IconAdornment'; | ||
30 | import type { SplitResult } from './splitUrl'; | ||
31 | |||
32 | const FastColorChangingButton = styled(Button)(({ theme }) => ({ | ||
33 | transition: theme.transitions.create( | ||
34 | ['background-color', 'box-shadow', 'border-color'], | ||
35 | { | ||
36 | duration: theme.transitions.duration.short, | ||
37 | easing: theme.transitions.easing.easeInOut, | ||
38 | }, | ||
39 | ), | ||
40 | })); | ||
41 | |||
42 | export default function UrlAdornment({ | ||
43 | splitResult, | ||
44 | changed, | ||
45 | }: { | ||
46 | splitResult: SplitResult; | ||
47 | changed: boolean; | ||
48 | }): JSX.Element { | ||
49 | const { type } = splitResult; | ||
50 | if (changed || type === 'empty') { | ||
51 | return ( | ||
52 | <IconAdornment position="start"> | ||
53 | <IconGlobe fontSize="small" /> | ||
54 | </IconAdornment> | ||
55 | ); | ||
56 | } | ||
57 | switch (type) { | ||
58 | case 'valid': { | ||
59 | const { secure } = splitResult; | ||
60 | return secure ? ( | ||
61 | <ButtonAdornment position="start"> | ||
62 | <FastColorChangingButton | ||
63 | aria-label="Show certificate" | ||
64 | color="inherit" | ||
65 | className={NO_LABEL_BUTTON_CLASS_NAME} | ||
66 | startIcon={<IconHttps />} | ||
67 | /> | ||
68 | </ButtonAdornment> | ||
69 | ) : ( | ||
70 | <ButtonAdornment position="start"> | ||
71 | <FastColorChangingButton color="error" startIcon={<IconWarning />}> | ||
72 | Not secure | ||
73 | </FastColorChangingButton> | ||
74 | </ButtonAdornment> | ||
75 | ); | ||
76 | } | ||
77 | case 'invalid': | ||
78 | return ( | ||
79 | <ButtonAdornment position="start"> | ||
80 | <FastColorChangingButton color="error" startIcon={<IconWarning />}> | ||
81 | Unknown site | ||
82 | </FastColorChangingButton> | ||
83 | </ButtonAdornment> | ||
84 | ); | ||
85 | default: | ||
86 | /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- | ||
87 | Error handling for impossible case. | ||
88 | */ | ||
89 | throw new Error(`Unexpected SplitResult: ${type}`); | ||
90 | } | ||
91 | } | ||
diff --git a/packages/renderer/src/components/locationBar/UrlOverlay.tsx b/packages/renderer/src/components/locationBar/UrlOverlay.tsx new file mode 100644 index 0000000..f7a3c4c --- /dev/null +++ b/packages/renderer/src/components/locationBar/UrlOverlay.tsx | |||
@@ -0,0 +1,70 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { styled } from '@mui/material/styles'; | ||
22 | import React from 'react'; | ||
23 | |||
24 | import type { SplitResult } from './splitUrl'; | ||
25 | |||
26 | const PrimaryFragment = styled('span', { | ||
27 | name: 'LocationOverlayInput', | ||
28 | slot: 'PrimaryFragment', | ||
29 | shouldForwardProp: (prop) => prop !== 'alert', | ||
30 | })<{ alert: boolean }>(({ theme, alert }) => ({ | ||
31 | color: alert ? theme.palette.error.main : theme.palette.text.primary, | ||
32 | })); | ||
33 | |||
34 | const SecondaryFragment = styled('span', { | ||
35 | name: 'LocationOverlayInput', | ||
36 | slot: 'SecondaryFragment', | ||
37 | })(({ theme }) => ({ | ||
38 | color: theme.palette.text.secondary, | ||
39 | })); | ||
40 | |||
41 | export default function UrlOverlay({ | ||
42 | splitResult, | ||
43 | }: { | ||
44 | splitResult: SplitResult; | ||
45 | }): JSX.Element { | ||
46 | const { type } = splitResult; | ||
47 | switch (type) { | ||
48 | case 'valid': { | ||
49 | const { secure, prefix, host, suffix } = splitResult; | ||
50 | return ( | ||
51 | <> | ||
52 | <SecondaryFragment>{prefix}</SecondaryFragment> | ||
53 | <PrimaryFragment alert={!secure}>{host}</PrimaryFragment> | ||
54 | <SecondaryFragment>{suffix}</SecondaryFragment> | ||
55 | </> | ||
56 | ); | ||
57 | } | ||
58 | case 'invalid': { | ||
59 | const { value } = splitResult; | ||
60 | return <PrimaryFragment alert>{value}</PrimaryFragment>; | ||
61 | } | ||
62 | case 'empty': | ||
63 | return <PrimaryFragment alert={false} />; | ||
64 | default: | ||
65 | /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- | ||
66 | Error handling for impossible case. | ||
67 | */ | ||
68 | throw new Error(`Unexpected SplitResult: ${type}`); | ||
69 | } | ||
70 | } | ||
diff --git a/packages/renderer/src/components/locationBar/splitUrl.ts b/packages/renderer/src/components/locationBar/splitUrl.ts new file mode 100644 index 0000000..6e95472 --- /dev/null +++ b/packages/renderer/src/components/locationBar/splitUrl.ts | |||
@@ -0,0 +1,62 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | export type SplitResult = | ||
22 | | { | ||
23 | type: 'valid'; | ||
24 | secure: boolean; | ||
25 | prefix: string; | ||
26 | host: string; | ||
27 | suffix: string; | ||
28 | } | ||
29 | | { | ||
30 | type: 'invalid'; | ||
31 | value: string; | ||
32 | } | ||
33 | | { | ||
34 | type: 'empty'; | ||
35 | }; | ||
36 | |||
37 | export default function splitUrl(urlString: string | undefined): SplitResult { | ||
38 | if (urlString === undefined || urlString === '') { | ||
39 | return { type: 'empty' }; | ||
40 | } | ||
41 | let url: URL; | ||
42 | try { | ||
43 | url = new URL(urlString); | ||
44 | } catch { | ||
45 | return { type: 'invalid', value: urlString }; | ||
46 | } | ||
47 | const { protocol, host, username, password, pathname, search, hash } = url; | ||
48 | if (protocol === 'http:' || protocol === 'https:') { | ||
49 | return { | ||
50 | type: 'valid', | ||
51 | secure: protocol === 'https:', | ||
52 | prefix: `${protocol}//${ | ||
53 | username === '' | ||
54 | ? '' | ||
55 | : `${username}${password === '' ? '' : `:${password}`}@` | ||
56 | }`, | ||
57 | host, | ||
58 | suffix: `${pathname}${search}${hash}`, | ||
59 | }; | ||
60 | } | ||
61 | return { type: 'invalid', value: urlString }; | ||
62 | } | ||