Merge branch 'staging' into issue-99

This commit is contained in:
eugene 2024-08-05 11:56:38 +00:00
commit 97aa87d8b1
80 changed files with 2076 additions and 1080 deletions

62
package-lock.json generated
View File

@ -10,6 +10,10 @@
"dependencies": { "dependencies": {
"@emotion/react": "11.11.4", "@emotion/react": "11.11.4",
"@emotion/styled": "11.11.0", "@emotion/styled": "11.11.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mui/icons-material": "5.15.11", "@mui/icons-material": "5.15.11",
"@mui/lab": "5.0.0-alpha.166", "@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
@ -1095,6 +1099,64 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
"integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
"integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.3"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.14", "version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",

View File

@ -16,6 +16,10 @@
"dependencies": { "dependencies": {
"@emotion/react": "11.11.4", "@emotion/react": "11.11.4",
"@emotion/styled": "11.11.0", "@emotion/styled": "11.11.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mui/icons-material": "5.15.11", "@mui/icons-material": "5.15.11",
"@mui/lab": "5.0.0-alpha.166", "@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11", "@mui/material": "5.15.11",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

34
public/logo.svg Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 734.95 255.06">
<defs>
<style>
.cls-1 {
fill: #47b17d;
}
.cls-2 {
fill: #4c82a3;
}
.cls-3 {
fill: #7d54a3;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<g>
<path class="cls-3" d="M335.64,100.98c0-4.9,.94-9.5,2.81-13.79,1.87-4.29,4.42-8.05,7.64-11.27,3.22-3.22,6.98-5.77,11.27-7.64,4.29-1.87,8.89-2.81,13.79-2.81h54.34v23.7h-54.34c-1.65,0-3.19,.3-4.62,.91-1.43,.61-2.68,1.45-3.76,2.52s-1.91,2.33-2.52,3.76c-.61,1.43-.91,2.97-.91,4.62s.3,3.21,.91,4.67c.61,1.46,1.45,2.73,2.52,3.8,1.07,1.07,2.33,1.91,3.76,2.52,1.43,.61,2.97,.91,4.62,.91h23.7c4.9,0,9.51,.92,13.83,2.77,4.32,1.85,8.09,4.38,11.31,7.6s5.75,6.99,7.6,11.31c1.84,4.32,2.77,8.93,2.77,13.83s-.92,9.5-2.77,13.79c-1.85,4.29-4.38,8.05-7.6,11.27s-6.99,5.77-11.31,7.64c-4.32,1.87-8.93,2.81-13.83,2.81h-52.61v-23.7h52.61c1.65,0,3.19-.3,4.62-.91,1.43-.61,2.68-1.45,3.76-2.52s1.91-2.33,2.52-3.76c.61-1.43,.91-2.97,.91-4.62s-.3-3.19-.91-4.62c-.61-1.43-1.45-2.68-2.52-3.76-1.07-1.07-2.33-1.91-3.76-2.52-1.43-.6-2.97-.91-4.62-.91h-23.7c-4.9,0-9.5-.94-13.79-2.81-4.29-1.87-8.05-4.42-11.27-7.64-3.22-3.22-5.77-6.99-7.64-11.31-1.87-4.32-2.81-8.93-2.81-13.83Z"/>
<path class="cls-3" d="M469.59,183.9h-23.7V65.47h23.7v118.43Z"/>
<path class="cls-3" d="M585.8,171.92c-5.51,4.68-11.64,8.27-18.42,10.78-6.77,2.5-13.82,3.76-21.14,3.76-5.62,0-11.03-.73-16.23-2.19-5.2-1.46-10.06-3.52-14.58-6.19-4.52-2.67-8.65-5.86-12.39-9.58-3.75-3.72-6.94-7.85-9.58-12.39s-4.7-9.43-6.15-14.66c-1.46-5.23-2.19-10.65-2.19-16.27s.73-11.01,2.19-16.19c1.46-5.17,3.51-10.03,6.15-14.58,2.64-4.54,5.83-8.67,9.58-12.39,3.74-3.72,7.87-6.9,12.39-9.54,4.51-2.64,9.37-4.69,14.58-6.15s10.61-2.19,16.23-2.19c7.32,0,14.37,1.25,21.14,3.76,6.77,2.51,12.91,6.1,18.42,10.78l-12.39,20.65c-3.58-3.63-7.71-6.48-12.39-8.55-4.68-2.06-9.61-3.1-14.78-3.1s-10.03,.99-14.58,2.97c-4.54,1.98-8.52,4.67-11.93,8.05-3.41,3.39-6.11,7.35-8.09,11.89-1.98,4.54-2.97,9.4-2.97,14.58s.99,10.13,2.97,14.7c1.98,4.57,4.68,8.56,8.09,11.98,3.41,3.41,7.39,6.11,11.93,8.09,4.54,1.98,9.4,2.97,14.58,2.97,2.97,0,5.86-.36,8.67-1.07,2.81-.71,5.48-1.71,8.01-2.97v-33.7h22.88v46.75Z"/>
<path class="cls-3" d="M627.75,183.9h-23.7V65.47h23.7v118.43Z"/>
<path class="cls-3" d="M699.44,183.9h-23.62V89.17h-35.6v-23.7h94.73v23.7h-35.51v94.73Z"/>
</g>
<g>
<path class="cls-2" d="M181.53,115.06h0c-9.4-36.67-56.77-24.79-121.09-12.57C-3.54,114.64-25.35,19.85,37.72,3.62,46.91,1.26,56.55,0,66.47,0c63.55,0,115.06,51.51,115.06,115.06Z"/>
<path class="cls-1" d="M100,140h0c9.4,36.67,56.77,24.79,121.09,12.57,63.98-12.16,85.79,82.64,22.72,98.86-9.19,2.36-18.83,3.62-28.76,3.62-63.55,0-115.06-51.51-115.06-115.06Z"/>
<circle class="cls-3" cx="140.77" cy="127.53" r="24.88"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

65
src/App.scss Normal file
View File

@ -0,0 +1,65 @@
@import './styles/colors.scss';
@import './styles/sizes.scss';
@import './styles/typography.scss';
:root {
// Import colors once as variables
--primary-main: #{$primary-main};
--primary-light: #{$primary-light};
--primary-dark: #{$primary-dark};
--secondary-main: #{$secondary-main};
--box-shadow-color: #{$box-shadow-color};
--border-color: #{$border-color};
--body-background-color: #{$body-background-color};
--overlay-background-color: #{$overlay-background-color};
--text-color: #{$text-color};
--input-text-color: #{$input-text-color};
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.2;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0;
}
body {
color: $text-color;
font-family: $font-familiy;
letter-spacing: $letter-spacing;
font-size: $body-font-size;
font-weight: $body-font-weight;
line-height: $body-line-height;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
}
a {
font-weight: 500;
color: $primary-main;
text-decoration: none;
text-decoration-color: inherit;
transition: ease 0.4s;
&:hover {
color: $primary-light;
text-decoration: underline;
text-decoration-color: inherit;
}
}

View File

@ -7,10 +7,12 @@ import {
appPrivateRoutes, appPrivateRoutes,
appPublicRoutes, appPublicRoutes,
privateRoutes, privateRoutes,
publicRoutes publicRoutes,
recursiveRouteRenderer
} from './routes' } from './routes'
import { State } from './store/rootReducer' import { State } from './store/rootReducer'
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils' import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
import './App.scss'
const App = () => { const App = () => {
const authState = useSelector((state: State) => state.auth) const authState = useSelector((state: State) => state.auth)
@ -48,39 +50,18 @@ const App = () => {
return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}` return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}`
} }
// Hide route only if loggedIn and r.hiddenWhenLoggedIn are both true
const publicRoutesList = recursiveRouteRenderer(publicRoutes, (r) => {
return !authState.loggedIn || !r.hiddenWhenLoggedIn
})
const privateRouteList = recursiveRouteRenderer(privateRoutes)
return ( return (
<Routes> <Routes>
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
{authState?.loggedIn && {authState?.loggedIn && privateRouteList}
privateRoutes.map((route, index) => ( {publicRoutesList}
<Route
key={route.path + index}
path={route.path}
element={route.element}
/>
))}
{publicRoutes.map((route, index) => {
if (authState?.loggedIn) {
if (!route.hiddenWhenLoggedIn) {
return (
<Route
key={route.path + index}
path={route.path}
element={route.element}
/>
)
}
} else {
return (
<Route
key={route.path + index}
path={route.path}
element={route.element}
/>
)
}
})}
<Route path="*" element={<Navigate to={handleRootRedirect()} />} /> <Route path="*" element={<Navigate to={handleRootRedirect()} />} />
</Route> </Route>
</Routes> </Routes>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 319.87">
<g id="Layer_1-2" data-name="Layer 1">
<path d="M113.31,3.31C73.23,10.93,35.28,18.5,0,24.8V307.11c30.14,8.31,61.89,12.76,94.68,12.76,30.67,0,60.43-3.88,88.82-11.19C378.31,258.56,310.93-34.23,113.31,3.31Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 319.87">
<g id="Layer_1-2" data-name="Layer 1">
<path d="M205.32,0c-30.67,0-60.43,3.88-88.82,11.19C-78.31,61.31-10.93,354.1,186.69,316.56c40.08-7.61,78.02-15.18,113.31-21.49V12.76C269.86,4.45,238.11,0,205.32,0Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

BIN
src/assets/images/nostr.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,50 +0,0 @@
$primary-main: var(--mui-palette-primary-main);
$primary-light: var(--mui-palette-primary-light);
$primary-dark: var(--mui-palette-primary-dark);
$box-shadow-color: rgba(0, 0, 0, 0.1);
$border-color: #27323c;
$page-background-color: #f4f4fb;
$modal-input-background: #f4f4fb;
$background-color: #fff;
$text-color: #3e3e3e;
$input-text-color: #717171;
$info-text-color: #4169e1;
$icon-color: #c1c1c1;
$zap-icon-color: #ffd700;
$btn-background-color: $primary-main;
$btn-color: #fff;
$progress-box-background-color: $primary-main;
$progress-box-inner-background-color: $primary-dark;
$rank-progress-background-color: #445c76;
$correct-answer-background-color: $primary-light;
$incorrect-answer-background-color: #e84d67;
$incorrect-answer-label-color: #fff;
$checkbox-border-color: #ccc;
$checkbox-hover-border-color: #888;
$checkbox-checked-background-color: $primary-main;
$checkbox-checked-color: #fff;
$comment-notice-background-color: #0d6efd;
$comment-notice-color: #fff;
$comment-divider-color: #eee;
$activated-topic-background-color: var(--mui-palette-info-dark);
$error-msg-color: red;
$suggestion-box-heading-color: #dfe0e0;
$callOut-success-background: $primary-light;
$callOut-success-color: $primary-dark;
$review-feedback-correct: #178b13;
$review-feedback-incorrect: #d82222;
$review-feedback-neutral: #f39220;
$review-feedback-selected-color: #fff;

View File

@ -34,6 +34,8 @@ import {
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { setUserRobotImage } from '../../store/userRobotImage/action' import { setUserRobotImage } from '../../store/userRobotImage/action'
import { Container } from '../Container'
import { ButtonIcon } from '../ButtonIcon'
const metadataController = new MetadataController() const metadataController = new MetadataController()
@ -117,21 +119,29 @@ export const AppBar = () => {
const isAuthenticated = authState?.loggedIn === true const isAuthenticated = authState?.loggedIn === true
return ( return (
<AppBarMui position="fixed" className={styles.AppBar}> <AppBarMui
<Toolbar className={styles.toolbar}> position="fixed"
className={styles.AppBar}
sx={{
boxShadow: 'none'
}}
>
<Container>
<Toolbar className={styles.toolbar} disableGutters={true}>
<Box className={styles.logoWrapper}> <Box className={styles.logoWrapper}>
<img src="/logo.png" alt="Logo" onClick={() => navigate('/')} /> <img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
</Box> </Box>
<Box className={styles.rightSideBox}> <Box className={styles.rightSideBox}>
{!isAuthenticated && ( {!isAuthenticated && (
<Button <Button
startIcon={<ButtonIcon />}
onClick={() => { onClick={() => {
navigate(appPublicRoutes.login) navigate(appPublicRoutes.nostr)
}} }}
variant="contained" variant="contained"
> >
Sign in LOGIN
</Button> </Button>
)} )}
@ -160,7 +170,10 @@ export const AppBar = () => {
<MenuItem <MenuItem
sx={{ sx={{
justifyContent: 'center', justifyContent: 'center',
display: { md: 'none' } display: { md: 'none' },
fontWeight: 500,
fontSize: '14px',
color: 'var(--text-color)'
}} }}
> >
<Typography variant="h6">{username}</Typography> <Typography variant="h6">{username}</Typography>
@ -223,6 +236,7 @@ export const AppBar = () => {
)} )}
</Box> </Box>
</Toolbar> </Toolbar>
</Container>
</AppBarMui> </AppBarMui>
) )
} }

View File

@ -1,12 +1,14 @@
@import '../../colors.scss'; @import '../../styles/colors.scss';
@import '../../styles/sizes.scss';
.AppBar { .AppBar {
background-color: $background-color !important; background-color: $overlay-background-color !important;
z-index: 1400 !important; height: $header-height;
height: 60px;
flex-direction: row !important; flex-direction: row !important;
align-items: center; align-items: center;
border-bottom: solid 1px rgba(0, 0, 0, 0.075);
.toolbar { .toolbar {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;

View File

@ -0,0 +1,14 @@
import styles from './style.module.scss'
import placeholder from '../../assets/images/placeholder.png'
interface ButtonIconProps {
src?: string
alt?: string
}
export const ButtonIcon = ({
src = placeholder,
alt = ''
}: ButtonIconProps) => {
return <img src={src} alt={alt} className={styles.icon}></img>
}

View File

@ -0,0 +1,5 @@
.icon {
border-radius: 100px;
width: 20px;
height: 20px;
}

View File

@ -0,0 +1,24 @@
import { CSSProperties, PropsWithChildren } from 'react'
import defaultStyle from './style.module.scss'
interface ContainerProps {
style?: CSSProperties
className?: string
}
export const Container = ({
style = {},
className = '',
children
}: PropsWithChildren<ContainerProps>) => {
return (
<div
style={{
...style
}}
className={`${defaultStyle.container} ${className}`}
>
{children}
</div>
)
}

View File

@ -0,0 +1,8 @@
@import '../../styles/sizes.scss';
.container {
width: 100%;
max-width: 1400px;
padding-inline: $default-container-padding-inline;
margin-inline: auto;
}

View File

@ -0,0 +1,10 @@
import { PropsWithChildren } from 'react'
import styles from './styles.module.scss'
/**
* This Component overlays FontAwesomeIcon icons on top of each other
*/
export const FontAwesomeIconStack = ({ children }: PropsWithChildren) => {
return <div className={styles.iconStackContainer}>{children}</div>
}

View File

@ -0,0 +1,7 @@
.iconStackContainer {
position: relative;
> * {
position: absolute;
}
}

View File

@ -0,0 +1,128 @@
import { Box, Button, Link as LinkMui } from '@mui/material'
import { Link } from 'react-router-dom'
import styles from './style.module.scss'
import { Container } from '../Container'
import nostrImage from '../../assets/images/nostr.gif'
import { appPublicRoutes } from '../../routes'
export const Footer = () => (
<footer className={`${styles.borderTop} ${styles.footer}`}>
<Container
style={{
paddingBlock: '50px'
}}
>
<Box
display={'grid'}
sx={{
gridTemplateColumns: {
xs: '1fr',
md: '0.5fr 2fr 0.5fr'
},
alignItems: {
xs: 'center',
md: 'start'
}
}}
gap={'50px'}
>
<LinkMui
sx={{
justifySelf: {
xs: 'center',
md: 'start'
}
}}
component={Link}
to={'/'}
className={styles.logo}
>
<img src="/logo.svg" alt="Logo" />
</LinkMui>
<Box
display={'grid'}
sx={{
gap: '15px',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
xl: 'repeat(3, 1fr)'
},
borderBlock: {
xs: 'solid 1px rgba(0, 0, 0, 0.1)',
md: 'unset'
},
paddingY: {
xs: '10px',
md: 'unset'
}
}}
component={'nav'}
className={styles.nav}
>
<Button
sx={{
justifyContent: 'center'
}}
component={Link}
to={'/'}
variant={'text'}
>
Home
</Button>
<Button
sx={{
justifyContent: 'center'
}}
component={LinkMui}
href={appPublicRoutes.docs}
target="_blank"
variant={'text'}
>
Documentation
</Button>
<Button
sx={{
justifyContent: 'center'
}}
component={LinkMui}
href={appPublicRoutes.source}
target="_blank"
variant={'text'}
>
Source
</Button>
</Box>
<Box
className={styles.links}
sx={{
justifySelf: {
xs: 'center',
md: 'end'
}
}}
>
<Button
component={LinkMui}
href="https://snort.social/npub1yay8e9sqk94jfgdlkpgeelj2t5ddsj2eu0xwt4kh4xw5ses2rauqnstrdv"
target="_blank"
sx={{
minWidth: '45px',
padding: '10px'
}}
variant={'contained'}
>
<img src={nostrImage} width="25" alt="nostr logo" height="25" />
</Button>
</Box>
</Box>
</Container>
<div className={`${styles.borderTop} ${styles.credits}`}>
Built by&nbsp;
<a href="https://nostrdev.com/" target="_blank">
Nostr Dev
</a>{' '}
2024.
</div>
</footer>
)

View File

@ -0,0 +1,49 @@
@import '../../styles/colors.scss';
.borderTop {
border-top: solid 1px rgba(0, 0, 0, 0.075);
}
.footer {
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
}
.links {
font-weight: 500;
color: rgba(0, 0, 0, 0.5);
> a + a {
margin-left: 25px;
}
}
.nav {
color: rgba(0, 0, 0, 0.5);
a {
width: 100%;
}
}
.credits {
width: 100%;
text-align: center;
padding: 10px 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
font-weight: 500;
}
.logo {
width: 100%;
max-width: 300px;
> img {
width: 100%;
height: auto;
}
}

View File

@ -0,0 +1,28 @@
import { ReactElement } from 'react'
import styles from './style.module.scss'
interface CardComponentProps {
icon: ReactElement
title: ReactElement
description: ReactElement
actions?: ReactElement
}
export const CardComponent = ({
icon,
title,
description,
actions
}: CardComponentProps) => {
return (
<div className={styles.card}>
<h3 className={styles.title}>
<div className={styles.icon}>{icon}</div>
{title}
</h3>
<p className={styles.description}>{description}</p>
{actions ? <div className={styles.actions}>{actions}</div> : null}
</div>
)
}

View File

@ -0,0 +1,64 @@
@import '../../../styles/colors.scss';
.card {
border-radius: 4px;
padding: 25px;
position: relative;
background: $overlay-background-color;
transition: ease 0.2s;
display: flex;
flex-direction: column;
gap: 15px;
&:hover {
color: white;
background: $primary-main;
.icon,
a {
color: inherit;
}
}
button {
transition:
color 0s,
background-color 0.2s;
}
a {
transition: none;
}
}
.icon {
color: $primary-main;
font-size: 25px;
line-height: 1;
height: 25px;
width: 25px;
}
.title {
display: flex;
flex-direction: row;
grid-gap: 10px;
align-items: center;
font-weight: bold;
font-size: 20px;
}
.description {
font-size: 14px;
font-weight: 500;
}
.actions {
margin-top: auto;
text-align: right;
}

View File

@ -11,7 +11,7 @@ export const LoadingSpinner = (props: Props) => {
<div className={styles.loadingSpinnerOverlay}> <div className={styles.loadingSpinnerOverlay}>
<div className={styles.loadingSpinnerContainer}> <div className={styles.loadingSpinnerContainer}>
<div className={styles.loadingSpinner}></div> <div className={styles.loadingSpinner}></div>
{desc && <span>{desc}</span>} {desc && <span className={styles.loadingSpinnerDesc}>{desc}</span>}
</div> </div>
</div> </div>
) )

View File

@ -1,3 +1,5 @@
@import '../../styles/colors.scss';
.loadingSpinnerOverlay { .loadingSpinnerOverlay {
position: fixed; position: fixed;
top: 0; top: 0;
@ -18,15 +20,21 @@
} }
.loadingSpinner { .loadingSpinner {
border: 4px solid #f3f3f3; background: url('/favicon.png') no-repeat center / cover;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px; width: 40px;
height: 40px; height: 40px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
} }
.loadingSpinnerDesc {
color: white;
margin-top: 13px;
font-size: 16px;
font-weight: 400;
}
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);

View File

@ -0,0 +1,42 @@
import { useNavigate } from 'react-router-dom'
import { getProfileRoute } from '../../routes'
import styles from './styles.module.scss'
import React from 'react'
import { AvatarIconButton } from '../UserAvatarIconButton'
interface UserAvatarProps {
name: string
pubkey: string
image?: string
}
/**
* This component will be used for the displaying username and profile picture.
* Clicking will navigate to the user's profile.
*/
export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => {
const navigate = useNavigate()
const handleClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
navigate(getProfileRoute(pubkey))
}
return (
<div className={styles.container}>
<AvatarIconButton
src={image}
hexKey={pubkey}
aria-label={`account of user ${name}`}
color="inherit"
onClick={handleClick}
/>
{name ? (
<label onClick={handleClick} className={styles.username}>
{name}
</label>
) : null}
</div>
)
}

View File

@ -0,0 +1,13 @@
.container {
display: flex;
align-items: center;
gap: 10px;
flex-grow: 1;
}
.username {
cursor: pointer;
font-weight: 500;
font-size: 14px;
color: var(--text-color);
}

View File

@ -0,0 +1,32 @@
import { IconButton, IconButtonProps } from '@mui/material'
import styles from './style.module.scss'
import { getRoboHashPicture } from '../../utils'
interface AvatarIconButtonProps extends IconButtonProps {
src: string | undefined
hexKey: string | undefined
}
/**
* This component displays profile image inside IconButton
* @param {string | undefined} props.src - image source or robohash picture
* @param {string | undefined} props.hexKey - robohash and affects border
* @param {IconButtonProps} props - component extends mui's IconButton
*/
export const AvatarIconButton = (props: AvatarIconButtonProps) => {
const { src, hexKey, ...rest } = props
return (
<IconButton {...rest}>
<img
src={src || getRoboHashPicture(hexKey)}
alt="user image"
className={`${styles.icon}`}
style={{
borderStyle: hexKey ? 'solid' : 'none',
borderColor: `#${hexKey?.substring(0, 6)}`
}}
/>
</IconButton>
)
}

View File

@ -0,0 +1,7 @@
.icon {
width: 40px;
height: 40px;
border-radius: 50%;
border-width: 3px;
overflow: hidden;
}

View File

@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: row;
justify-content: end;
align-items: center;
gap: 12px;
}

View File

@ -1,9 +1,9 @@
import { Box, IconButton, Typography, useTheme } from '@mui/material' import { Typography } from '@mui/material'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import { getProfileRoute } from '../routes'
import { State } from '../store/rootReducer' import { State } from '../store/rootReducer'
import { hexToNpub } from '../utils'
import styles from './username.module.scss'
import { AvatarIconButton } from './UserAvatarIconButton'
type Props = { type Props = {
username: string username: string
@ -11,92 +11,36 @@ type Props = {
handleClick: (event: React.MouseEvent<HTMLElement>) => void handleClick: (event: React.MouseEvent<HTMLElement>) => void
} }
/**
* This component will be used for the displaying logged in user in AppBar.
* Clicking will open the menu.
*/
const Username = ({ username, avatarContent, handleClick }: Props) => { const Username = ({ username, avatarContent, handleClick }: Props) => {
const hexKey = useSelector((state: State) => state.auth.usersPubkey) const hexKey = useSelector((state: State) => state.auth.usersPubkey)
return ( return (
<IconButton <div className={styles.container}>
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleClick}
color="inherit"
>
<img
src={avatarContent}
alt="user-avatar"
className="profile-image"
style={{
borderWidth: '3px',
borderStyle: hexKey ? 'solid' : 'none',
borderColor: `#${hexKey?.substring(0, 6)}`
}}
/>
<Typography <Typography
variant="h6" variant="h6"
sx={{ sx={{
color: '#3e3e3e', fontWeight: 500,
padding: '0 8px', fontSize: '14px',
color: 'var(--text-color)',
display: { xs: 'none', md: 'flex' } display: { xs: 'none', md: 'flex' }
}} }}
> >
{username} {username}
</Typography> </Typography>
</IconButton> <AvatarIconButton
src={avatarContent}
hexKey={hexKey}
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleClick}
/>
</div>
) )
} }
export default Username export default Username
type UserProps = {
pubkey: string
name: string
image?: string
}
/**
* This component will be used for the displaying username and profile picture.
* If image is not available, robohash image will be displayed
*/
export const UserComponent = ({ pubkey, name, image }: UserProps) => {
const theme = useTheme()
const navigate = useNavigate()
const npub = hexToNpub(pubkey)
const roboImage = `https://robohash.org/${npub}.png?set=set3`
const handleClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
// navigate to user's profile
navigate(getProfileRoute(pubkey))
}
return (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: '10px', flexGrow: 1 }}
>
<img
src={image || roboImage}
alt="User Image"
className="profile-image"
style={{
borderWidth: '3px',
borderStyle: 'solid',
borderColor: `#${pubkey.substring(0, 6)}`
}}
onClick={handleClick}
/>
<Typography
component="label"
sx={{
textAlign: 'center',
cursor: 'pointer',
color: theme.palette.text.primary
}}
onClick={handleClick}
>
{name}
</Typography>
</Box>
)
}

