feat(design): home page new design and functionality #135

Merged
enes merged 24 commits from issue-121 into staging 2024-08-14 08:44:09 +00:00
15 changed files with 812 additions and 397 deletions
Showing only changes of commit 64c48835a4 - Show all commits

View File

@ -6,6 +6,18 @@ interface ContainerProps {
className?: string className?: string
} }
/**
* Container component with pre-defined width, padding and margins for top level layout.
*
* **Important:** To avoid conflicts with `defaultStyle` (changing the `width`, `max-width`, `padding-inline`, and/or `margin-inline`) make sure to either:
* - When using *className* override, that styles are imported after the actual `Container` component
* ```
* import { Container } from './components/Container'
* import styles from './style.module.scss'
* ```
* - or add *!important* to imported styles
* - or override styles with *CSSProperties* object
*/
export const Container = ({ export const Container = ({
style = {}, style = {},
className = '', className = '',

View File

@ -0,0 +1,203 @@
import { Dispatch, SetStateAction, useEffect } from 'react'
import { Meta, ProfileMetadata } from '../../types'
import { SignedStatus, useSigitMeta } from '../../hooks/useSigitMeta'
import { Event, kinds } from 'nostr-tools'
import { Link } from 'react-router-dom'
import { MetadataController } from '../../controllers'
import { hexToNpub, npubToHex, shorten } from '../../utils'
import { appPublicRoutes, appPrivateRoutes } from '../../routes'
import { Button, Divider, Tooltip } from '@mui/material'
import { DisplaySigner } from '../DisplaySigner'
import {
faArchive,
faCalendar,
faCopy,
faEye,
faFilePdf
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { UserAvatar } from '../UserAvatar'
import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss'
import { TooltipChild } from '../TooltipChild'
type SigitProps = {
meta: Meta
profiles: { [key: string]: ProfileMetadata }
setProfiles: Dispatch<SetStateAction<{ [key: string]: ProfileMetadata }>>
}
// function
// const ExtensionIconMapper = new Map<string, ReactNode>([
// [
// 'pdf',
// <>
// <FontAwesomeIcon icon={faFilePdf} /> PDF
// </>
// ],
// [
// 'csv',
// <>
// <FontAwesomeIcon icon={faFileCsv} /> CSV
// </>
// ]
// ])
export const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => {
const {
title,
createdAt,
submittedBy,
signers,
signedStatus
// fileExtensions
} = useSigitMeta(meta)
useEffect(() => {
const hexKeys: string[] = []
if (submittedBy) {
hexKeys.push(npubToHex(submittedBy)!)
}
hexKeys.push(...signers.map((signer) => npubToHex(signer)!))
const metadataController = new MetadataController()
hexKeys.forEach((key) => {
if (!(key in profiles)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setProfiles((prev) => ({
...prev,
[key]: metadataContent
}))
}
metadataController.on(key, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(key)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(`error occurred in finding metadata for: ${key}`, err)
})
}
})
}, [submittedBy, signers, profiles, setProfiles])
return (
<Link
className={styles.itemWrapper}
to={
signedStatus === SignedStatus.Complete
? appPublicRoutes.verify
: appPrivateRoutes.sign
}
state={{ meta }}
>
<p className={`line-clamp-2 ${styles.title}`}>{title}</p>
<div className={styles.users}>
{submittedBy &&
(function () {
const profile = profiles[submittedBy]
return (
<Tooltip
title={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(submittedBy))
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<UserAvatar pubkey={submittedBy} image={profile?.picture} />
</TooltipChild>
</Tooltip>
)
})()}
{submittedBy && signers.length ? (
<Divider orientation="vertical" flexItem />
) : null}
<UserAvatarGroup className={styles.signers} max={7}>
{signers.map((signer) => {
const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return (
<Tooltip
key={signer}
title={
profile?.display_name || profile?.name || shorten(pubkey)
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<DisplaySigner
meta={meta}
profile={profile}
pubkey={pubkey}
/>
</TooltipChild>
</Tooltip>
)
})}
</UserAvatarGroup>
</div>
<div className={`${styles.details} ${styles.date} ${styles.iconLabel}`}>
<FontAwesomeIcon icon={faCalendar} />
{createdAt}
</div>
<div className={`${styles.details} ${styles.status}`}>
<span className={styles.iconLabel}>
<FontAwesomeIcon icon={faEye} /> {signedStatus}
</span>
<span className={styles.iconLabel}>
{/* {fileExtensions.map(ext =>
return
)} */}
<FontAwesomeIcon icon={faFilePdf} /> {'PDF'}
</span>
</div>
<div className={styles.itemActions}>
<Tooltip title="Duplicate" arrow placement="top" disableInteractive>
<Button
sx={{
color: 'var(--primary-main)',
minWidth: '34px',
padding: '10px'
}}
variant={'text'}
>
<FontAwesomeIcon icon={faCopy} />
</Button>
</Tooltip>
<Tooltip title="Archive" arrow placement="top" disableInteractive>
<Button
sx={{
color: 'var(--primary-main)',
minWidth: '34px',
padding: '10px'
}}
variant={'text'}
>
<FontAwesomeIcon icon={faArchive} />
</Button>
</Tooltip>
</div>
</Link>
)
}

View File

@ -0,0 +1,129 @@
@import '../../styles/colors.scss';
.itemWrapper {
position: relative;
overflow: hidden;
background-color: $overlay-background-color;
border-radius: 4px;
display: flex;
padding: 15px;
gap: 15px;
flex-direction: column;
cursor: pointer;
&:only-child {
max-width: 600px;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
transition: opacity ease 0.2s;
opacity: 0;
width: 4px;
background-color: $primary-main;
pointer-events: none;
}
&:hover,
&:focus-within {
&::before {
opacity: 1;
}
.itemActions {
transform: translateX(0);
}
}
}
.itemActions {
display: flex;
gap: 10px;
padding: 10px;
> * {
flex-grow: 1;
}
@media (hover: hover) {
transition: ease 0.2s;
transform: translateX(100%);
position: absolute;
right: 0;
top: 0;
bottom: 0;
flex-direction: column;
background: $overlay-background-color;
border-left: solid 1px rgba(0, 0, 0, 0.1);
&:hover,
&:focus-within {
transform: translateX(0);
}
}
@media (hover: none) {
border-top: solid 1px rgba(0, 0, 0, 0.1);
padding-top: 10px;
margin-inline: -15px;
margin-bottom: -15px;
}
}
.title {
font-size: 20px;
}
.users {
margin-top: auto;
display: flex;
grid-gap: 10px;
}
.signers {
padding: 0 0 0 10px;
> * {
transition: margin ease 0.2s;
margin: 0 0 0 -10px;
position: relative;
z-index: 1;
&:first-child {
margin-left: -10px !important;
}
}
> *:hover,
> *:focus-within {
margin: 0 15px 0 5px;
z-index: 2;
}
}
.details {
color: rgba(0, 0, 0, 0.3);
font-size: 14px;
}
.iconLabel {
display: flex;
grid-gap: 10px;
align-items: center;
}
.status {
display: flex;
grid-gap: 25px;
}
a.itemWrapper:hover {
text-decoration: none;
}

View File

@ -0,0 +1,76 @@
import { Badge } from '@mui/material'
import { Event, verifyEvent } from 'nostr-tools'
import { useState, useEffect } from 'react'
import { Meta, ProfileMetadata } from '../../types'
import { hexToNpub, parseJson } from '../../utils'
import styles from './style.module.scss'
import { UserAvatar } from '../UserAvatar'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck, faExclamation } from '@fortawesome/free-solid-svg-icons'
enum SignStatus {
Signed = 'Signed',
Pending = 'Pending',
Invalid = 'Invalid Sign'
}
type DisplaySignerProps = {
meta: Meta
profile: ProfileMetadata
pubkey: string
}
export const DisplaySigner = ({
meta,
profile,
pubkey
}: DisplaySignerProps) => {
const [signStatus, setSignedStatus] = useState<SignStatus>()
useEffect(() => {
const updateSignStatus = async () => {
const npub = hexToNpub(pubkey)
if (npub in meta.docSignatures) {
parseJson<Event>(meta.docSignatures[npub])
.then((event) => {
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
setSignedStatus(SignStatus.Signed)
} else {
setSignedStatus(SignStatus.Invalid)
}
})
.catch((err) => {
console.log(`err in parsing the docSignatures for ${npub}:>> `, err)
setSignedStatus(SignStatus.Invalid)
})
} else {
setSignedStatus(SignStatus.Pending)
}
}
updateSignStatus()
}, [meta, pubkey])
return (
<Badge
className={styles.signer}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
badgeContent={
signStatus !== SignStatus.Pending && (
<div className={styles.statusBadge}>
{signStatus === SignStatus.Signed && (
<FontAwesomeIcon icon={faCheck} />
)}
{signStatus === SignStatus.Invalid && (
<FontAwesomeIcon icon={faExclamation} />
)}
</div>
)
}
>
<UserAvatar pubkey={pubkey} image={profile?.picture} />
</Badge>
)
}

View File

@ -0,0 +1,23 @@
@import '../../styles/colors.scss';
.statusBadge {
width: 22px;
height: 22px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
background-color: $primary-main;
}
.signer {
background-color: white;
border-radius: 50%;
z-index: 1;
}

View File

@ -0,0 +1,11 @@
import { forwardRef, PropsWithChildren } from 'react'
export const TooltipChild = forwardRef<HTMLSpanElement, PropsWithChildren>(
({ children, ...rest }, ref) => {
return (
<span ref={ref} {...rest}>
{children}
</span>
)
}
)

View File

@ -1,12 +1,11 @@
import { useNavigate } from 'react-router-dom'
import { getProfileRoute } from '../../routes' import { getProfileRoute } from '../../routes'
import styles from './styles.module.scss' import styles from './styles.module.scss'
import React from 'react'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { Link } from 'react-router-dom'
interface UserAvatarProps { interface UserAvatarProps {
name: string name?: string
pubkey: string pubkey: string
image?: string image?: string
} }
@ -16,27 +15,18 @@ interface UserAvatarProps {
* Clicking will navigate to the user's profile. * Clicking will navigate to the user's profile.
*/ */
export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => { export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => {
const navigate = useNavigate()
const handleClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
navigate(getProfileRoute(pubkey))
}
return ( return (
<div className={styles.container}> <Link to={getProfileRoute(pubkey)} className={styles.container}>
<AvatarIconButton <AvatarIconButton
src={image} src={image}
hexKey={pubkey} hexKey={pubkey}
aria-label={`account of user ${name}`} aria-label={`account of user ${name || pubkey}`}
color="inherit" color="inherit"
onClick={handleClick} sx={{
padding: 0
}}
/> />
{name ? ( {name ? <label className={styles.username}>{name}</label> : null}
<label onClick={handleClick} className={styles.username}> </Link>
{name}
</label>
) : null}
</div>
) )
} }

View File

@ -2,7 +2,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-grow: 1; // flex-grow: 1;
} }
.username { .username {

View File

@ -0,0 +1,38 @@
import { Children, PropsWithChildren } from 'react'
import styles from './style.module.scss'
interface UserAvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
max: number
renderSurplus?: ((surplus: number) => React.ReactNode) | undefined
}
const defaultSurplus = (surplus: number) => {
return <span className={styles.icon}>+{surplus}</span>
}
/**
* Renders children with the `max` limit (including surplus if available).
* The children are wrapped with a `div` (accepts standard `HTMLDivElement` attributes)
* @param max The maximum number of children rendered in a div.
* @param renderSurplus Custom render for surplus children (accepts surplus number).
*/
export const UserAvatarGroup = ({
max,
renderSurplus = defaultSurplus,
children,
...rest
}: PropsWithChildren<UserAvatarGroupProps>) => {
const total = Children.count(children)
const surplus = total - max + 1
const childrenArray = Children.toArray(children)
return (
<div {...rest}>
{surplus > 1
? childrenArray.slice(0, surplus * -1).map((c) => c)
: children}
{surplus > 1 && renderSurplus(surplus)}
</div>
)
}

View File

@ -0,0 +1,19 @@
@import '../../styles/colors.scss';
.icon {
width: 40px;
height: 40px;
border-radius: 50%;
border-width: 2px;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
background: white;
color: rgba(0, 0, 0, 0.5);
font-weight: bold;
font-size: 14px;
border: solid 2px $primary-main;
}

View File

@ -2,6 +2,6 @@
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
border-width: 3px; border-width: 2px;
overflow: hidden; overflow: hidden;
} }

