Compare commits

..

46 Commits

Author SHA1 Message Date
b
5445120511 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>
2024-07-31 13:06:57 +00:00
6f4737d75c fix: icons, use FontAwesome package 2024-07-31 14:37:10 +02:00
20bb05ddc6 feat: add UserAvatar, UserIconButton
Closes #68 - Only use getRoboHashPicture function (set 1) for Avatars
2024-07-31 13:53:36 +02:00
5d415a2359 refactor: recursiveRouteRenderer params update and comments 2024-07-31 13:48:57 +02:00
1070c9a8f9 refactor: review colors, usage
Requested Change
2024-07-31 13:39:16 +02:00
fcd00d9e9c fix: font url typo 2024-07-31 13:36:41 +02:00
dc11f5695d refactor: update main layout loading desc 2024-07-30 16:25:36 +02:00
d7f9807e20 fix: color scheme 2024-07-30 16:16:52 +02:00
5a4da1834b fix: loading spinner, improve desc readability, use favicon instead of circle 2024-07-30 13:29:03 +02:00
202c98c94c fix: background overlap 2024-07-30 13:23:06 +02:00
e9a1b9894c feat: add background images 2024-07-30 13:14:18 +02:00
f1a26e4dc4 chore: styling cleanup 2024-07-30 12:23:38 +02:00
aa5aa60c6a fix: fonts 2024-07-30 12:00:57 +02:00
3a93622966 fix: svg attributes 2024-07-30 10:38:06 +02:00
c5b1a9b380 fix: title text align 2024-07-30 10:35:09 +02:00
53b7b05ac5 fix: top level container wrapper for other pages 2024-07-30 10:28:57 +02:00
baa1a7b040 fix: works offline card icon 2024-07-30 10:25:57 +02:00
0d49c49459 fix: card icons 2024-07-30 09:34:52 +02:00
99856fd8f2 fix: gap, spacing 2024-07-29 18:55:19 +02:00
d0a6297cce fix: remove placeholder used for text 2024-07-29 18:25:32 +02:00
9dae3a48be fix: IconButton conflict, username layout 2024-07-29 18:06:23 +02:00
e3ca3ab908 fix: popup forms designs 2024-07-29 17:12:47 +02:00
c7dfb2864a fix: list item key 2024-07-29 15:28:19 +02:00
55158fc313 fix: update popup design 2024-07-29 15:27:20 +02:00
28184ab038 fix: update buttons and button icon design 2024-07-29 14:39:30 +02:00
af689a00f7 fix: update footer design 2024-07-29 14:12:01 +02:00
5d59ffce28 fix: update design buttons 2024-07-29 14:03:16 +02:00
9189ff33bc fix: reduce mui usage, implement design updates 2024-07-29 11:22:04 +02:00
e54eced800 feat: add custom Container component for layouts 2024-07-29 11:20:39 +02:00
2cd851a7c1 fix: add default typography styles 2024-07-29 11:19:41 +02:00
6a1f04ec6b fix: add Roboto font 2024-07-29 11:16:27 +02:00
b5de0ce04a chore: remove unused css and fonts 2024-07-29 11:15:59 +02:00
804bb6c9ac fix: composition for links and buttons 2024-07-26 16:03:45 +02:00
3c22429941 fix: move nostr login to nostr route 2024-07-26 15:45:13 +02:00
868ae6f23e feat: add modal with login, register, nostr routes 2024-07-26 15:38:44 +02:00
87c6807ba0 fix: app bar z-index 2024-07-26 15:37:40 +02:00
06deecba76 chore: landing page comments cleanup 2024-07-26 13:18:05 +02:00
0b35f11abf feat: add children support to routes arrays 2024-07-26 13:15:14 +02:00
017d1ab88b fix: update logo and favicon 2024-07-26 10:43:19 +02:00
8fa074899c chore: move scss styles to separate folder 2024-07-26 10:42:15 +02:00
45f0764fa8 fix: footer padding and responsiveness 2024-07-25 18:03:03 +02:00
5b9093a6e7 chore: remove unused css 2024-07-25 16:46:52 +02:00
87e4536713 feat: landing page - responsive cards 2024-07-25 16:29:18 +02:00
3149ba9757 feat: landing page - larger cta button 2024-07-25 16:11:20 +02:00
a82f138057 chore: remove unused css file 2024-07-25 15:06:55 +02:00
0a61ae5f64 feat: landing page implementation and styling 2024-07-25 15:05:47 +02:00
73 changed files with 2050 additions and 1080 deletions

62
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

34
public/logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

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

65
src/App.scss Normal file
View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 364 B

View File

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

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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&nbsp;
<a href="https://nostrdev.com/" target="_blank">
Nostr Dev
</a>{' '}
2024.
</div>
</footer>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,55 +1,52 @@
:root { :root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
word-break: break-word;
} }
a { * {
font-weight: 500; box-sizing: border-box;
color: #646cff;
text-decoration: inherit;
} }
a:hover {
color: #535bf2; @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 { body {
margin: 0; margin: 0;
/* display: flex;
place-items: center; */
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
background-color: #f4f4fb;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
.qrzap { .qrzap {
position: fixed; position: fixed;
top: 80px; top: 80px;
@ -57,21 +54,10 @@ button {
z-index: 100; z-index: 100;
} }
@media (prefers-color-scheme: light) { #root {
:root { min-height: 100vh;
color: #213547; display: flex;
background-color: #ffffff; flex-direction: column;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
.main {
padding: 60px 0;
} }
.hide-mobile { .hide-mobile {
@ -90,6 +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 * when this class is assigned to a component, user will not be able to select and copy the content from that component
*/ */
.no-select { .no-select {
-webkit-user-select: none;
user-select: none; user-select: none;
} }

View File

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

111
src/layouts/modal/index.tsx Normal file
View 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>
)
}

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,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>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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