Merge pull request 'Landing page - new design implementation' (#122) from issue-21 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m2s
Reviewed-on: #122 Reviewed-by: s <sabir@4gl.io>
62
package-lock.json
generated
@ -10,6 +10,10 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.11.4",
|
||||
"@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/lab": "5.0.0-alpha.166",
|
||||
"@mui/material": "5.15.11",
|
||||
@ -1093,6 +1097,64 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
|
||||
"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": {
|
||||
"version": "0.11.14",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
|
||||
|
@ -16,6 +16,10 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.11.4",
|
||||
"@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/lab": "5.0.0-alpha.166",
|
||||
"@mui/material": "5.15.11",
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/logo.png
Before Width: | Height: | Size: 72 KiB |
34
public/logo.svg
Normal 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 |
42
src/App.css
@ -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
@ -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;
|
||||
}
|
||||
}
|
43
src/App.tsx
@ -7,10 +7,12 @@ import {
|
||||
appPrivateRoutes,
|
||||
appPublicRoutes,
|
||||
privateRoutes,
|
||||
publicRoutes
|
||||
publicRoutes,
|
||||
recursiveRouteRenderer
|
||||
} from './routes'
|
||||
import { State } from './store/rootReducer'
|
||||
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
|
||||
import './App.scss'
|
||||
|
||||
const App = () => {
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
@ -48,39 +50,18 @@ const App = () => {
|
||||
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 (
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
{authState?.loggedIn &&
|
||||
privateRoutes.map((route, index) => (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
||||
{authState?.loggedIn && privateRouteList}
|
||||
{publicRoutesList}
|
||||
<Route path="*" element={<Navigate to={handleRootRedirect()} />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
6
src/assets/images/bg_l.svg
Normal 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 |
6
src/assets/images/bg_r.svg
Normal 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 |
BIN
src/assets/images/placeholder.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/roboto-font/Roboto-Bold.ttf
Normal file
BIN
src/assets/roboto-font/Roboto-Light.ttf
Normal file
BIN
src/assets/roboto-font/Roboto-Medium.ttf
Normal file
BIN
src/assets/roboto-font/Roboto-Regular.ttf
Normal 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;
|
@ -34,6 +34,8 @@ import {
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { setUserRobotImage } from '../../store/userRobotImage/action'
|
||||
import { Container } from '../Container'
|
||||
import { ButtonIcon } from '../ButtonIcon'
|
||||
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
@ -117,21 +119,29 @@ export const AppBar = () => {
|
||||
const isAuthenticated = authState?.loggedIn === true
|
||||
|
||||
return (
|
||||
<AppBarMui position="fixed" className={styles.AppBar}>
|
||||
<Toolbar className={styles.toolbar}>
|
||||
<AppBarMui
|
||||
position="fixed"
|
||||
className={styles.AppBar}
|
||||
sx={{
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<Toolbar className={styles.toolbar} disableGutters={true}>
|
||||
<Box className={styles.logoWrapper}>
|
||||
<img src="/logo.png" alt="Logo" onClick={() => navigate('/')} />
|
||||
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
|
||||
</Box>
|
||||
|
||||
<Box className={styles.rightSideBox}>
|
||||
{!isAuthenticated && (
|
||||
<Button
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
navigate(appPublicRoutes.login)
|
||||
}}
|
||||
variant="contained"
|
||||
>
|
||||
Sign in
|
||||
LOGIN
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -160,7 +170,10 @@ export const AppBar = () => {
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
display: { md: 'none' }
|
||||
display: { md: 'none' },
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-color)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{username}</Typography>
|
||||
@ -223,6 +236,7 @@ export const AppBar = () => {
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBarMui>
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
@import '../../colors.scss';
|
||||
@import '../../styles/colors.scss';
|
||||
@import '../../styles/sizes.scss';
|
||||
|
||||
.AppBar {
|
||||
background-color: $background-color !important;
|
||||
z-index: 1400 !important;
|
||||
height: 60px;
|
||||
background-color: $overlay-background-color !important;
|
||||
height: $header-height;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
|
||||
border-bottom: solid 1px rgba(0, 0, 0, 0.075);
|
||||
|
||||
.toolbar {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
|
14
src/components/ButtonIcon/index.tsx
Normal 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>
|
||||
}
|
5
src/components/ButtonIcon/style.module.scss
Normal file
@ -0,0 +1,5 @@
|
||||
.icon {
|
||||
border-radius: 100px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
24
src/components/Container/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
8
src/components/Container/style.module.scss
Normal file
@ -0,0 +1,8 @@
|
||||
@import '../../styles/sizes.scss';
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
padding-inline: $default-container-padding-inline;
|
||||
margin-inline: auto;
|
||||
}
|
10
src/components/FontAwesomeIconStack/index.tsx
Normal 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>
|
||||
}
|
7
src/components/FontAwesomeIconStack/styles.module.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.iconStackContainer {
|
||||
position: relative;
|
||||
|
||||
> * {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
138
src/components/Footer/Footer.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
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'
|
||||
|
||||
export const Footer = () => (
|
||||
<footer className={`${styles.borderTop} ${styles.footer}`}>
|
||||
<Container
|
||||
style={{
|
||||
paddingBlock: '50px'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display={'grid'}
|
||||
sx={{
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
md: '0.5fr 1.75fr 0.75fr'
|
||||
},
|
||||
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: {
|
||||
xs: 'center',
|
||||
sm: 'start'
|
||||
}
|
||||
}}
|
||||
component={Link}
|
||||
to={'/'}
|
||||
variant={'text'}
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'start'
|
||||
}
|
||||
}}
|
||||
component={Link}
|
||||
to={'/#'}
|
||||
variant={'text'}
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
justifyContent: {
|
||||
xs: 'center',
|
||||
sm: 'start'
|
||||
}
|
||||
}}
|
||||
component={Link}
|
||||
to={'/#'}
|
||||
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="https://image.nostr.build/fb557f1b6d58c7bbcdf4d1edb1b48090c76ff1d1384b9d1aae13d652e7a3cfe4.gif"
|
||||
width="25"
|
||||
alt="nostr logo"
|
||||
height="25"
|
||||
/>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
<div className={`${styles.borderTop} ${styles.credits}`}>
|
||||
Built by
|
||||
<a href="https://nostrdev.com/" target="_blank">
|
||||
Nostr Dev
|
||||
</a>{' '}
|
||||
2024.
|
||||
</div>
|
||||
</footer>
|
||||
)
|
49
src/components/Footer/style.module.scss
Normal 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;
|
||||
}
|
||||
}
|
28
src/components/Landing/CardComponent/CardComponent.tsx
Normal 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>
|
||||
)
|
||||
}
|
64
src/components/Landing/CardComponent/style.module.scss
Normal 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;
|
||||
}
|
@ -11,7 +11,7 @@ export const LoadingSpinner = (props: Props) => {
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
{desc && <span>{desc}</span>}
|
||||
{desc && <span className={styles.loadingSpinnerDesc}>{desc}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -1,3 +1,5 @@
|
||||
@import '../../styles/colors.scss';
|
||||
|
||||
.loadingSpinnerOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@ -18,15 +20,21 @@
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
background: url('/favicon.png') no-repeat center / cover;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingSpinnerDesc {
|
||||
color: white;
|
||||
margin-top: 13px;
|
||||
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
|
42
src/components/UserAvatar/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
13
src/components/UserAvatar/styles.module.scss
Normal 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);
|
||||
}
|
32
src/components/UserAvatarIconButton/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
7
src/components/UserAvatarIconButton/style.module.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border-width: 3px;
|
||||
overflow: hidden;
|
||||
}
|
7
src/components/username.module.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { Box, IconButton, Typography, useTheme } from '@mui/material'
|
||||
import { Typography } from '@mui/material'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getProfileRoute } from '../routes'
|
||||
import { State } from '../store/rootReducer'
|
||||
import { hexToNpub } from '../utils'
|
||||
|
||||
import styles from './username.module.scss'
|
||||
import { AvatarIconButton } from './UserAvatarIconButton'
|
||||
|
||||
type Props = {
|
||||
username: string
|
||||
@ -11,92 +11,36 @@ type Props = {
|
||||
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 hexKey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
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)}`
|
||||
}}
|
||||
/>
|
||||
<div className={styles.container}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#3e3e3e',
|
||||
padding: '0 8px',
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-color)',
|
||||
display: { xs: 'none', md: 'flex' }
|
||||
}}
|
||||
>
|
||||
{username}
|
||||
</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
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
@ -1,55 +1,52 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-weight: 300;
|
||||
src: local('Roboto-Light'), url(./assets/roboto-font/Roboto-Light.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-weight: 400;
|
||||
src: local('Roboto-Regular'), url(./assets/Roboto-font/Roboto-Regular.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-weight: 500;
|
||||
src: local('Roboto-Medium'), url(./assets/Roboto-font/Roboto-Medium.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-weight: 700;
|
||||
src: local('Roboto-Bold'), url(./assets/Roboto-font/Roboto-Bold.ttf);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* display: flex;
|
||||
place-items: center; */
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: #f4f4fb;
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
@ -57,21 +54,10 @@ button {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 60px 0;
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hide-mobile {
|
||||
@ -90,6 +76,7 @@ button {
|
||||
* when this class is assigned to a component, user will not be able to select and copy the content from that component
|
||||
*/
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Box } from '@mui/material'
|
||||
import Container from '@mui/material/Container'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
@ -28,6 +26,8 @@ import {
|
||||
} from '../utils'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import { SubCloser } from 'nostr-tools/abstract-pool'
|
||||
import styles from './style.module.scss'
|
||||
import { Footer } from '../components/Footer/Footer'
|
||||
|
||||
export const MainLayout = () => {
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
@ -136,7 +136,7 @@ export const MainLayout = () => {
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc(`Fetching user's app data`)
|
||||
setLoadingSpinnerDesc(`Loading SIGit History`)
|
||||
getUsersAppData()
|
||||
.then((appData) => {
|
||||
if (appData) {
|
||||
@ -152,19 +152,10 @@ export const MainLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<AppBar />
|
||||
|
||||
<Box className="main">
|
||||
<Container
|
||||
sx={{
|
||||
position: 'relative',
|
||||
maxWidth: {
|
||||
xs: '550px'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
111
src/layouts/modal/index.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
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'
|
||||
|
||||
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="https://image.nostr.build/fb557f1b6d58c7bbcdf4d1edb1b48090c76ff1d1384b9d1aae13d652e7a3cfe4.gif"
|
||||
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>
|
||||
<div className={styles.tabContent}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>Welcome to SIGit!</footer>
|
||||
</div>
|
||||
</ModalMui>
|
||||
)
|
||||
}
|
76
src/layouts/modal/style.module.scss
Normal file
@ -0,0 +1,76 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
@ -1,22 +1,8 @@
|
||||
@import '../colors.scss';
|
||||
@import '../styles/colors.scss';
|
||||
@import '../styles/sizes.scss';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Avenir';
|
||||
font-weight: normal;
|
||||
src: local('Avenir'),
|
||||
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');
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
padding: $header-height + $body-vertical-padding 0 $body-vertical-padding 0;
|
||||
background-color: $body-background-color;
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ import { useSelector } from 'react-redux'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { UserComponent } from '../../components/username'
|
||||
import { UserAvatar } from '../../components/UserAvatar'
|
||||
import { MetadataController, NostrController } from '../../controllers'
|
||||
import { appPrivateRoutes } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
@ -799,7 +799,7 @@ const DisplayUser = ({
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell className={styles.tableCell}>
|
||||
<UserComponent
|
||||
<UserAvatar
|
||||
pubkey={user.pubkey}
|
||||
name={
|
||||
userMeta?.display_name ||
|
||||
@ -954,7 +954,7 @@ const SignerRow = ({
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}
|
||||
>
|
||||
<DragHandle />
|
||||
<UserComponent
|
||||
<UserAvatar
|
||||
pubkey={user.pubkey}
|
||||
name={
|
||||
userMeta?.display_name ||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '../../colors.scss';
|
||||
@import '../../styles/colors.scss';
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
|
@ -5,7 +5,7 @@ import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { UserComponent } from '../../components/username'
|
||||
import { UserAvatar } from '../../components/UserAvatar'
|
||||
import { MetadataController } from '../../controllers'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
|
||||
@ -18,6 +18,7 @@ import {
|
||||
shorten
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { Container } from '../../components/Container'
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -81,8 +82,7 @@ export const HomePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className={styles.container}>
|
||||
<Container className={styles.container}>
|
||||
<Box className={styles.header}>
|
||||
<Typography variant="h3" className={styles.title}>
|
||||
Sigits
|
||||
@ -138,8 +138,7 @@ export const HomePage = () => {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@ -291,7 +290,7 @@ const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => {
|
||||
(function () {
|
||||
const profile = profiles[submittedBy]
|
||||
return (
|
||||
<UserComponent
|
||||
<UserAvatar
|
||||
pubkey={submittedBy}
|
||||
name={
|
||||
profile?.display_name ||
|
||||
@ -371,7 +370,7 @@ const DisplaySigner = ({ meta, profile, pubkey }: DisplaySignerProps) => {
|
||||
<Typography variant="button" className={styles.status}>
|
||||
{signStatus}
|
||||
</Typography>
|
||||
<UserComponent
|
||||
<UserAvatar
|
||||
pubkey={pubkey}
|
||||
name={
|
||||
profile?.display_name ||
|
||||
|
@ -2,11 +2,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background: var(--mui-palette-background-paper);
|
||||
}
|
||||
|
||||
.header {
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
.title {
|
||||
@ -19,9 +17,9 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submissions {
|
||||
.submissions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@ -93,5 +91,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
@ -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.login)
|
||||
}
|
||||
|
||||
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 & 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 & 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 & 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>
|
||||
)
|
||||
}
|
@ -1,29 +1,70 @@
|
||||
@import '../../colors.scss';
|
||||
@import '../../styles/colors.scss';
|
||||
@import '../../styles/sizes.scss';
|
||||
|
||||
.landingPage {
|
||||
.background {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
gap: 45px;
|
||||
|
||||
position: relative;
|
||||
padding-block: 50px;
|
||||
padding-inline: 50px + $default-container-padding-inline;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: $text-color;
|
||||
min-height: 80vh;
|
||||
justify-content: center;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.loginBottomBar {
|
||||
.logo {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.loginBtn {
|
||||
// margin-top: 20px;
|
||||
min-width: 200px;
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -1,380 +1,26 @@
|
||||
import { Box, Button, TextField, Typography } 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'
|
||||
import { Button, TextField } from '@mui/material'
|
||||
|
||||
export const Login = () => {
|
||||
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} />}
|
||||
<div className={styles.loginPage}>
|
||||
<Typography variant="h4">Welcome to Sigit</Typography>
|
||||
<TextField
|
||||
onKeyDown={handleInputKeyDown}
|
||||
label="nip05 login / nip46 bunker string"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
sx={{ width: '100%', mt: 2 }}
|
||||
label="Email"
|
||||
placeholder="Your email address"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{isNostrExtensionAvailable && (
|
||||
<Button onClick={loginWithExtension} variant="text">
|
||||
Login with extension
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button disabled={!inputValue} onClick={login} variant="contained">
|
||||
<Button variant="contained" fullWidth>
|
||||
Login
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
@import '../../colors.scss';
|
||||
|
||||
.loginPage {
|
||||
color: $text-color;
|
||||
margin-top: 20px;
|
||||
}
|
400
src/pages/nostr/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
1
src/pages/nostr/styles.module.scss
Normal file
@ -0,0 +1 @@
|
||||
@import '../../styles/form.scss';
|
@ -14,6 +14,7 @@ import { State } from '../../store/rootReducer'
|
||||
import { NostrJoiningBlock, ProfileMetadata } from '../../types'
|
||||
import { getRoboHashPicture, hexToNpub, shorten } from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { Container } from '../../components/Container'
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -155,7 +156,7 @@ export const ProfilePage = () => {
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
{pubkey && (
|
||||
<Box className={styles.container}>
|
||||
<Container className={styles.container}>
|
||||
<Box
|
||||
className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`}
|
||||
>
|
||||
@ -278,7 +279,7 @@ export const ProfilePage = () => {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
35
src/pages/register/index.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { Button, TextField } from '@mui/material'
|
||||
|
||||
export const Register = () => {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="Email"
|
||||
placeholder="Your email address"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm password"
|
||||
placeholder="Re-type your password"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<Button variant="contained" fullWidth>
|
||||
Register
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
@ -12,6 +12,7 @@ import { useSelector } from 'react-redux'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { Container } from '../../components/Container'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
@ -42,11 +43,11 @@ export const SettingsPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper',
|
||||
marginTop: 2
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
@ -92,5 +93,6 @@ export const SettingsPage = () => {
|
||||
{listItem('Local Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
5
src/pages/settings/cache/index.tsx
vendored
@ -13,6 +13,7 @@ import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { localCache } from '../../../services'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
import { Container } from '../../../components/Container'
|
||||
|
||||
export const CacheSettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
@ -49,7 +50,7 @@ export const CacheSettingsPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<List
|
||||
sx={{
|
||||
@ -91,6 +92,6 @@ export const CacheSettingsPage = () => {
|
||||
{listItem('Clear Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
import { LoginMethods } from '../../../store/auth/types'
|
||||
import { SmartToy } from '@mui/icons-material'
|
||||
import { getRoboHashPicture } from '../../../utils'
|
||||
import { Container } from '../../../components/Container'
|
||||
|
||||
export const ProfileSettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
@ -271,7 +272,7 @@ export const ProfileSettingsPage = () => {
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<div className={styles.container}>
|
||||
<Container className={styles.container}>
|
||||
<List
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
@ -380,7 +381,7 @@ export const ProfileSettingsPage = () => {
|
||||
SAVE
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import {
|
||||
shorten
|
||||
} from '../../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { Container } from '../../../components/Container'
|
||||
|
||||
export const RelaysPage = () => {
|
||||
const nostrController = NostrController.getInstance()
|
||||
@ -314,7 +315,7 @@ export const RelaysPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={styles.container}>
|
||||
<Container className={styles.container}>
|
||||
<Box className={styles.relayAddContainer}>
|
||||
<TextField
|
||||
label="Add new relay"
|
||||
@ -512,6 +513,6 @@ export const RelaysPage = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
@import '../../../colors.scss';
|
||||
@import '../../../styles/colors.scss';
|
||||
|
||||
.container {
|
||||
margin-top: 25px;
|
||||
color: $text-color;
|
||||
|
||||
.relayURItextfield {
|
||||
@ -93,11 +92,11 @@
|
||||
}
|
||||
|
||||
.connectionStatusConnected {
|
||||
background-color: $review-feedback-correct;
|
||||
background-color: $relay-status-connected;
|
||||
}
|
||||
|
||||
.connectionStatusNotConnected {
|
||||
background-color: $review-feedback-incorrect;
|
||||
background-color: $relay-status-notconnected;
|
||||
}
|
||||
|
||||
.connectionStatusUnknown {
|
||||
|
@ -33,6 +33,7 @@ import {
|
||||
} from '../../utils'
|
||||
import { DisplayMeta } from './internal/displayMeta'
|
||||
import styles from './style.module.scss'
|
||||
import { Container } from '../../components/Container'
|
||||
enum SignedStatus {
|
||||
Fully_Signed,
|
||||
User_Is_Next_Signer,
|
||||
@ -850,7 +851,7 @@ export const SignPage = () => {
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<Box className={styles.container}>
|
||||
<Container className={styles.container}>
|
||||
{displayInput && (
|
||||
<>
|
||||
<Typography component="label" variant="h6">
|
||||
@ -916,7 +917,7 @@ export const SignPage = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ import saveAs from 'file-saver'
|
||||
import { kinds, Event } from 'nostr-tools'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { UserComponent } from '../../../components/username'
|
||||
import { UserAvatar } from '../../../components/UserAvatar'
|
||||
import { MetadataController } from '../../../controllers'
|
||||
import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils'
|
||||
import styles from '../style.module.scss'
|
||||
@ -172,7 +172,7 @@ export const DisplayMeta = ({
|
||||
{(function () {
|
||||
const profile = metadata[submittedBy]
|
||||
return (
|
||||
<UserComponent
|
||||
<UserAvatar
|
||||
pubkey={submittedBy}
|
||||
name={
|
||||
profile?.display_name ||
|
||||
@ -372,7 +372,7 @@ const DisplayUser = ({
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className={styles.tableCell}>
|
||||
<UserComponent
|
||||
<UserAvatar
|
||||
pubkey={user.pubkey}
|
||||
name={
|
||||
userMeta?.display_name ||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '../../colors.scss';
|
||||
@import '../../styles/colors.scss';
|
||||
|
||||
.container {
|
||||
color: $text-color;
|
||||
|
@ -14,7 +14,7 @@ import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { UserComponent } from '../../components/username'
|
||||
import { UserAvatar } from '../../components/UserAvatar'
|
||||
import { MetadataController } from '../../controllers'
|
||||
import {
|
||||
CreateSignatureEventContent,
|
||||
@ -36,6 +36,7 @@ import styles from './style.module.scss'
|
||||
import { Cancel, CheckCircle } from '@mui/icons-material'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { Container } from '../../components/Container'
|
||||
|
||||
export const VerifyPage = () => {
|
||||
const theme = useTheme()
|
||||
@ -402,7 +403,7 @@ export const VerifyPage = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserComponent
|
||||
<UserAvatar
|
||||
pubkey={pubkey}
|
||||
name={
|
||||
profile?.display_name || profile?.name || shorten(hexToNpub(pubkey))
|
||||
@ -456,7 +457,7 @@ export const VerifyPage = () => {
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<Box className={styles.container}>
|
||||
<Container className={styles.container}>
|
||||
{!meta && (
|
||||
<>
|
||||
<Typography component="label" variant="h6">
|
||||
@ -622,7 +623,7 @@ export const VerifyPage = () => {
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import '../../colors.scss';
|
||||
@import '../../styles/colors.scss';
|
||||
|
||||
.container {
|
||||
color: $text-color;
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { Modal } from '../layouts/modal'
|
||||
import { CreatePage } from '../pages/create'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { LandingPage } from '../pages/landing/LandingPage'
|
||||
import { LandingPage } from '../pages/landing'
|
||||
import { Login } from '../pages/login'
|
||||
import { Nostr } from '../pages/nostr'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { Register } from '../pages/register'
|
||||
import { SettingsPage } from '../pages/settings/Settings'
|
||||
import { CacheSettingsPage } from '../pages/settings/cache'
|
||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||
@ -10,6 +13,7 @@ import { RelaysPage } from '../pages/settings/relays'
|
||||
import { SignPage } from '../pages/sign'
|
||||
import { VerifyPage } from '../pages/verify'
|
||||
import { hexToNpub } from '../utils'
|
||||
import { Route, RouteProps } from 'react-router-dom'
|
||||
|
||||
export const appPrivateRoutes = {
|
||||
homePage: '/',
|
||||
@ -25,6 +29,8 @@ export const appPublicRoutes = {
|
||||
profile: '/profile/:npub',
|
||||
landingPage: '/',
|
||||
login: '/login',
|
||||
register: '/login/register',
|
||||
nostr: '/login/nostr',
|
||||
verify: '/verify',
|
||||
source: 'https://git.sigit.io/sig/it'
|
||||
}
|
||||
@ -35,17 +41,73 @@ export const getProfileRoute = (hexKey: string) =>
|
||||
export const getProfileSettingsRoute = (hexKey: string) =>
|
||||
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,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <LandingPage />
|
||||
},
|
||||
element: <LandingPage />,
|
||||
children: [
|
||||
{
|
||||
element: <Modal />,
|
||||
children: [
|
||||
{
|
||||
path: appPublicRoutes.login,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Login />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.register,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Register />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.nostr,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Nostr />
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.profile,
|
||||
element: <ProfilePage />
|
||||
|
17
src/styles/colors.scss
Normal 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
@ -0,0 +1,3 @@
|
||||
.label {
|
||||
font-size: 16px;
|
||||
}
|
4
src/styles/sizes.scss
Normal file
@ -0,0 +1,4 @@
|
||||
$header-height: 65px;
|
||||
$body-vertical-padding: 25px;
|
||||
|
||||
$default-container-padding-inline: 10px;
|
7
src/styles/typography.scss
Normal 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;
|
@ -8,7 +8,9 @@ export const theme = extendTheme({
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#291334'
|
||||
main: '#4c82a3',
|
||||
light: '#5e8eab',
|
||||
dark: '447592'
|
||||
},
|
||||
info: {
|
||||
main: '#3abff8'
|
||||
@ -21,15 +23,39 @@ export const theme = extendTheme({
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MuiModal: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
insetBlock: '25px',
|
||||
insetInline: '10px'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
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: {
|
||||
':hover': {
|
||||
background: '#150a1a'
|
||||
}
|
||||
background: 'var(--primary-main)',
|
||||
color: 'white'
|
||||
},
|
||||
outlined: {
|
||||
':hover': {
|
||||
@ -37,8 +63,26 @@ export const theme = extendTheme({
|
||||
borderColor: '#291334',
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|