View File

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { CreateSignatureEventContent, Meta } from '../types'
import { parseJson, formatTimestamp } from '../utils'
import { Event } from 'nostr-tools'
export enum SignedStatus {
Partial = 'In-Progress',
Complete = 'Completed'
}
export const useSigitMeta = (meta: Meta) => {
const [title, setTitle] = useState<string>()
const [createdAt, setCreatedAt] = useState('')
const [submittedBy, setSubmittedBy] = useState<string>()
const [signers, setSigners] = useState<`npub1${string}`[]>([])
const [signedStatus, setSignedStatus] = useState<SignedStatus>(
SignedStatus.Partial
)
const [fileExtensions, setFileExtensions] = useState<string[]>([])

Should SigitInfo returned by hook also contain other information available in Meta, specifically in the createSignature string, i.e. CreateSIgnatureEventContent? Similarly, should the data from docSignatures, represented by SignedEventContent be returned as well?

I beleive you would need to merge the current staging branch into yours to get a hold of some of these data types

Should `SigitInfo` returned by hook also contain other information available in `Meta`, specifically in the `createSignature` string, i.e. `CreateSIgnatureEventContent`? Similarly, should the data from `docSignatures`, represented by `SignedEventContent` be returned as well? I beleive you would need to merge the current staging branch into yours to get a hold of some of these data types
Outdated
Review

I've returned most of the things I found, we can add if something is missing later down the line 👍

I've returned most of the things I found, we can add if something is missing later down the line 👍
useEffect(() => {
const extractInfo = async () => {
const createSignatureEvent = await parseJson<Event>(
meta.createSignature
).catch((err) => {
console.log('err in parsing the createSignature event:>> ', err)
toast.error(
err.message || 'error occurred in parsing the create signature event'
)
return null
})
if (!createSignatureEvent) return
// created_at in nostr events are stored in seconds
// convert it to ms before formatting
setCreatedAt(formatTimestamp(createSignatureEvent.created_at * 1000))
const createSignatureContent =
await parseJson<CreateSignatureEventContent>(
createSignatureEvent.content
).catch((err) => {
console.log(
`err in parsing the createSignature event's content :>> `,
err
)
return null
})
if (!createSignatureContent) return
const files = Object.keys(createSignatureContent.fileHashes)
const extensions = files.reduce((result: string[], file: string) => {
const extension = file.split('.').pop()
if (extension) {
result.push(extension)
}
return result
}, [])
setTitle(createSignatureContent.title)
setSubmittedBy(createSignatureEvent.pubkey)
setSigners(createSignatureContent.signers)
setFileExtensions(extensions)
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = createSignatureContent.signers.every(
(signer) => signedBy.includes(signer)
)
if (isCompletelySigned) {
Review

created_at * 1000 should be made into a utility function and used in other similar places (there is at least one that I could find).

Similarly, there is a reverse action, created_at / 1000, which should also be a utility.

`created_at * 1000` should be made into a utility function and used in other similar places (there is at least one that I could find). Similarly, there is a reverse action, `created_at / 1000`, which should also be a utility.
setSignedStatus(SignedStatus.Complete)
}
}
extractInfo()
}, [meta])
return {
title,
createdAt,
submittedBy,
signers,
signedStatus,
fileExtensions
}
}

