Compare commits

...

6 Commits

Author SHA1 Message Date
64c48835a4 refactor: sigit cards design and split components 2024-08-06 12:42:21 +02:00
f51afe3b67 fix: some linter warnings and an error 2024-08-05 09:49:56 +02:00
8d168314de feat: custom select component 2024-08-05 09:39:46 +02:00
64b6f8309f fix: modal override removed
Mui modal overrides affected tooltip positioning
2024-08-05 09:24:14 +02:00
b959aa7dc2 style: applied css prettier formatting 2024-08-05 09:21:22 +02:00
f21d158a8e fix: input font-family inherit 2024-08-05 09:16:36 +02:00
23 changed files with 943 additions and 417 deletions

View File

@ -63,3 +63,7 @@ a {
text-decoration-color: inherit; text-decoration-color: inherit;
} }
} }
input {
font-family: inherit;
}

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,105 @@
import {
FormControl,
MenuItem,
Select as SelectMui,
SelectChangeEvent,
styled,
SelectProps as SelectMuiProps,
MenuItemProps
} from '@mui/material'
const SelectCustomized = styled(SelectMui)<SelectMuiProps>(() => ({
backgroundColor: 'var(--primary-main)',
fontSize: '14px',
fontWeight: '500',
color: 'white',
':hover': {
backgroundColor: 'var(--primary-light)'
},
'& .MuiSelect-select:focus': {
backgroundColor: 'var(--primary-light)'
},
'& .MuiSvgIcon-root': {
color: 'white'
},
'& .MuiOutlinedInput-notchedOutline': {
border: 'none'
}
}))
const MenuItemCustomized = styled(MenuItem)<MenuItemProps>(() => ({
marginInline: '5px',
borderRadius: '4px',
'&:hover': {
background: 'var(--primary-light)',
color: 'white'
},
'&.Mui-selected': {
background: 'var(--primary-dark)',
color: 'white'
},
'&.Mui-selected:hover': {
background: 'var(--primary-light)'
},
'&.Mui-selected.Mui-focusVisible': {
background: 'var(--primary-light)',
color: 'white'
},
'&.Mui-focusVisible': {
background: 'var(--primary-light)',
color: 'white'
},
'& + *': {
marginTop: '5px'
}
}))
interface SelectItemProps<T extends string> {
value: T
label: string
}
interface SelectProps<T extends string> {
setValue: React.Dispatch<React.SetStateAction<T>>
options: SelectItemProps<T>[]
}
export function Select<T extends string>({
setValue,
options
}: SelectProps<T>) {
const handleChange = (event: SelectChangeEvent<unknown>) => {
setValue(event.target.value as T)
}
return (
<FormControl>
<SelectCustomized
size="small"
variant="outlined"
defaultValue={options[0].value as string}
onChange={handleChange}
MenuProps={{
MenuListProps: {
sx: {
paddingBlock: '5px'
}
},
PaperProps: {
sx: {
boxShadow: '0 0 4px 0 rgb(0, 0, 0, 0.1)'
}
}
}}
>
{options.map((o) => {
return (
<MenuItemCustomized key={o.label} value={o.value as string}>
{o.label}
</MenuItemCustomized>
)
})}
</SelectCustomized>
</FormControl>
)
}

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[]>([])
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) {
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;
@ -111,7 +120,9 @@ button:disabled {
/* Fonts */ /* Fonts */
@font-face { @font-face {
font-family: 'Roboto'; font-family: 'Roboto';
src: local('Roboto Medium'), local('Roboto-Medium'), src:
local('Roboto Medium'),
local('Roboto-Medium'),
url('assets/fonts/roboto-medium.woff2') format('woff2'), url('assets/fonts/roboto-medium.woff2') format('woff2'),
url('assets/fonts/roboto-medium.woff') format('woff'); url('assets/fonts/roboto-medium.woff') format('woff');
font-weight: 500; font-weight: 500;
@ -121,7 +132,9 @@ button:disabled {
@font-face { @font-face {
font-family: 'Roboto'; font-family: 'Roboto';
src: local('Roboto Light'), local('Roboto-Light'), src:
local('Roboto Light'),
local('Roboto-Light'),
url('assets/fonts/roboto-light.woff2') format('woff2'), url('assets/fonts/roboto-light.woff2') format('woff2'),
url('assets/fonts/roboto-light.woff') format('woff'); url('assets/fonts/roboto-light.woff') format('woff');
font-weight: 300; font-weight: 300;
@ -131,7 +144,9 @@ button:disabled {
@font-face { @font-face {
font-family: 'Roboto'; font-family: 'Roboto';
src: local('Roboto Bold'), local('Roboto-Bold'), src:
local('Roboto Bold'),
local('Roboto-Bold'),
url('assets/fonts/roboto-bold.woff2') format('woff2'), url('assets/fonts/roboto-bold.woff2') format('woff2'),
url('assets/fonts/roboto-bold.woff') format('woff'); url('assets/fonts/roboto-bold.woff') format('woff');
font-weight: bold; font-weight: bold;
@ -141,7 +156,9 @@ button:disabled {
@font-face { @font-face {
font-family: 'Roboto'; font-family: 'Roboto';
src: local('Roboto'), local('Roboto-Regular'), src:
local('Roboto'),
local('Roboto-Regular'),
url('assets/fonts/roboto-regular.woff2') format('woff2'), url('assets/fonts/roboto-regular.woff2') format('woff2'),
url('assets/fonts/roboto-regular.woff') format('woff'); url('assets/fonts/roboto-regular.woff') format('woff');
font-weight: normal; font-weight: normal;

View File

@ -145,7 +145,7 @@ export const MainLayout = () => {
}) })
.finally(() => setIsLoading(false)) .finally(() => setIsLoading(false))
} }
}, [authState]) }, [authState, dispatch])
if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} /> if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} />

View File

@ -5,7 +5,7 @@ $default-modal-padding: 15px 25px;
.modal { .modal {
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: calc(50% - 10px);
transform: translate(-50%, 0); transform: translate(-50%, 0);
background-color: $overlay-background-color; background-color: $overlay-background-color;
@ -16,6 +16,8 @@ $default-modal-padding: 15px 25px;
flex-direction: column; flex-direction: column;
border-radius: 4px; border-radius: 4px;
margin: 25px 10px;
} }
.header { .header {

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 {
label: f,
value: f
}
})}
/>
<Select
setValue={setSort}
options={SORT_BY.map((s) => {
return { ...s }
})}
/>
</>
)}
<div className={styles.actionButtons}>
<div className={styles.search}>
<TextField
placeholder="Search"
size="small"
sx={{ sx={{
display: { fontSize: '16px',
xs: 'none', height: '34px',
md: 'flex' borderTopLeftRadius: 'var(----mui-shape-borderRadius)',
borderBottomLeftRadius: 'var(----mui-shape-borderRadius)',
'& .MuiInputBase-root': {
borderTopRightRadius: 0,
borderBottomRightRadius: 0
},
'& .MuiInputBase-input': {
padding: '5.5px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
} }
}} }}
>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
/> />
<Button <Button
variant="outlined"
startIcon={<Upload />}
onClick={handleUploadClick}
>
Upload
</Button>
</Box>
{/* This is for mobile view */}
<Box
className={styles.actionButtons}
sx={{ sx={{
display: { minWidth: '44px',
xs: 'flex', padding: '10px 12px',
md: 'none' borderTopLeftRadius: 0,
} borderBottomLeftRadius: 0
}} }}
variant={'contained'}
> >
<Tooltip title="Upload" arrow> <FontAwesomeIcon icon={faSearch} />
<Button variant="outlined" onClick={handleUploadClick}> </Button>
<Upload /> </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 { .actionButtons {
display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 10px; 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);
}
}
> div {
transition: background-color ease 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
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);
}
}
}
}
} }