View File

@ -1,55 +1,28 @@
:root { :root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
word-break: break-word;
} }
a { * {
font-weight: 500; box-sizing: border-box;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
} }
body { body {
margin: 0; margin: 0;
/* display: flex;
place-items: center; */
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
background-color: #f4f4fb;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
.qrzap { .qrzap {
position: fixed; position: fixed;
top: 80px; top: 80px;
@ -57,21 +30,10 @@ button {
z-index: 100; z-index: 100;
} }
@media (prefers-color-scheme: light) { #root {
:root { min-height: 100vh;
color: #213547; display: flex;
background-color: #ffffff; flex-direction: column;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
.main {
padding: 60px 0;
} }
.hide-mobile { .hide-mobile {
@ -90,6 +52,7 @@ button {
* when this class is assigned to a component, user will not be able to select and copy the content from that component * when this class is assigned to a component, user will not be able to select and copy the content from that component
*/ */
.no-select { .no-select {
-webkit-user-select: none;
user-select: none; user-select: none;
} }
@ -144,3 +107,44 @@ button:disabled {
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
} }
/* Fonts */
@font-face {
font-family: 'Roboto';
src: local('Roboto Medium'), local('Roboto-Medium'),
url('assets/fonts/roboto-medium.woff2') format('woff2'),
url('assets/fonts/roboto-medium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Roboto';
src: local('Roboto Light'), local('Roboto-Light'),
url('assets/fonts/roboto-light.woff2') format('woff2'),
url('assets/fonts/roboto-light.woff') format('woff');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Roboto';
src: local('Roboto Bold'), local('Roboto-Bold'),
url('assets/fonts/roboto-bold.woff2') format('woff2'),
url('assets/fonts/roboto-bold.woff') format('woff');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Roboto';
src: local('Roboto'), local('Roboto-Regular'),
url('assets/fonts/roboto-regular.woff2') format('woff2'),
url('assets/fonts/roboto-regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}

View File

@ -1,5 +1,3 @@
import { Box } from '@mui/material'
import Container from '@mui/material/Container'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
@ -28,6 +26,8 @@ import {
} from '../utils' } from '../utils'
import { useAppSelector } from '../hooks' import { useAppSelector } from '../hooks'
import { SubCloser } from 'nostr-tools/abstract-pool' import { SubCloser } from 'nostr-tools/abstract-pool'
import styles from './style.module.scss'
import { Footer } from '../components/Footer/Footer'
export const MainLayout = () => { export const MainLayout = () => {
const dispatch: Dispatch = useDispatch() const dispatch: Dispatch = useDispatch()
@ -136,7 +136,7 @@ export const MainLayout = () => {
} }
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc(`Fetching user's app data`) setLoadingSpinnerDesc(`Loading SIGit History`)
getUsersAppData() getUsersAppData()
.then((appData) => { .then((appData) => {
if (appData) { if (appData) {
@ -152,19 +152,10 @@ export const MainLayout = () => {
return ( return (
<> <>
<AppBar /> <AppBar />
<main className={styles.main}>
<Box className="main">
<Container
sx={{
position: 'relative',
maxWidth: {
xs: '550px'
}
}}
>
<Outlet /> <Outlet />
</Container> </main>
</Box> <Footer />
</> </>
) )
} }

109
src/layouts/modal/index.tsx Normal file
View File

@ -0,0 +1,109 @@
import { Button, IconButton, Modal as ModalMui } from '@mui/material'
import {
Link,
matchPath,
Outlet,
useLocation,
useNavigate
} from 'react-router-dom'
import styles from './style.module.scss'
import { appPublicRoutes } from '../../routes'
import { faClose } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import nostrImage from '../../assets/images/nostr.gif'
function useRouteMatch(patterns: readonly string[]) {
const { pathname } = useLocation()
for (let i = 0; i < patterns.length; i += 1) {
const pattern = patterns[i]
const possibleMatch = matchPath(pattern, pathname)
if (possibleMatch !== null) {
return possibleMatch
}
}
return null
}
export const Modal = () => {
const navigate = useNavigate()
const tabs = [
{ to: appPublicRoutes.login, title: 'Login', label: 'Login' },
{ to: appPublicRoutes.register, title: 'Register', label: 'Register' },
{
to: appPublicRoutes.nostr,
title: 'Login',
sx: { padding: '10px' },
label: <img src={nostrImage} width="25" alt="nostr logo" height="25" />
}
]
const routeMatch = useRouteMatch(tabs.map((t) => t.to))
const activeTab = routeMatch?.pattern?.path
const handleClose = () => {
navigate(appPublicRoutes.landingPage)
}
return (
<ModalMui open={true} onClose={handleClose} aria-labelledby="modal-title">
<div className={styles.modal}>
<header className={styles.header}>
<h2 className={styles.title} id="modal-title">
{tabs.find((t) => activeTab === t.to)?.title}
</h2>
<IconButton
onClick={handleClose}
sx={{
fontSize: '18px',
color: 'rgba(0, 0, 0, 0.5)',
padding: '8px 15px',
borderRadius: '4px',
':hover': {
background: 'var(--primary-light)',
color: 'white'
}
}}
>
<FontAwesomeIcon icon={faClose} />
</IconButton>
</header>
<main className={styles.body}>
<ul>
{tabs.map((t) => {
return (
<li key={t.to}>
<Button
component={Link}
to={t.to}
sx={{
width: '100%',
height: '100%',
...(t?.sx ? t.sx : {})
}}
variant={
activeTab === t.to || t.to === appPublicRoutes.nostr
? 'contained'
: 'text'
}
>
{t.label}
</Button>
</li>
)
})}
</ul>
{activeTab === appPublicRoutes.login ||
activeTab === appPublicRoutes.register ? (
<div className={styles.comingSoon}>Coming soon!</div>
) : null}
<div className={styles.tabContent}>
<Outlet />
</div>
</main>
<footer className={styles.footer}>Welcome to SIGit!</footer>
</div>
</ModalMui>
)
}

View File

@ -0,0 +1,84 @@
@import '../../styles/colors.scss';
$default-modal-padding: 15px 25px;
.modal {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, 0);
background-color: $overlay-background-color;
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
border-radius: 4px;
}
.header {
display: flex;
grid-gap: 10px;
justify-content: space-between;
padding: $default-modal-padding;
border-bottom: solid 1px rgba(0, 0, 0, 0.1);
}
.title {
font-size: 24px;
font-weight: bold;
}
.body {
display: flex;
flex-direction: column;
grid-gap: 15px;
padding: $default-modal-padding;
> ul {
margin: 0;
padding: 0;
list-style: none;
width: 100%;
display: flex;
flex-direction: row;
grid-gap: 10px;
> li {
flex-grow: 1;
&:last-child {
max-width: 65px;
padding: 0 0 0 10px;
border-left: solid 1px rgba(0, 0, 0, 0.1);
}
}
}
}
.tabContent {
display: flex;
flex-direction: column;
grid-gap: 15px;
padding-inline: 10px;
}
.comingSoon {
padding: 15px;
color: rgba(0, 0, 0, 0.5);
text-align: center;
font-weight: 400;
font-size: 16px;
}
.footer {
padding: 15px;
border-top: solid 1px rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.5);
text-align: center;
font-weight: 400;
font-size: 16px;
}

View File

@ -1,22 +1,8 @@
@import '../colors.scss'; @import '../styles/colors.scss';
@import '../styles/sizes.scss';
@font-face { .main {
font-family: 'Avenir'; flex-grow: 1;
font-weight: normal; padding: $header-height + $body-vertical-padding 0 $body-vertical-padding 0;
src: local('Avenir'), background-color: $body-background-color;
url(../assets/avenir-font/AvenirLTStd-Roman.otf) format('opentype');
}
@font-face {
font-family: 'Avenir';
font-weight: bold;
src: local('Avenir'),
url(../assets/avenir-font/AvenirLTStd-Black.otf) format('opentype');
}
@font-face {
font-family: 'Avenir';
font-weight: lighter;
src: local('Avenir'),
url(../assets/avenir-font/AvenirLTStd-Book.otf) format('opentype');
} }

View File

@ -30,7 +30,7 @@ import { useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username' import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController, NostrController } from '../../controllers' import { MetadataController, NostrController } from '../../controllers'
import { appPrivateRoutes } from '../../routes' import { appPrivateRoutes } from '../../routes'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
@ -841,7 +841,7 @@ const DisplayUser = ({
return ( return (
<TableRow key={index}> <TableRow key={index}>
<TableCell className={styles.tableCell}> <TableCell className={styles.tableCell}>
<UserComponent <UserAvatar
pubkey={user.pubkey} pubkey={user.pubkey}
name={ name={
userMeta?.display_name || userMeta?.display_name ||
@ -996,7 +996,7 @@ const SignerRow = ({
sx={{ display: 'flex', alignItems: 'center', gap: '10px' }} sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}
> >
<DragHandle /> <DragHandle />
<UserComponent <UserAvatar
pubkey={user.pubkey} pubkey={user.pubkey}
name={ name={
userMeta?.display_name || userMeta?.display_name ||

View File

@ -1,4 +1,4 @@
@import '../../colors.scss'; @import '../../styles/colors.scss';
.container { .container {
display: flex; display: flex;

View File

@ -5,7 +5,7 @@ import { Event, kinds, verifyEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { UserComponent } from '../../components/username' import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController } from '../../controllers' import { MetadataController } from '../../controllers'
import { useAppSelector } from '../../hooks' import { useAppSelector } from '../../hooks'
import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { appPrivateRoutes, appPublicRoutes } from '../../routes'
@ -18,6 +18,7 @@ import {
shorten shorten
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../components/Container'
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -81,8 +82,7 @@ export const HomePage = () => {
} }
return ( return (
<> <Container className={styles.container}>
<Box className={styles.container}>
<Box className={styles.header}> <Box className={styles.header}>
<Typography variant="h3" className={styles.title}> <Typography variant="h3" className={styles.title}>
Sigits Sigits
@ -138,8 +138,7 @@ export const HomePage = () => {
/> />
))} ))}
</Box> </Box>
</Box> </Container>
</>
) )
} }
@ -291,7 +290,7 @@ const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => {
(function () { (function () {
const profile = profiles[submittedBy] const profile = profiles[submittedBy]
return ( return (
<UserComponent <UserAvatar
pubkey={submittedBy} pubkey={submittedBy}
name={ name={
profile?.display_name || profile?.display_name ||
@ -371,7 +370,7 @@ const DisplaySigner = ({ meta, profile, pubkey }: DisplaySignerProps) => {
<Typography variant="button" className={styles.status}> <Typography variant="button" className={styles.status}>
{signStatus} {signStatus}
</Typography> </Typography>
<UserComponent <UserAvatar
pubkey={pubkey} pubkey={pubkey}
name={ name={
profile?.display_name || profile?.display_name ||

View File

@ -2,11 +2,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; gap: 25px;
padding: 10px; }
margin-top: 10px;
background: var(--mui-palette-background-paper);
.header { .header {
display: flex; display: flex;
.title { .title {
@ -19,9 +17,9 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
} }
.submissions { .submissions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@ -93,5 +91,4 @@
} }
} }
} }
}
} }

View File

@ -1,87 +0,0 @@
import { Box, Button, Typography, useTheme } from '@mui/material'
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { appPublicRoutes } from '../../routes'
import { State } from '../../store/rootReducer'
import { saveVisitedLink } from '../../utils'
import styles from './style.module.scss'
const bodyBackgroundColor = document.body.style.backgroundColor
export const LandingPage = () => {
const authState = useSelector((state: State) => state.auth)
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
useEffect(() => {
saveVisitedLink(location.pathname, location.search)
}, [location])
const onSignInClick = async () => {
navigate(appPublicRoutes.login)
}
return (
<div className={styles.landingPage}>
<Box>
<Typography
sx={{
fontWeight: 'bold',
marginBottom: 5,
color: bodyBackgroundColor
? theme.palette.getContrastText(bodyBackgroundColor)
: ''
}}
variant="h4"
>
Secure Document Signing
</Typography>
<Typography
sx={{
color: bodyBackgroundColor
? theme.palette.getContrastText(bodyBackgroundColor)
: ''
}}
variant="body1"
>
SIGit is an open-source and self-hostable solution for secure document
signing and verification. Code is MIT licenced and available at{' '}
<a
className="bold-link"
target="_blank"
href="https://git.sigit.io/sig/it"
>
https://git.sigit.io/sig/it
</a>
.
<br />
<br />
SIGit lets you Create, Sign and Verify from any device with a browser.
<br />
<br />
Unlike other solutions, SIGit is totally private - files are encrypted
locally, and can only be exported by named recipients.
<br />
<br />
Anyone can <Link to={appPublicRoutes.verify}>VERIFY</Link> the
exported document.
</Typography>
</Box>
{!authState?.loggedIn && (
<div className={styles.loginBottomBar}>
<Button
className={styles.loginBtn}
variant="contained"
onClick={onSignInClick}
>
GET STARTED
</Button>
</div>
)}
</div>
)
}

167
src/pages/landing/index.tsx Normal file
View File

@ -0,0 +1,167 @@
import { Box, Button } from '@mui/material'
import { useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { appPublicRoutes } from '../../routes'
import { saveVisitedLink } from '../../utils'
import { CardComponent } from '../../components/Landing/CardComponent/CardComponent'
import { Container } from '../../components/Container'
import styles from './style.module.scss'
import bg_l from '../../assets/images/bg_l.svg'
import bg_r from '../../assets/images/bg_r.svg'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faOsi } from '@fortawesome/free-brands-svg-icons'
import {
faCheck,
faMobileScreenButton,
faShieldHeart,
faSlash,
faUsers,
faWifi
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIconStack } from '../../components/FontAwesomeIconStack'
export const LandingPage = () => {
const navigate = useNavigate()
const location = useLocation()
const onSignInClick = async () => {
navigate(appPublicRoutes.nostr)
}
const cards = [
{
icon: <FontAwesomeIcon width={25} height={25} icon={faOsi} />,
title: <>Open Source</>,
description: (
<>
Code is MIT licenced and available at{' '}
<a href="https://git.nostrdev.com/sigit/sigit.io">
https://git.nostrdev.com/sigit/sigit.io
</a>
.
</>
)
},
{
icon: (
<FontAwesomeIcon width={25} height={25} icon={faMobileScreenButton} />
),
title: <>Multi-Device</>,
description: (
<>
Create, Sign and Verify documents and files from any device with a
browser.
</>
)
},
{
icon: <FontAwesomeIcon width={25} height={25} icon={faShieldHeart} />,
title: <>Secure &amp; Private</>,
description: (
<>
Documents are encrypted locally and can be accessed only by named
recipients.
</>
)
},
{
icon: <FontAwesomeIcon width={25} height={25} icon={faCheck} />,
title: <>Verifiable</>,
description: (
<>
Thanks to Schnorr Signatures and Web of Trust, SIGit is far more
auditable than traditional server-based offerings.
</>
)
},
{
icon: (
<FontAwesomeIconStack>
<FontAwesomeIcon width={25} height={25} icon={faSlash} />
<FontAwesomeIcon width={25} height={25} icon={faWifi} />
</FontAwesomeIconStack>
),
title: <>Works Offline</>,
description: (
<>
Presuming you have a hardware signing device, it is possible to
complete a SIGit round without an internet connection.
</>
)
},
{
icon: <FontAwesomeIcon width={25} height={25} icon={faUsers} />,
title: <>Multi-Party Signing</>,
description: (
<>
Choose any number of Signers and Viewers, track the signature status,
send reminders, get notifications on completion.
</>
)
}
]
useEffect(() => {
saveVisitedLink(location.pathname, location.search)
}, [location])
return (
<div className={styles.background}>
<div
className={`${styles.backgroundBlob} ${styles.backgroundBlobLeft}`}
style={{ backgroundImage: `url("${bg_l}")` }}
></div>
<div
className={`${styles.backgroundBlob} ${styles.backgroundBlobRight}`}
style={{ backgroundImage: `url("${bg_r}")` }}
></div>
<Container className={styles.container}>
<img className={styles.logo} src="/logo.svg" alt="Logo" width={300} />
<div className={styles.titleSection}>
<h1 className={styles.title}>
Secure &amp; Private Document Signing
</h1>
<p className={styles.subTitle}>
An open-source and self-hostable solution for secure document
signing and verification.
</p>
</div>
<Box
display={'grid'}
gap={'25px'}
sx={{
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
xl: 'repeat(3, 1fr)'
}
}}
>
{cards.map((c, i) => (
<CardComponent key={i} {...c} />
))}
</Box>
<p className={styles.description}>
SIGit is a secure &amp; private document signing service where you can
create, sign, and verify any document from any device with a browser.
</p>
<Button
sx={{
fontSize: '22px',
padding: '16px 32px',
backgroundColor: 'var(--secondary-main)'
}}
variant="contained"
onClick={onSignInClick}
>
GET STARTED
</Button>
<Outlet />
</Container>
</div>
)
}

View File

@ -1,29 +1,70 @@
@import '../../colors.scss'; @import '../../styles/colors.scss';
@import '../../styles/sizes.scss';
.landingPage { .background {
position: relative;
}
.container {
display: flex; display: flex;
gap: 45px;
position: relative;
padding-block: 50px;
padding-inline: 50px + $default-container-padding-inline;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
color: $text-color;
min-height: 80vh;
justify-content: center; justify-content: center;
padding-top: 20px; }
.loginBottomBar { .logo {
width: 100%;
max-width: 500px;
}
.titleSection {
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center; gap: 15px;
padding: 10px; }
background-color: rgba(255, 255, 255, 0.7);
border-top: 1px solid rgba(0, 0, 0, 0.096);
position: fixed;
bottom: 0;
left: 0;
right: 0;
.loginBtn { .title {
// margin-top: 20px; font-size: 2.5rem;
min-width: 200px; text-align: center;
}
.subTitle {
font-size: 16px;
font-weight: 500;
}
.description {
font-size: 14px;
text-align: center;
color: rgba(0, 0, 0, 0.5);
}
.backgroundBlob {
position: absolute;
opacity: 0.05;
width: 13%;
height: 349px;
pointer-events: none;
background: no-repeat center / contain;
&Right {
top: 50px;
right: 0;
background-position: right;
} }
&Left {
bottom: 50px;
left: 0;
background-position: left;
}
@media (max-width: 1200px) {
display: none;
} }
} }

View File

@ -1,380 +1,30 @@
import { Box, Button, TextField, Typography } from '@mui/material' import { Button, TextField } from '@mui/material'
import { getPublicKey, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import {
AuthController,
MetadataController,
NostrController
} from '../../controllers'
import {
updateKeyPair,
updateLoginMethod,
updateNsecbunkerPubkey,
updateNsecbunkerRelays
} from '../../store/actions'
import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store'
import { npubToHex, queryNip05 } from '../../utils'
import styles from './style.module.scss'
import { hexToBytes } from '@noble/hashes/utils'
import { NIP05_REGEX } from '../../constants'
export const Login = () => { export const Login = () => {
const [searchParams] = useSearchParams() // TODO: All fields, buttons are disabled
// Feature not implemented
const dispatch: Dispatch = useDispatch()
const navigate = useNavigate()
const authController = new AuthController()
const metadataController = new MetadataController()
const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [inputValue, setInputValue] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
useState(false)
useEffect(() => {
setTimeout(() => {
setIsNostrExtensionAvailable(!!window.nostr)
}, 500)
}, [])
/**
* Call login function when enter is pressed
*/
const handleInputKeyDown = (event: any) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
login()
}
}
const navigateAfterLogin = (path: string) => {
const callbackPath = searchParams.get('callbackPath')
if (callbackPath) {
// base64 decoded path
const path = atob(callbackPath)
navigate(path)
return
}
navigate(path)
}
const loginWithExtension = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
nostrController
.capturePublicKey()
.then(async (pubkey) => {
dispatch(updateLoginMethod(LoginMethods.extension))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath =
await authController.authAndGetMetadataAndRelaysMap(pubkey)
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error('Error capturing public key from nostr extension: ' + err)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
/**
* Login with NSEC or HEX private key
* @param privateKey in HEX format
*/
const loginWithNsec = async (privateKey?: Uint8Array) => {
let nsec = ''
if (privateKey) {
nsec = nip19.nsecEncode(privateKey)
} else {
nsec = inputValue
try {
privateKey = nip19.decode(nsec).data as Uint8Array
} catch (err) {
toast.error(`Error decoding the nsec. ${err}`)
}
}
if (!privateKey) {
toast.error(
'Snap, we failed to convert the private key you provided. Please make sure key is valid.'
)
setIsLoading(false)
return
}
const publickey = getPublicKey(privateKey)
dispatch(
updateKeyPair({
private: nsec,
public: publickey
})
)
dispatch(updateLoginMethod(LoginMethods.privateKey))
setIsLoading(true)
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(publickey)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
setIsLoading(false)
setLoadingSpinnerDesc('')
}
const loginWithNsecBunker = async () => {
let relays: string[] | undefined
let pubkey: string | undefined
setIsLoading(true)
const displayError = (message: string) => {
toast.error(message)
setIsLoading(false)
setLoadingSpinnerDesc('')
}
if (inputValue.match(NIP05_REGEX)) {
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
pubkey = nip05Profile.pubkey
relays = nip05Profile.relays
}
} else if (inputValue.startsWith('npub')) {
pubkey = nip19.decode(inputValue).data as string
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch(() => {
return null
})
if (!metadataEvent) {
return displayError('metadata not found!')
}
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (!metadataContent?.nip05) {
return displayError('nip05 not present in metadata')
}
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
if (nip05Profile.pubkey !== pubkey) {
return displayError(
'pubkey in nip05 does not match with provided npub'
)
}
relays = nip05Profile.relays
}
}
if (!relays || relays.length === 0) {
return displayError('No relay found for nsecbunker')
}
if (!pubkey) {
return displayError('pubkey not found')
}
setLoadingSpinnerDesc('Initializing nsecBunker')
await nostrController.nsecBunkerInit(relays)
setLoadingSpinnerDesc('Creating nsecbunker singer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays(relays))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const loginWithBunkerConnectionString = async () => {
// Extract the key
const keyStartIndex = inputValue.indexOf('bunker://') + 'bunker://'.length
const keyEndIndex = inputValue.indexOf('?relay=')
const key = inputValue.substring(keyStartIndex, keyEndIndex)
const pubkey = npubToHex(key)
if (!pubkey) {
toast.error('Invalid pubkey in bunker connection string.')
setIsLoading(false)
return
}
// Extract the relay value
const relayIndex = inputValue.indexOf('relay=')
const relay = inputValue.substring(
relayIndex + 'relay='.length,
inputValue.length
)
setIsLoading(true)
setLoadingSpinnerDesc('Initializing bunker NDK')
await nostrController.nsecBunkerInit([relay])
setLoadingSpinnerDesc('Creating remote signer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays([relay]))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const login = () => {
if (inputValue.startsWith('bunker://')) {
return loginWithBunkerConnectionString()
}
if (inputValue.startsWith('nsec')) {
return loginWithNsec()
}
if (inputValue.startsWith('npub')) {
return loginWithNsecBunker()
}
if (inputValue.match(NIP05_REGEX)) {
return loginWithNsecBunker()
}
// Check if maybe hex nsec
try {
const privateKey = hexToBytes(inputValue)
const publickey = getPublicKey(privateKey)
if (publickey) return loginWithNsec(privateKey)
} catch (err) {
console.warn('err', err)
}
toast.error(
'Invalid format, please use: private key (hex), nsec..., bunker:// or nip05 format.'
)
return
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className={styles.loginPage}>
<Typography variant="h4">Welcome to Sigit</Typography>
<TextField <TextField
onKeyDown={handleInputKeyDown} label="Email"
label="nip05 login / nip46 bunker string" placeholder="Your email address"
value={inputValue} fullWidth
onChange={(e) => setInputValue(e.target.value)} margin="dense"
sx={{ width: '100%', mt: 2 }} autoComplete="username"
disabled
/>
<TextField
label="Password"
placeholder="Your password"
fullWidth
margin="dense"
autoComplete="current-password"
disabled
/> />
{isNostrExtensionAvailable && (
<Button onClick={loginWithExtension} variant="text">
Login with extension
</Button>
)}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}> <Button variant="contained" fullWidth disabled>
<Button disabled={!inputValue} onClick={login} variant="contained">
Login Login
</Button> </Button>
</Box>
</div>
</> </>
) )
} }

View File

@ -1,6 +0,0 @@
@import '../../colors.scss';
.loginPage {
color: $text-color;
margin-top: 20px;
}

400
src/pages/nostr/index.tsx Normal file
View File

@ -0,0 +1,400 @@
import { Button, Divider, TextField } from '@mui/material'
import { getPublicKey, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import {
AuthController,
MetadataController,
NostrController
} from '../../controllers'
import {
updateKeyPair,
updateLoginMethod,
updateNsecbunkerPubkey,
updateNsecbunkerRelays
} from '../../store/actions'
import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store'
import { npubToHex, queryNip05 } from '../../utils'
import { hexToBytes } from '@noble/hashes/utils'
import { NIP05_REGEX } from '../../constants'
import styles from './styles.module.scss'
export const Nostr = () => {
const [searchParams] = useSearchParams()
const dispatch: Dispatch = useDispatch()
const navigate = useNavigate()
const authController = new AuthController()
const metadataController = new MetadataController()
const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [inputValue, setInputValue] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
useState(false)
useEffect(() => {
setTimeout(() => {
setIsNostrExtensionAvailable(!!window.nostr)
}, 500)
}, [])
/**
* Call login function when enter is pressed
*/
const handleInputKeyDown = (event: any) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
login()
}
}
const navigateAfterLogin = (path: string) => {
const callbackPath = searchParams.get('callbackPath')
if (callbackPath) {
// base64 decoded path
const path = atob(callbackPath)
navigate(path)
return
}
navigate(path)
}
const loginWithExtension = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
nostrController
.capturePublicKey()
.then(async (pubkey) => {
dispatch(updateLoginMethod(LoginMethods.extension))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath =
await authController.authAndGetMetadataAndRelaysMap(pubkey)
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error('Error capturing public key from nostr extension: ' + err)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
/**
* Login with NSEC or HEX private key
* @param privateKey in HEX format
*/
const loginWithNsec = async (privateKey?: Uint8Array) => {
let nsec = ''
if (privateKey) {
nsec = nip19.nsecEncode(privateKey)
} else {
nsec = inputValue
try {
privateKey = nip19.decode(nsec).data as Uint8Array
} catch (err) {
toast.error(`Error decoding the nsec. ${err}`)
}
}
if (!privateKey) {
toast.error(
'Snap, we failed to convert the private key you provided. Please make sure key is valid.'
)
setIsLoading(false)
return
}
const publickey = getPublicKey(privateKey)
dispatch(
updateKeyPair({
private: nsec,
public: publickey
})
)
dispatch(updateLoginMethod(LoginMethods.privateKey))
setIsLoading(true)
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(publickey)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
setIsLoading(false)
setLoadingSpinnerDesc('')
}
const loginWithNsecBunker = async () => {
let relays: string[] | undefined
let pubkey: string | undefined
setIsLoading(true)
const displayError = (message: string) => {
toast.error(message)
setIsLoading(false)
setLoadingSpinnerDesc('')
}
if (inputValue.match(NIP05_REGEX)) {
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
pubkey = nip05Profile.pubkey
relays = nip05Profile.relays
}
} else if (inputValue.startsWith('npub')) {
pubkey = nip19.decode(inputValue).data as string
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch(() => {
return null
})
if (!metadataEvent) {
return displayError('metadata not found!')
}
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (!metadataContent?.nip05) {
return displayError('nip05 not present in metadata')
}
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
if (nip05Profile.pubkey !== pubkey) {
return displayError(
'pubkey in nip05 does not match with provided npub'
)
}
relays = nip05Profile.relays
}
}
if (!relays || relays.length === 0) {
return displayError('No relay found for nsecbunker')
}
if (!pubkey) {
return displayError('pubkey not found')
}
setLoadingSpinnerDesc('Initializing nsecBunker')
await nostrController.nsecBunkerInit(relays)
setLoadingSpinnerDesc('Creating nsecbunker singer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays(relays))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const loginWithBunkerConnectionString = async () => {
// Extract the key
const keyStartIndex = inputValue.indexOf('bunker://') + 'bunker://'.length
const keyEndIndex = inputValue.indexOf('?relay=')
const key = inputValue.substring(keyStartIndex, keyEndIndex)
const pubkey = npubToHex(key)
if (!pubkey) {
toast.error('Invalid pubkey in bunker connection string.')
setIsLoading(false)
return
}
// Extract the relay value
const relayIndex = inputValue.indexOf('relay=')
const relay = inputValue.substring(
relayIndex + 'relay='.length,
inputValue.length
)
setIsLoading(true)
setLoadingSpinnerDesc('Initializing bunker NDK')
await nostrController.nsecBunkerInit([relay])
setLoadingSpinnerDesc('Creating remote signer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays([relay]))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const login = () => {
if (inputValue.startsWith('bunker://')) {
return loginWithBunkerConnectionString()
}
if (inputValue.startsWith('nsec')) {
return loginWithNsec()
}
if (inputValue.startsWith('npub')) {
return loginWithNsecBunker()
}
if (inputValue.match(NIP05_REGEX)) {
return loginWithNsecBunker()
}
// Check if maybe hex nsec
try {
const privateKey = hexToBytes(inputValue)
const publickey = getPublicKey(privateKey)
if (publickey) return loginWithNsec(privateKey)
} catch (err) {
console.warn('err', err)
}
toast.error(
'Invalid format, please use: private key (hex), nsec..., bunker:// or nip05 format.'
)
return
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
{isNostrExtensionAvailable && (
<>
<label className={styles.label} htmlFor="extension-login">
Login by using a browser extension
</label>
<Button
id="extension-login"
onClick={loginWithExtension}
variant="contained"
>
Extension Login
</Button>
<Divider
sx={{
fontSize: '16px'
}}
>
or
</Divider>
</>
)}
<TextField
onKeyDown={handleInputKeyDown}
label="nip05 login / nip46 bunker string"
helperText="Private key (Not recommended)"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
fullWidth
margin="dense"
/>
<Button
disabled={!inputValue}
onClick={login}
variant="contained"
fullWidth
>
Login
</Button>
</>
)
}

View File

@ -0,0 +1 @@
@import '../../styles/form.scss';

View File

@ -14,6 +14,7 @@ import { State } from '../../store/rootReducer'
import { NostrJoiningBlock, ProfileMetadata } from '../../types' import { NostrJoiningBlock, ProfileMetadata } from '../../types'
import { getRoboHashPicture, hexToNpub, shorten } from '../../utils' import { getRoboHashPicture, hexToNpub, shorten } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../components/Container'
export const ProfilePage = () => { export const ProfilePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -155,7 +156,7 @@ export const ProfilePage = () => {
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
{pubkey && ( {pubkey && (
<Box className={styles.container}> <Container className={styles.container}>
<Box <Box
className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`} className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`}
> >
@ -278,7 +279,7 @@ export const ProfilePage = () => {
)} )}
</Box> </Box>
</Box> </Box>
</Box> </Container>
)} )}
</> </>
) )

View File

@ -0,0 +1,40 @@
import { Button, TextField } from '@mui/material'
export const Register = () => {
// TODO: All fields, buttons are disabled
// Feature not implemented
return (
<>
<TextField
label="Email"
placeholder="Your email address"
fullWidth
margin="dense"
autoComplete="username"
disabled
/>
<TextField
label="Password"
placeholder="Your password"
fullWidth
margin="dense"
type="password"
autoComplete="new-password"
disabled
/>
<TextField
label="Confirm password"
placeholder="Re-type your password"
fullWidth
margin="dense"
type="password"
autoComplete="new-password"
disabled
/>
<Button variant="contained" fullWidth disabled>
Register
</Button>
</>
)
}

View File

@ -12,6 +12,7 @@ import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes' import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
import { State } from '../../store/rootReducer' import { State } from '../../store/rootReducer'
import { Container } from '../../components/Container'
export const SettingsPage = () => { export const SettingsPage = () => {
const theme = useTheme() const theme = useTheme()
@ -42,11 +43,11 @@ export const SettingsPage = () => {
} }
return ( return (
<Container>
<List <List
sx={{ sx={{
width: '100%', width: '100%',
bgcolor: 'background.paper', bgcolor: 'background.paper'
marginTop: 2
}} }}
subheader={ subheader={
<ListSubheader <ListSubheader
@ -92,5 +93,6 @@ export const SettingsPage = () => {
{listItem('Local Cache')} {listItem('Local Cache')}
</ListItemButton> </ListItemButton>
</List> </List>
</Container>
) )
} }

View File

@ -13,6 +13,7 @@ import { useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { localCache } from '../../../services' import { localCache } from '../../../services'
import { LoadingSpinner } from '../../../components/LoadingSpinner' import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { Container } from '../../../components/Container'
export const CacheSettingsPage = () => { export const CacheSettingsPage = () => {
const theme = useTheme() const theme = useTheme()
@ -49,7 +50,7 @@ export const CacheSettingsPage = () => {
} }
return ( return (
<> <Container>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<List <List
sx={{ sx={{
@ -91,6 +92,6 @@ export const CacheSettingsPage = () => {
{listItem('Clear Cache')} {listItem('Clear Cache')}
</ListItemButton> </ListItemButton>
</List> </List>
</> </Container>
) )
} }

View File

@ -27,6 +27,7 @@ import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { LoginMethods } from '../../../store/auth/types' import { LoginMethods } from '../../../store/auth/types'
import { SmartToy } from '@mui/icons-material' import { SmartToy } from '@mui/icons-material'
import { getRoboHashPicture } from '../../../utils' import { getRoboHashPicture } from '../../../utils'
import { Container } from '../../../components/Container'
export const ProfileSettingsPage = () => { export const ProfileSettingsPage = () => {
const theme = useTheme() const theme = useTheme()
@ -271,7 +272,7 @@ export const ProfileSettingsPage = () => {
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<div className={styles.container}> <Container className={styles.container}>
<List <List
sx={{ sx={{
bgcolor: 'background.paper', bgcolor: 'background.paper',
@ -380,7 +381,7 @@ export const ProfileSettingsPage = () => {
SAVE SAVE
</LoadingButton> </LoadingButton>
)} )}
</div> </Container>
</> </>
) )
} }

View File

@ -31,6 +31,7 @@ import {
shorten shorten
} from '../../../utils' } from '../../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../../components/Container'
export const RelaysPage = () => { export const RelaysPage = () => {
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
@ -314,7 +315,7 @@ export const RelaysPage = () => {
} }
return ( return (
<Box className={styles.container}> <Container className={styles.container}>
<Box className={styles.relayAddContainer}> <Box className={styles.relayAddContainer}>
<TextField <TextField
label="Add new relay" label="Add new relay"
@ -512,6 +513,6 @@ export const RelaysPage = () => {
))} ))}
</Box> </Box>
)} )}
</Box> </Container>
) )
} }

View File

@ -1,7 +1,6 @@
@import '../../../colors.scss'; @import '../../../styles/colors.scss';
.container { .container {
margin-top: 25px;
color: $text-color; color: $text-color;
.relayURItextfield { .relayURItextfield {
@ -93,11 +92,11 @@
} }
.connectionStatusConnected { .connectionStatusConnected {
background-color: $review-feedback-correct; background-color: $relay-status-connected;
} }
.connectionStatusNotConnected { .connectionStatusNotConnected {
background-color: $review-feedback-incorrect; background-color: $relay-status-notconnected;
} }
.connectionStatusUnknown { .connectionStatusUnknown {

View File

@ -33,6 +33,7 @@ import {
} from '../../utils' } from '../../utils'
import { DisplayMeta } from './internal/displayMeta' import { DisplayMeta } from './internal/displayMeta'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../components/Container'
enum SignedStatus { enum SignedStatus {
Fully_Signed, Fully_Signed,
User_Is_Next_Signer, User_Is_Next_Signer,
@ -850,7 +851,7 @@ export const SignPage = () => {
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}> <Container className={styles.container}>
{displayInput && ( {displayInput && (
<> <>
<Typography component="label" variant="h6"> <Typography component="label" variant="h6">
@ -916,7 +917,7 @@ export const SignPage = () => {
)} )}
</> </>
)} )}
</Box> </Container>
</> </>
) )
} }

View File

@ -30,7 +30,7 @@ import saveAs from 'file-saver'
import { kinds, Event } from 'nostr-tools' import { kinds, Event } from 'nostr-tools'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { UserComponent } from '../../../components/username' import { UserAvatar } from '../../../components/UserAvatar'
import { MetadataController } from '../../../controllers' import { MetadataController } from '../../../controllers'
import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils' import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils'
import styles from '../style.module.scss' import styles from '../style.module.scss'
@ -172,7 +172,7 @@ export const DisplayMeta = ({
{(function () { {(function () {
const profile = metadata[submittedBy] const profile = metadata[submittedBy]
return ( return (
<UserComponent <UserAvatar
pubkey={submittedBy} pubkey={submittedBy}
name={ name={
profile?.display_name || profile?.display_name ||
@ -372,7 +372,7 @@ const DisplayUser = ({
return ( return (
<TableRow> <TableRow>
<TableCell className={styles.tableCell}> <TableCell className={styles.tableCell}>
<UserComponent <UserAvatar
pubkey={user.pubkey} pubkey={user.pubkey}
name={ name={
userMeta?.display_name || userMeta?.display_name ||

View File

@ -1,4 +1,4 @@
@import '../../colors.scss'; @import '../../styles/colors.scss';
.container { .container {
color: $text-color; color: $text-color;

View File

@ -14,7 +14,7 @@ import { Event, kinds, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username' import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController } from '../../controllers' import { MetadataController } from '../../controllers'
import { import {
CreateSignatureEventContent, CreateSignatureEventContent,
@ -36,6 +36,7 @@ import styles from './style.module.scss'
import { Cancel, CheckCircle } from '@mui/icons-material' import { Cancel, CheckCircle } from '@mui/icons-material'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { Container } from '../../components/Container'
export const VerifyPage = () => { export const VerifyPage = () => {
const theme = useTheme() const theme = useTheme()
@ -402,7 +403,7 @@ export const VerifyPage = () => {
return ( return (
<> <>
<UserComponent <UserAvatar
pubkey={pubkey} pubkey={pubkey}
name={ name={
profile?.display_name || profile?.name || shorten(hexToNpub(pubkey)) profile?.display_name || profile?.name || shorten(hexToNpub(pubkey))
@ -456,7 +457,7 @@ export const VerifyPage = () => {
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}> <Container className={styles.container}>
{!meta && ( {!meta && (
<> <>
<Typography component="label" variant="h6"> <Typography component="label" variant="h6">
@ -622,7 +623,7 @@ export const VerifyPage = () => {
</List> </List>
</> </>
)} )}
</Box> </Container>
</> </>
) )
} }

View File

@ -1,4 +1,4 @@
@import '../../colors.scss'; @import '../../styles/colors.scss';
.container { .container {
color: $text-color; color: $text-color;

View File

@ -1,8 +1,11 @@
import { Modal } from '../layouts/modal'
import { CreatePage } from '../pages/create' import { CreatePage } from '../pages/create'
import { HomePage } from '../pages/home' import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing/LandingPage' import { LandingPage } from '../pages/landing'
import { Login } from '../pages/login' import { Login } from '../pages/login'
import { Nostr } from '../pages/nostr'
import { ProfilePage } from '../pages/profile' import { ProfilePage } from '../pages/profile'
import { Register } from '../pages/register'
import { SettingsPage } from '../pages/settings/Settings' import { SettingsPage } from '../pages/settings/Settings'
import { CacheSettingsPage } from '../pages/settings/cache' import { CacheSettingsPage } from '../pages/settings/cache'
import { ProfileSettingsPage } from '../pages/settings/profile' import { ProfileSettingsPage } from '../pages/settings/profile'
@ -10,6 +13,7 @@ import { RelaysPage } from '../pages/settings/relays'
import { SignPage } from '../pages/sign' import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify' import { VerifyPage } from '../pages/verify'
import { hexToNpub } from '../utils' import { hexToNpub } from '../utils'
import { Route, RouteProps } from 'react-router-dom'
export const appPrivateRoutes = { export const appPrivateRoutes = {
homePage: '/', homePage: '/',
@ -25,8 +29,11 @@ export const appPublicRoutes = {
profile: '/profile/:npub', profile: '/profile/:npub',
landingPage: '/', landingPage: '/',
login: '/login', login: '/login',
register: '/login/register',
nostr: '/login/nostr',
verify: '/verify', verify: '/verify',
source: 'https://git.sigit.io/sig/it' source: 'https://git.sigit.io/sig/it',
docs: 'https://docs.sigit.io'
} }
export const getProfileRoute = (hexKey: string) => export const getProfileRoute = (hexKey: string) =>
@ -35,17 +42,73 @@ export const getProfileRoute = (hexKey: string) =>
export const getProfileSettingsRoute = (hexKey: string) => export const getProfileSettingsRoute = (hexKey: string) =>
appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey)) appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey))
export const publicRoutes = [ /**
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
*/
type CustomRouteProps<T> = T &
Omit<RouteProps, 'children'> & {
children?: Array<CustomRouteProps<T>>
}
/**
* This function maps over nested routes with optional condition for rendering
* @param {CustomRouteProps<T>[]} routes - routes list
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
*/
export function recursiveRouteRenderer<T>(
routes?: CustomRouteProps<T>[],
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
true
) {
if (!routes) return null
// Callback allows us to pass arbitrary conditions for each route's rendering
// Skipping the callback will by default evaluate to true (show route)
return routes.map((route, index) =>
renderConditionCallbackFn(route) ? (
<Route
key={`${route.path}${index}`}
path={route.path}
element={route.element}
>
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
</Route>
) : null
)
}
type PublicRouteProps = CustomRouteProps<{
hiddenWhenLoggedIn?: boolean
}>
export const publicRoutes: PublicRouteProps[] = [
{ {
path: appPublicRoutes.landingPage, path: appPublicRoutes.landingPage,
hiddenWhenLoggedIn: true, hiddenWhenLoggedIn: true,
element: <LandingPage /> element: <LandingPage />,
}, children: [
{
element: <Modal />,
children: [
{ {
path: appPublicRoutes.login, path: appPublicRoutes.login,
hiddenWhenLoggedIn: true, hiddenWhenLoggedIn: true,
element: <Login /> element: <Login />
}, },
{
path: appPublicRoutes.register,
hiddenWhenLoggedIn: true,
element: <Register />
},
{
path: appPublicRoutes.nostr,
hiddenWhenLoggedIn: true,
element: <Nostr />
}
]
}
]
},
{ {
path: appPublicRoutes.profile, path: appPublicRoutes.profile,
element: <ProfilePage /> element: <ProfilePage />

17
src/styles/colors.scss Normal file
View File

@ -0,0 +1,17 @@
$primary-main: #4c82a3;
$primary-light: #5e8eab;
$primary-dark: #447592;
$secondary-main: #7d54a3;
$box-shadow-color: rgba(0, 0, 0, 0.1);
$border-color: #27323c;
$body-background-color: #ededed;
$overlay-background-color: #ffffff;
$text-color: #434343;
$input-text-color: #717171;
$relay-status-connected: #178b13;
$relay-status-notconnected: #d82222;

3
src/styles/form.scss Normal file
View File

@ -0,0 +1,3 @@
.label {
font-size: 16px;
}

4
src/styles/sizes.scss Normal file
View File

@ -0,0 +1,4 @@
$header-height: 65px;
$body-vertical-padding: 25px;
$default-container-padding-inline: 10px;

View File

@ -0,0 +1,7 @@
$font-familiy: 'Roboto', system-ui, sans-serif;
$letter-spacing: 1px;
$body-font-size: 18px;
$body-line-height: 1.5;
$body-font-weight: 500;

View File

@ -8,7 +8,9 @@ export const theme = extendTheme({
light: { light: {
palette: { palette: {
primary: { primary: {
main: '#291334' main: '#4c82a3',
light: '#5e8eab',
dark: '447592'
}, },
info: { info: {
main: '#3abff8' main: '#3abff8'
@ -21,14 +23,41 @@ export const theme = extendTheme({
} }
}, },
components: { components: {
MuiModal: {
styleOverrides: {
root: {
insetBlock: '25px',
insetInline: '10px'
}
}
},
MuiButton: { MuiButton: {
styleOverrides: { styleOverrides: {
root: { root: {
borderRadius: '2rem' fontSize: '14px',
fontWeight: 500,
padding: '8px 15px',
transition: 'ease 0.2s',
boxShadow: 'unset',
lineHeight: 'inherit',
borderRadius: '4px',
':hover': {
background: 'var(--primary-light)',
boxShadow: 'unset'
}
},
text: {
color: 'inherit',
background: 'transparent',
':hover': {
color: 'white'
}
}, },
contained: { contained: {
background: 'var(--primary-main)',
color: 'white',
':hover': { ':hover': {
background: '#150a1a' color: 'white'
} }
}, },
outlined: { outlined: {
@ -37,8 +66,26 @@ export const theme = extendTheme({
borderColor: '#291334', borderColor: '#291334',
background: '#29133433' background: '#29133433'
} }
},
startIcon: {
marginRight: '12px',
marginLeft: 0
} }
} }
},
MuiTypography: {
defaultProps: {
fontFamily: ['Roboto', 'system-ui', 'sans-serif'].join(',')
}
}
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 900,
lg: 1200,
xl: 1420
} }
} }
}) })