View File

@ -101,6 +101,15 @@ button:disabled {
color: inherit !important; color: inherit !important;
} }
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.profile-image { .profile-image {
width: 40px; width: 40px;
height: 40px; height: 40px;

View File

@ -1,24 +1,42 @@
import { CalendarMonth, Description, Upload } from '@mui/icons-material' import { Button, Divider, TextField, Tooltip } from '@mui/material'
import { Box, Button, Tooltip, Typography } from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { Event, kinds, verifyEvent } from 'nostr-tools' import { 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 { UserAvatar } from '../../components/UserAvatar'
import { MetadataController } from '../../controllers'
import { useAppSelector } from '../../hooks' import { useAppSelector } from '../../hooks'
import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import { CreateSignatureEventContent, Meta, ProfileMetadata } from '../../types' import { Meta, ProfileMetadata } from '../../types'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { import {
formatTimestamp, faAdd,
hexToNpub, faFilter,
npubToHex, faFilterCircleXmark,
parseJson, faSearch
shorten } from '@fortawesome/free-solid-svg-icons'
} from '../../utils' import { Select } from '../../components/Select'
import styles from './style.module.scss' import { DisplaySigit } from '../../components/DisplaySigit'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import styles from './style.module.scss'
// Unsupported Filter options are commented
const FILTERS = [
'Show all',
// 'Drafts',
'In-progress',
'Completed'
// 'Archived'
] as const
type Filter = (typeof FILTERS)[number]
const SORT_BY = [
{
label: 'Newest',
value: 'desc'
},
{ label: 'Oldest', value: 'asc' }
] as const
type Sort = (typeof SORT_BY)[number]['value']
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -81,54 +99,112 @@ export const HomePage = () => {
} }
} }
const [filter, setFilter] = useState<Filter>('Show all')
const [isFilterVisible, setIsFilterVisible] = useState(true)
const [sort, setSort] = useState<Sort>('asc')
console.log(filter, sort)
return ( return (
<Container className={styles.container}> <Container className={styles.container}>
<Box className={styles.header}> <div className={styles.header}>
<Typography variant="h3" className={styles.title}> {isFilterVisible && (
Sigits <>
</Typography> <Select
{/* This is for desktop view */} setValue={setFilter}
<Box options={FILTERS.map((f) => {
className={styles.actionButtons} return {
sx={{ label: f,
display: { value: f
xs: 'none', }
md: 'flex' })}
} />
}} <Select
> setValue={setSort}
<input options={SORT_BY.map((s) => {
type="file" return { ...s }
ref={fileInputRef} })}
style={{ display: 'none' }} />
onChange={handleFileChange} </>
/> )}
<Button <div className={styles.actionButtons}>
variant="outlined" <div className={styles.search}>
startIcon={<Upload />} <TextField
onClick={handleUploadClick} placeholder="Search"
> size="small"
Upload sx={{
</Button> fontSize: '16px',
</Box> height: '34px',
{/* This is for mobile view */} borderTopLeftRadius: 'var(----mui-shape-borderRadius)',
<Box borderBottomLeftRadius: 'var(----mui-shape-borderRadius)',
className={styles.actionButtons} '& .MuiInputBase-root': {
sx={{ borderTopRightRadius: 0,
display: { borderBottomRightRadius: 0
xs: 'flex', },
md: 'none' '& .MuiInputBase-input': {
} padding: '5.5px 14px'
}} },
> '& .MuiOutlinedInput-notchedOutline': {
<Tooltip title="Upload" arrow> display: 'none'
<Button variant="outlined" onClick={handleUploadClick}> }
<Upload /> }}
/>
<Button
sx={{
minWidth: '44px',
padding: '10px 12px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
variant={'contained'}
>
<FontAwesomeIcon icon={faSearch} />
</Button>
</div>
<Divider orientation="vertical" variant="middle" flexItem />
<Tooltip title="Toggle Filter" arrow>
<Button
sx={{
minWidth: '44px',
padding: '10px 12px'
}}
variant={'contained'}
onClick={() => setIsFilterVisible((v) => !v)}
>
{isFilterVisible ? (
<FontAwesomeIcon icon={faFilterCircleXmark} />
) : (
<FontAwesomeIcon icon={faFilter} />
)}
</Button> </Button>
</Tooltip> </Tooltip>
</Box> <Tooltip title="Upload" arrow>
</Box> <Button
<Box className={styles.submissions}> sx={{
minWidth: '44px',
padding: '10px 12px'
}}
component={'label'}
htmlFor="fileUpload"
variant={'contained'}
onClick={handleUploadClick}
>
<FontAwesomeIcon icon={faAdd} />
</Button>
</Tooltip>
<input
id="fileUpload"
type="file"
hidden
ref={fileInputRef}
onChange={handleFileChange}
/>
</div>
</div>
<div className={styles.dropzone}>
<div>Click or drag files to upload!</div>
</div>
<div className={styles.submissions}>
{sigits.map((sigit, index) => ( {sigits.map((sigit, index) => (
<DisplaySigit <DisplaySigit
key={`sigit-${index}`} key={`sigit-${index}`}
@ -137,248 +213,7 @@ export const HomePage = () => {
setProfiles={setProfiles} setProfiles={setProfiles}
/> />
))} ))}
</Box> </div>
</Container> </Container>
) )
} }
type SigitProps = {
meta: Meta
profiles: { [key: string]: ProfileMetadata }
setProfiles: Dispatch<SetStateAction<{ [key: string]: ProfileMetadata }>>
}
enum SignedStatus {
Partial = 'Partially Signed',
Complete = 'Completely Signed'
}
const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => {
const navigate = useNavigate()
const [title, setTitle] = useState<string>()
const [createdAt, setCreatedAt] = useState('')
const [submittedBy, setSubmittedBy] = useState<string>()
const [signers, setSigners] = useState<`npub1${string}`[]>([])
const [signedStatus, setSignedStatus] = useState<SignedStatus>(
SignedStatus.Partial
)
useEffect(() => {
const extractInfo = async () => {
const createSignatureEvent = await parseJson<Event>(
meta.createSignature
).catch((err) => {
console.log('err in parsing the createSignature event:>> ', err)
toast.error(
err.message || 'error occurred in parsing the create signature event'
)
return null
})
if (!createSignatureEvent) return
// created_at in nostr events are stored in seconds
// convert it to ms before formatting
setCreatedAt(formatTimestamp(createSignatureEvent.created_at * 1000))
const createSignatureContent =
await parseJson<CreateSignatureEventContent>(
createSignatureEvent.content
).catch((err) => {
console.log(
`err in parsing the createSignature event's content :>> `,
err
)
return null
})
if (!createSignatureContent) return
setTitle(createSignatureContent.title)
setSubmittedBy(createSignatureEvent.pubkey)
setSigners(createSignatureContent.signers)
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = createSignatureContent.signers.every(
(signer) => signedBy.includes(signer)
)
if (isCompletelySigned) {
setSignedStatus(SignedStatus.Complete)
}
}
extractInfo()
}, [meta])
useEffect(() => {
const hexKeys: string[] = []
if (submittedBy) {
hexKeys.push(npubToHex(submittedBy)!)
}
hexKeys.push(...signers.map((signer) => npubToHex(signer)!))
const metadataController = new MetadataController()
hexKeys.forEach((key) => {
if (!(key in profiles)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setProfiles((prev) => ({
...prev,
[key]: metadataContent
}))
}
metadataController.on(key, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(key)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(`error occurred in finding metadata for: ${key}`, err)
})
}
})
}, [submittedBy, signers])
const handleNavigation = () => {
if (signedStatus === SignedStatus.Complete) {
navigate(appPublicRoutes.verify, { state: { meta } })
} else {
navigate(appPrivateRoutes.sign, { state: { meta } })
}
}
return (
<Box
className={styles.item}
sx={{
flexDirection: {
xs: 'column',
md: 'row'
}
}}
onClick={handleNavigation}
>
<Box
className={styles.titleBox}
sx={{
borderBottomLeftRadius: {
xs: 'initial',
md: 'inherit'
},
borderTopRightRadius: {
xs: 'inherit',
md: 'initial'
}
}}
>
<Typography variant="body1" className={styles.title}>
<Description />
{title}
</Typography>
{submittedBy &&
(function () {
const profile = profiles[submittedBy]
return (
<UserAvatar
pubkey={submittedBy}
name={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(submittedBy))
}
image={profile?.picture}
/>
)
})()}
<Typography variant="body2" className={styles.date}>
<CalendarMonth />
{createdAt}
</Typography>
</Box>
<Box className={styles.signers}>
{signers.map((signer) => {
const pubkey = npubToHex(signer)!
const profile = profiles[pubkey]
return (
<DisplaySigner
key={signer}
meta={meta}
profile={profile}
pubkey={pubkey}
/>
)
})}
</Box>
</Box>
)
}
enum SignStatus {
Signed = 'Signed',
Pending = 'Pending',
Invalid = 'Invalid Sign'
}
type DisplaySignerProps = {
meta: Meta
profile: ProfileMetadata
pubkey: string
}
const DisplaySigner = ({ meta, profile, pubkey }: DisplaySignerProps) => {
const [signStatus, setSignedStatus] = useState<SignStatus>()
useEffect(() => {
const updateSignStatus = async () => {
const npub = hexToNpub(pubkey)
if (npub in meta.docSignatures) {
parseJson<Event>(meta.docSignatures[npub])
.then((event) => {
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
setSignedStatus(SignStatus.Signed)
} else {
setSignedStatus(SignStatus.Invalid)
}
})
.catch((err) => {
console.log(`err in parsing the docSignatures for ${npub}:>> `, err)
setSignedStatus(SignStatus.Invalid)
})
} else {
setSignedStatus(SignStatus.Pending)
}
}
updateSignStatus()
}, [meta, pubkey])
return (
<Box className={styles.signerItem}>
<Typography variant="button" className={styles.status}>
{signStatus}
</Typography>
<UserAvatar
pubkey={pubkey}
name={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(pubkey), 5)
}
image={profile?.picture}
/>
</Box>
)
}