View File

@ -51,7 +51,7 @@ export const Nostr = () => {
/** /**
* Call login function when enter is pressed * Call login function when enter is pressed
*/ */
const handleInputKeyDown = (event: any) => { const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') { if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault() event.preventDefault()
login() login()

View File

@ -12,7 +12,7 @@ import {
useTheme useTheme
} from '@mui/material' } from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools' import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { MetadataController, NostrController } from '../../../controllers' import { MetadataController, NostrController } from '../../../controllers'
@ -321,8 +321,8 @@ export const ProfileSettingsPage = () => {
}} }}
> >
<img <img
onError={(event: any) => { onError={(event: React.SyntheticEvent<HTMLImageElement>) => {
event.target.src = getRoboHashPicture(npub!) event.currentTarget.src = getRoboHashPicture(npub!)
}} }}
className={styles.img} className={styles.img}
src={getProfileImage(profileMetadata)} src={getProfileImage(profileMetadata)}

View File

@ -23,14 +23,6 @@ export const theme = extendTheme({
} }
}, },
components: { components: {
MuiModal: {
styleOverrides: {
root: {
insetBlock: '25px',
insetInline: '10px'
}
}
},
MuiButton: { MuiButton: {
styleOverrides: { styleOverrides: {
root: { root: {

View File

@ -120,7 +120,7 @@ export const queryNip05 = async (
if (!match) throw new Error('Invalid nip05') if (!match) throw new Error('Invalid nip05')
// Destructure the match result, assigning default value '_' to name if not provided // Destructure the match result, assigning default value '_' to name if not provided
const [_, name = '_', domain] = match const [, name = '_', domain] = match
// Construct the URL to query the NIP-05 data // Construct the URL to query the NIP-05 data
const url = `https://${domain}/.well-known/nostr.json?name=${name}` const url = `https://${domain}/.well-known/nostr.json?name=${name}`