View File

@ -1,94 +1,78 @@
@import '../../styles/colors.scss';
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; gap: 25px;
container-type: inline-size;
} }
.header { .header {
display: flex; display: flex;
gap: 10px;
align-items: center;
.title { @container (width < 610px) {
color: var(--mui-palette-primary-light); flex-wrap: wrap;
flex: 1; }
}
.actionButtons {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-left: auto;
padding: 1.5px 0;
}
.search {
display: flex;
align-items: center;
justify-content: end;
height: 34px;
overflow: hidden;
border-radius: 4px;
outline: solid 1px #dddddd;
background: white;
&:focus-within {
outline-color: $primary-main;
}
}
.dropzone {
background-color: $overlay-background-color;
height: 250px;
transition: padding ease 0.2s;
padding: 15px;
&:hover {
padding: 10px;
> div {
background: rgba(0, 0, 0, 0.15);
}
} }
.actionButtons { > div {
transition: background-color ease 0.2s;
display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 10px; background: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.25);
height: 100%;
border-radius: 2px;
border: dashed 3px rgba(0, 0, 0, 0.1);
font-size: 16px;
} }
} }
.submissions { .submissions {
display: flex; display: grid;
flex-direction: column; gap: 25px;
gap: 10px; grid-template-columns: repeat(auto-fit, minmax(365px, 1fr));
.item {
display: flex;
gap: 10px;
background-color: #efeae6;
border-radius: 1rem;
cursor: pointer;
.titleBox {
display: flex;
flex: 4;
flex-direction: column;
align-items: center;
overflow-wrap: anywhere;
gap: 10px;
padding: 10px;
background-color: #cdc8c499;
border-top-left-radius: inherit;
.title {
display: flex;
justify-content: center;
align-items: center;
color: var(--mui-palette-primary-light);
font-size: 1.5rem;
svg {
font-size: 1.5rem;
}
}
.date {
display: flex;
justify-content: center;
align-items: center;
color: var(--mui-palette-primary-light);
font-size: 1rem;
svg {
font-size: 1rem;
}
}
}
.signers {
display: flex;
flex-direction: column;
flex: 6;
justify-content: center;
gap: 10px;
padding: 10px;
color: var(--mui-palette-primary-light);
.signerItem {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
.status {
border-radius: 2rem;
width: 100px;
text-align: center;
background-color: var(--mui-palette-info-light);
}
}
}
}
} }