Merge branch 'staging' into issue-171
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
This commit is contained in:
commit
e33996c1f9
10
package-lock.json
generated
10
package-lock.json
generated
@ -21,6 +21,7 @@
|
||||
"@mui/material": "5.15.11",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@nostr-dev-kit/ndk": "2.5.0",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@reduxjs/toolkit": "2.2.1",
|
||||
"axios": "^1.7.4",
|
||||
"crypto-hash": "3.0.0",
|
||||
@ -1749,6 +1750,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/fontkit": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
|
||||
"integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
|
@ -31,6 +31,7 @@
|
||||
"@mui/material": "5.15.11",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@nostr-dev-kit/ndk": "2.5.0",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@reduxjs/toolkit": "2.2.1",
|
||||
"axios": "^1.7.4",
|
||||
"crypto-hash": "3.0.0",
|
||||
|
20
src/App.scss
20
src/App.scss
@ -100,10 +100,10 @@ li {
|
||||
// - first-child Header height, default body padding, and center content border (10px) and padding (10px)
|
||||
// - others We don't include border and padding and scroll to the top of the image
|
||||
&:first-child {
|
||||
scroll-margin-top: $header-height + $body-vertical-padding + 20px;
|
||||
scroll-margin-top: $body-vertical-padding + 20px;
|
||||
}
|
||||
&:not(:first-child) {
|
||||
scroll-margin-top: $header-height + $body-vertical-padding;
|
||||
scroll-margin-top: $body-vertical-padding;
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,14 +135,20 @@ li {
|
||||
// Consistent styling for every file mark
|
||||
// Reverts some of the design defaults for font
|
||||
.file-mark {
|
||||
font-family: Arial;
|
||||
font-size: 16px;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
color: black;
|
||||
letter-spacing: normal;
|
||||
border: 1px solid transparent;
|
||||
line-height: 1;
|
||||
|
||||
scroll-margin-top: $header-height + $body-vertical-padding;
|
||||
font-size: 16px;
|
||||
color: black;
|
||||
outline: 1px solid transparent;
|
||||
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
|
||||
scroll-margin-top: $body-vertical-padding;
|
||||
}
|
||||
|
||||
[data-dev='true'] {
|
||||
|
BIN
src/assets/fonts/roboto-regular.ttf
Normal file
BIN
src/assets/fonts/roboto-regular.ttf
Normal file
Binary file not shown.
@ -28,14 +28,17 @@ import {
|
||||
} from '../../routes'
|
||||
import {
|
||||
clearAuthToken,
|
||||
getProfileUsername,
|
||||
hexToNpub,
|
||||
saveNsecBunkerDelegatedKey,
|
||||
shorten
|
||||
saveNsecBunkerDelegatedKey
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { setUserRobotImage } from '../../store/userRobotImage/action'
|
||||
import { Container } from '../Container'
|
||||
import { ButtonIcon } from '../ButtonIcon'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faClose } from '@fortawesome/free-solid-svg-icons'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
@ -55,9 +58,8 @@ export const AppBar = () => {
|
||||
useEffect(() => {
|
||||
if (metadataState) {
|
||||
if (metadataState.content) {
|
||||
const { picture, display_name, name } = JSON.parse(
|
||||
metadataState.content
|
||||
)
|
||||
const profileMetadata = JSON.parse(metadataState.content)
|
||||
const { picture } = profileMetadata
|
||||
|
||||
if (picture || userRobotImage) {
|
||||
setUserAvatar(picture || userRobotImage)
|
||||
@ -67,7 +69,7 @@ export const AppBar = () => {
|
||||
? hexToNpub(authState.usersPubkey)
|
||||
: ''
|
||||
|
||||
setUsername(shorten(display_name || name || npub, 7))
|
||||
setUsername(getProfileUsername(npub, profileMetadata))
|
||||
} else {
|
||||
setUserAvatar(userRobotImage || '')
|
||||
setUsername('')
|
||||
@ -118,125 +120,152 @@ export const AppBar = () => {
|
||||
}
|
||||
const isAuthenticated = authState?.loggedIn === true
|
||||
|
||||
const matches = useMediaQuery('(max-width:767px)')
|
||||
const [isBannerVisible, setIsBannerVisible] = useState(true)
|
||||
const handleBannerHide = () => {
|
||||
setIsBannerVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppBarMui
|
||||
position="fixed"
|
||||
className={styles.AppBar}
|
||||
sx={{
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<Toolbar className={styles.toolbar} disableGutters={true}>
|
||||
<Box className={styles.logoWrapper}>
|
||||
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
|
||||
</Box>
|
||||
|
||||
<Box className={styles.rightSideBox}>
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
{isAuthenticated && isBannerVisible && (
|
||||
<div className={styles.banner}>
|
||||
<Container>
|
||||
<div className={styles.bannerInner}>
|
||||
<p className={styles.bannerText}>
|
||||
SIGit is currently Alpha software (available for internal
|
||||
testing), use at your own risk!
|
||||
</p>
|
||||
<Button
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
navigate(appPublicRoutes.nostr)
|
||||
}}
|
||||
variant="contained"
|
||||
aria-label={`close banner`}
|
||||
variant="text"
|
||||
onClick={handleBannerHide}
|
||||
>
|
||||
LOGIN
|
||||
<FontAwesomeIcon icon={faClose} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)}
|
||||
<AppBarMui
|
||||
position={matches ? 'sticky' : 'static'}
|
||||
className={styles.AppBar}
|
||||
sx={{
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<Toolbar className={styles.toolbar} disableGutters={true}>
|
||||
<Box className={styles.logoWrapper}>
|
||||
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
|
||||
</Box>
|
||||
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Username
|
||||
username={username}
|
||||
avatarContent={userAvatar}
|
||||
handleClick={handleOpenUserMenu}
|
||||
/>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorElUser}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
<Box className={styles.rightSideBox}>
|
||||
{!isAuthenticated && (
|
||||
<Button
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
navigate(appPublicRoutes.nostr)
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
open={!!anchorElUser}
|
||||
onClose={handleCloseUserMenu}
|
||||
variant="contained"
|
||||
>
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
display: { md: 'none' },
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-color)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{username}</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleProfile}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
LOGIN
|
||||
</Button>
|
||||
)}
|
||||
|
||||
navigate(appPrivateRoutes.settings)
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Username
|
||||
username={username}
|
||||
avatarContent={userAvatar}
|
||||
handleClick={handleOpenUserMenu}
|
||||
/>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorElUser}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPublicRoutes.verify)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Verify
|
||||
</MenuItem>
|
||||
<Link
|
||||
to={appPublicRoutes.source}
|
||||
target="_blank"
|
||||
style={{ color: 'inherit', textDecoration: 'inherit' }}
|
||||
open={!!anchorElUser}
|
||||
onClose={handleCloseUserMenu}
|
||||
>
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
display: { md: 'none' },
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-color)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{username}</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleProfile}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Source
|
||||
Profile
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBarMui>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPrivateRoutes.settings)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPublicRoutes.verify)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Verify
|
||||
</MenuItem>
|
||||
<Link
|
||||
to={appPublicRoutes.source}
|
||||
target="_blank"
|
||||
style={{ color: 'inherit', textDecoration: 'inherit' }}
|
||||
>
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Source
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBarMui>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -34,3 +34,42 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
color: #ffffff;
|
||||
background-color: $primary-main;
|
||||
}
|
||||
|
||||
.bannerInner {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-block: 10px;
|
||||
z-index: 1;
|
||||
|
||||
width: 100%;
|
||||
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active,
|
||||
&:focus-within {
|
||||
background: $primary-main;
|
||||
color: inherit;
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bannerText {
|
||||
margin-left: 54px;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -11,15 +11,16 @@ import styles from './style.module.scss'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ProfileMetadata, User, UserRole } from '../../types'
|
||||
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
|
||||
import { truncate } from 'lodash'
|
||||
import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils'
|
||||
import { getSigitFile, SigitFile } from '../../utils/file'
|
||||
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
|
||||
import { SigitFile } from '../../utils/file'
|
||||
import { getToolboxLabelByMarkType } from '../../utils/mark'
|
||||
import { FileDivider } from '../FileDivider'
|
||||
import { ExtensionFileBox } from '../ExtensionFileBox'
|
||||
import { inPx } from '../../utils/pdf'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
|
||||
import { useScale } from '../../hooks/useScale'
|
||||
import { AvatarIconButton } from '../UserAvatarIconButton'
|
||||
import { LoadingSpinner } from '../LoadingSpinner'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
import _ from 'lodash'
|
||||
|
||||
const DEFAULT_START_SIZE = {
|
||||
width: 140,
|
||||
@ -27,52 +28,50 @@ const DEFAULT_START_SIZE = {
|
||||
} as const
|
||||
|
||||
interface Props {
|
||||
selectedFiles: File[]
|
||||
users: User[]
|
||||
metadata: { [key: string]: ProfileMetadata }
|
||||
onDrawFieldsChange: (sigitFiles: SigitFile[]) => void
|
||||
sigitFiles: SigitFile[]
|
||||
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>>
|
||||
selectedTool?: DrawTool
|
||||
}
|
||||
|
||||
export const DrawPDFFields = (props: Props) => {
|
||||
const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props
|
||||
const { to, from } = useScale()
|
||||
const { selectedTool, sigitFiles, setSigitFiles, users } = props
|
||||
|
||||
const [sigitFiles, setSigitFiles] = useState<SigitFile[]>([])
|
||||
const [parsingPdf, setIsParsing] = useState<boolean>(false)
|
||||
const signers = users.filter((u) => u.role === UserRole.signer)
|
||||
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
|
||||
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
|
||||
/**
|
||||
* Return first pubkey that is present in the signers list
|
||||
* @param pubkeys
|
||||
* @returns available pubkey or empty string
|
||||
*/
|
||||
const getAvailableSigner = (...pubkeys: string[]) => {
|
||||
const availableSigner: string | undefined = pubkeys.find((pubkey) =>
|
||||
signers.some((s) => s.pubkey === npubToHex(pubkey))
|
||||
)
|
||||
return availableSigner || ''
|
||||
}
|
||||
|
||||
const { to, from } = useScale()
|
||||
|
||||
const [mouseState, setMouseState] = useState<MouseState>({
|
||||
clicked: false
|
||||
})
|
||||
|
||||
const [activeDrawField, setActiveDrawField] = useState<number>()
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles) {
|
||||
/**
|
||||
* Reads the binary files and converts to internal file type
|
||||
* and sets to a state (adds images if it's a PDF)
|
||||
*/
|
||||
const parsePages = async () => {
|
||||
const files = await settleAllFullfilfedPromises(
|
||||
selectedFiles,
|
||||
getSigitFile
|
||||
)
|
||||
|
||||
setSigitFiles(files)
|
||||
}
|
||||
|
||||
setIsParsing(true)
|
||||
|
||||
parsePages().finally(() => {
|
||||
setIsParsing(false)
|
||||
})
|
||||
}
|
||||
}, [selectedFiles])
|
||||
|
||||
useEffect(() => {
|
||||
if (sigitFiles) onDrawFieldsChange(sigitFiles)
|
||||
}, [onDrawFieldsChange, sigitFiles])
|
||||
const [activeDrawnField, setActiveDrawnField] = useState<{
|
||||
fileIndex: number
|
||||
pageIndex: number
|
||||
drawnFieldIndex: number
|
||||
}>()
|
||||
const isActiveDrawnField = (
|
||||
fileIndex: number,
|
||||
pageIndex: number,
|
||||
drawnFieldIndex: number
|
||||
) =>
|
||||
activeDrawnField?.fileIndex === fileIndex &&
|
||||
activeDrawnField?.pageIndex === pageIndex &&
|
||||
activeDrawnField?.drawnFieldIndex === drawnFieldIndex
|
||||
|
||||
/**
|
||||
* Drawing events
|
||||
@ -99,7 +98,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
* @param event Pointer event
|
||||
* @param page PdfPage where press happened
|
||||
*/
|
||||
const handlePointerDown = (event: React.PointerEvent, page: PdfPage) => {
|
||||
const handlePointerDown = (
|
||||
event: React.PointerEvent,
|
||||
page: PdfPage,
|
||||
fileIndex: number,
|
||||
pageIndex: number
|
||||
) => {
|
||||
// Proceed only if left click
|
||||
if (event.button !== 0) return
|
||||
|
||||
@ -114,12 +118,17 @@ export const DrawPDFFields = (props: Props) => {
|
||||
top: to(page.width, y),
|
||||
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
|
||||
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
|
||||
counterpart: '',
|
||||
counterpart: getAvailableSigner(lastSigner, defaultSignerNpub),
|
||||
type: selectedTool.identifier
|
||||
}
|
||||
|
||||
page.drawnFields.push(newField)
|
||||
|
||||
setActiveDrawnField({
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex: page.drawnFields.length - 1
|
||||
})
|
||||
setMouseState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
@ -188,6 +197,8 @@ export const DrawPDFFields = (props: Props) => {
|
||||
*/
|
||||
const handleDrawnFieldPointerDown = (
|
||||
event: React.PointerEvent,
|
||||
fileIndex: number,
|
||||
pageIndex: number,
|
||||
drawnFieldIndex: number
|
||||
) => {
|
||||
event.stopPropagation()
|
||||
@ -197,7 +208,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
|
||||
const drawingRectangleCoords = getPointerCoordinates(event)
|
||||
|
||||
setActiveDrawField(drawnFieldIndex)
|
||||
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
|
||||
setMouseState({
|
||||
dragging: true,
|
||||
clicked: false,
|
||||
@ -253,13 +264,15 @@ export const DrawPDFFields = (props: Props) => {
|
||||
*/
|
||||
const handleResizePointerDown = (
|
||||
event: React.PointerEvent,
|
||||
fileIndex: number,
|
||||
pageIndex: number,
|
||||
drawnFieldIndex: number
|
||||
) => {
|
||||
// Proceed only if left click
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
|
||||
setActiveDrawField(drawnFieldIndex)
|
||||
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
|
||||
setMouseState({
|
||||
resizing: true
|
||||
})
|
||||
@ -368,7 +381,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
handlePointerMove(event, page)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, page)
|
||||
handlePointerDown(event, page, fileIndex, pageIndex)
|
||||
}}
|
||||
draggable="false"
|
||||
src={page.image}
|
||||
@ -380,7 +393,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
<div
|
||||
key={drawnFieldIndex}
|
||||
onPointerDown={(event) =>
|
||||
handleDrawnFieldPointerDown(event, drawnFieldIndex)
|
||||
handleDrawnFieldPointerDown(
|
||||
event,
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
}
|
||||
onPointerMove={(event) => {
|
||||
handleDrawnFieldPointerMove(event, drawnField, page.width)
|
||||
@ -390,7 +408,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
backgroundColor: drawnField.counterpart
|
||||
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b`
|
||||
: undefined,
|
||||
borderColor: drawnField.counterpart
|
||||
outlineColor: drawnField.counterpart
|
||||
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}`
|
||||
: undefined,
|
||||
left: inPx(from(page.width, drawnField.left)),
|
||||
@ -401,14 +419,33 @@ export const DrawPDFFields = (props: Props) => {
|
||||
touchAction: 'none',
|
||||
opacity:
|
||||
mouseState.dragging &&
|
||||
activeDrawField === drawnFieldIndex
|
||||
isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
? 0.8
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`file-mark ${styles.placeholder}`}
|
||||
style={{
|
||||
fontFamily: FONT_TYPE,
|
||||
fontSize: inPx(from(page.width, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{getToolboxLabelByMarkType(drawnField.type) ||
|
||||
'placeholder'}
|
||||
</div>
|
||||
<span
|
||||
onPointerDown={(event) =>
|
||||
handleResizePointerDown(event, drawnFieldIndex)
|
||||
handleResizePointerDown(
|
||||
event,
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
}
|
||||
onPointerMove={(event) => {
|
||||
handleResizePointerMove(event, drawnField, page.width)
|
||||
@ -417,7 +454,11 @@ export const DrawPDFFields = (props: Props) => {
|
||||
style={{
|
||||
background:
|
||||
mouseState.resizing &&
|
||||
activeDrawField === drawnFieldIndex
|
||||
isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
? 'var(--primary-main)'
|
||||
: undefined
|
||||
}}
|
||||
@ -435,56 +476,59 @@ export const DrawPDFFields = (props: Props) => {
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</span>
|
||||
<div
|
||||
onPointerDown={handleUserSelectPointerDown}
|
||||
className={styles.userSelect}
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="counterparts">Counterpart</InputLabel>
|
||||
<Select
|
||||
value={drawnField.counterpart}
|
||||
onChange={(event) => {
|
||||
drawnField.counterpart = event.target.value
|
||||
refreshPdfFiles()
|
||||
}}
|
||||
labelId="counterparts"
|
||||
label="Counterparts"
|
||||
sx={{
|
||||
background: 'white'
|
||||
}}
|
||||
renderValue={(value) => renderCounterpartValue(value)}
|
||||
>
|
||||
{users
|
||||
.filter((u) => u.role === UserRole.signer)
|
||||
.map((user, index) => {
|
||||
const npub = hexToNpub(user.pubkey)
|
||||
let displayValue = truncate(npub, {
|
||||
length: 16
|
||||
})
|
||||
|
||||
const metadata = props.metadata[user.pubkey]
|
||||
|
||||
if (metadata) {
|
||||
displayValue = truncate(
|
||||
metadata.name ||
|
||||
metadata.display_name ||
|
||||
metadata.username ||
|
||||
npub,
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)
|
||||
}
|
||||
{!isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
) &&
|
||||
!!drawnField.counterpart && (
|
||||
<div className={styles.counterpartAvatar}>
|
||||
<UserAvatar
|
||||
pubkey={npubToHex(drawnField.counterpart)!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
) && (
|
||||
<div
|
||||
onPointerDown={handleUserSelectPointerDown}
|
||||
className={styles.userSelect}
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="counterparts">Counterpart</InputLabel>
|
||||
<Select
|
||||
value={getAvailableSigner(drawnField.counterpart)}
|
||||
onChange={(event) => {
|
||||
drawnField.counterpart = event.target.value
|
||||
setLastSigner(event.target.value)
|
||||
refreshPdfFiles()
|
||||
}}
|
||||
labelId="counterparts"
|
||||
label="Counterparts"
|
||||
sx={{
|
||||
background: 'white'
|
||||
}}
|
||||
renderValue={(value) =>
|
||||
renderCounterpartValue(value)
|
||||
}
|
||||
>
|
||||
{signers.map((signer, index) => {
|
||||
const npub = hexToNpub(signer.pubkey)
|
||||
const metadata = props.metadata[signer.pubkey]
|
||||
const displayValue = getProfileUsername(
|
||||
npub,
|
||||
metadata
|
||||
)
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={index}
|
||||
value={hexToNpub(user.pubkey)}
|
||||
>
|
||||
<MenuItem key={index} value={npub}>
|
||||
<ListItemIcon>
|
||||
<AvatarIconButton
|
||||
src={metadata?.picture}
|
||||
hexKey={user.pubkey}
|
||||
hexKey={signer.pubkey}
|
||||
aria-label={`account of user ${displayValue}`}
|
||||
color="inherit"
|
||||
sx={{
|
||||
@ -500,9 +544,10 @@ export const DrawPDFFields = (props: Props) => {
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -513,28 +558,19 @@ export const DrawPDFFields = (props: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const renderCounterpartValue = (value: string) => {
|
||||
const user = users.find((u) => u.pubkey === npubToHex(value))
|
||||
if (user) {
|
||||
let displayValue = truncate(value, {
|
||||
length: 16
|
||||
})
|
||||
const renderCounterpartValue = (npub: string) => {
|
||||
let displayValue = _.truncate(npub, { length: 16 })
|
||||
|
||||
const metadata = props.metadata[user.pubkey]
|
||||
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
|
||||
if (signer) {
|
||||
const metadata = props.metadata[signer.pubkey]
|
||||
displayValue = getProfileUsername(npub, metadata)
|
||||
|
||||
if (metadata) {
|
||||
displayValue = truncate(
|
||||
metadata.name || metadata.display_name || metadata.username || value,
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.counterpartSelectValue}>
|
||||
<AvatarIconButton
|
||||
src={props.metadata[user.pubkey]?.picture}
|
||||
hexKey={npubToHex(user.pubkey) || undefined}
|
||||
src={props.metadata[signer.pubkey]?.picture}
|
||||
hexKey={signer.pubkey || undefined}
|
||||
sx={{
|
||||
padding: 0,
|
||||
marginRight: '6px',
|
||||
@ -545,19 +581,11 @@ export const DrawPDFFields = (props: Props) => {
|
||||
}}
|
||||
/>
|
||||
{displayValue}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
if (parsingPdf) {
|
||||
return <LoadingSpinner variant="small" />
|
||||
}
|
||||
|
||||
if (!sigitFiles.length) {
|
||||
return ''
|
||||
return displayValue
|
||||
}
|
||||
|
||||
return (
|
||||
@ -578,7 +606,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
<ExtensionFileBox extension={file.extension} />
|
||||
)}
|
||||
</div>
|
||||
{i < selectedFiles.length - 1 && <FileDivider />}
|
||||
{i < sigitFiles.length - 1 && <FileDivider />}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
|
@ -13,9 +13,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.drawingRectangle {
|
||||
position: absolute;
|
||||
border: 1px solid #01aaad;
|
||||
outline: 1px solid #01aaad;
|
||||
z-index: 50;
|
||||
background-color: #01aaad4b;
|
||||
cursor: pointer;
|
||||
@ -29,7 +35,7 @@
|
||||
}
|
||||
|
||||
&.edited {
|
||||
border: 1px dotted #01aaad;
|
||||
outline: 1px dotted #01aaad;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
@ -78,3 +84,14 @@
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.counterpartSelectValue {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.counterpartAvatar {
|
||||
img {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
|
||||
backgroundColor: selectedMark?.mark.npub
|
||||
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
|
||||
: undefined,
|
||||
borderColor: selectedMark?.mark.npub
|
||||
outlineColor: selectedMark?.mark.npub
|
||||
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}`
|
||||
: undefined,
|
||||
left: inPx(from(pageWidth, location.left)),
|
||||
|
@ -33,7 +33,8 @@ const PdfPageItem = ({
|
||||
useEffect(() => {
|
||||
if (selectedMark !== null && !!markRefs.current[selectedMark.id]) {
|
||||
markRefs.current[selectedMark.id]?.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
})
|
||||
}
|
||||
}, [selectedMark])
|
||||
|
@ -5,7 +5,7 @@ import { AvatarIconButton } from '../UserAvatarIconButton'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useProfileMetadata } from '../../hooks/useProfileMetadata'
|
||||
import { Tooltip } from '@mui/material'
|
||||
import { shorten } from '../../utils'
|
||||
import { getProfileUsername } from '../../utils'
|
||||
import { TooltipChild } from '../TooltipChild'
|
||||
|
||||
interface UserAvatarProps {
|
||||
@ -22,7 +22,7 @@ export const UserAvatar = ({
|
||||
isNameVisible = false
|
||||
}: UserAvatarProps) => {
|
||||
const profile = useProfileMetadata(pubkey)
|
||||
const name = profile?.display_name || profile?.name || shorten(pubkey)
|
||||
const name = getProfileUsername(pubkey, profile)
|
||||
const image = profile?.picture
|
||||
|
||||
return (
|
||||
|
@ -42,15 +42,22 @@
|
||||
.sides {
|
||||
@media only screen and (min-width: 768px) {
|
||||
position: sticky;
|
||||
top: $header-height + $body-vertical-padding;
|
||||
top: $body-vertical-padding;
|
||||
}
|
||||
> :first-child {
|
||||
// We want to keep header on smaller devices at all times
|
||||
max-height: calc(
|
||||
100dvh - $header-height - $body-vertical-padding * 2 - $tabs-height
|
||||
);
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
max-height: calc(100dvh - $body-vertical-padding * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust the content scroll on smaller screens
|
||||
// Make sure only the inner tab is scrolling
|
||||
.scrollAdjust {
|
||||
@media only screen and (max-width: 767px) {
|
||||
max-height: calc(
|
||||
|
@ -3,5 +3,5 @@
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
padding: $header-height + $body-vertical-padding 0 $body-vertical-padding 0;
|
||||
padding: $body-vertical-padding 0 $body-vertical-padding 0;
|
||||
}
|
||||
|
@ -38,46 +38,31 @@ import {
|
||||
sendNotification,
|
||||
signEventForMetaFile,
|
||||
updateUsersAppData,
|
||||
uploadToFileStorage
|
||||
uploadToFileStorage,
|
||||
DEFAULT_TOOLBOX,
|
||||
settleAllFullfilfedPromises
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import fileListStyles from '../../components/FileList/style.module.scss'
|
||||
import { DrawTool, MarkType } from '../../types/drawing'
|
||||
import { DrawTool } from '../../types/drawing'
|
||||
import { DrawPDFFields } from '../../components/DrawPDFFields'
|
||||
import { Mark } from '../../types/mark.ts'
|
||||
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {
|
||||
fa1,
|
||||
faBriefcase,
|
||||
faCalendarDays,
|
||||
faCheckDouble,
|
||||
faCircleDot,
|
||||
faClock,
|
||||
faCreditCard,
|
||||
faEllipsis,
|
||||
faEye,
|
||||
faFile,
|
||||
faFileCirclePlus,
|
||||
faGripLines,
|
||||
faHeading,
|
||||
faIdCard,
|
||||
faImage,
|
||||
faPaperclip,
|
||||
faPen,
|
||||
faPhone,
|
||||
faPlus,
|
||||
faSignature,
|
||||
faSquareCaretDown,
|
||||
faSquareCheck,
|
||||
faStamp,
|
||||
faT,
|
||||
faTableCellsLarge,
|
||||
faToolbox,
|
||||
faTrash,
|
||||
faUpload
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { SigitFile } from '../../utils/file.ts'
|
||||
import { getSigitFile, SigitFile } from '../../utils/file.ts'
|
||||
import _ from 'lodash'
|
||||
|
||||
export const CreatePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -123,118 +108,31 @@ export const CreatePage = () => {
|
||||
{}
|
||||
)
|
||||
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
|
||||
const [parsingPdf, setIsParsing] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
if (selectedFiles) {
|
||||
/**
|
||||
* Reads the binary files and converts to internal file type
|
||||
* and sets to a state (adds images if it's a PDF)
|
||||
*/
|
||||
const parsePages = async () => {
|
||||
const files = await settleAllFullfilfedPromises(
|
||||
selectedFiles,
|
||||
getSigitFile
|
||||
)
|
||||
|
||||
setDrawnFiles(files)
|
||||
}
|
||||
|
||||
setIsParsing(true)
|
||||
|
||||
parsePages().finally(() => {
|
||||
setIsParsing(false)
|
||||
})
|
||||
}
|
||||
}, [selectedFiles])
|
||||
|
||||
const [selectedTool, setSelectedTool] = useState<DrawTool>()
|
||||
const [toolbox] = useState<DrawTool[]>([
|
||||
{
|
||||
identifier: MarkType.TEXT,
|
||||
icon: faT,
|
||||
label: 'Text',
|
||||
active: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SIGNATURE,
|
||||
icon: faSignature,
|
||||
label: 'Signature',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.JOBTITLE,
|
||||
icon: faBriefcase,
|
||||
label: 'Job Title',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FULLNAME,
|
||||
icon: faIdCard,
|
||||
label: 'Full Name',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.INITIALS,
|
||||
icon: faHeading,
|
||||
label: 'Initials',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATETIME,
|
||||
icon: faClock,
|
||||
label: 'Date Time',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATE,
|
||||
icon: faCalendarDays,
|
||||
label: 'Date',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.NUMBER,
|
||||
icon: fa1,
|
||||
label: 'Number',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.IMAGES,
|
||||
icon: faImage,
|
||||
label: 'Images',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CHECKBOX,
|
||||
icon: faSquareCheck,
|
||||
label: 'Checkbox',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.MULTIPLE,
|
||||
icon: faCheckDouble,
|
||||
label: 'Multiple',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FILE,
|
||||
icon: faPaperclip,
|
||||
label: 'File',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.RADIO,
|
||||
icon: faCircleDot,
|
||||
label: 'Radio',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SELECT,
|
||||
icon: faSquareCaretDown,
|
||||
label: 'Select',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CELLS,
|
||||
icon: faTableCellsLarge,
|
||||
label: 'Cells',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.STAMP,
|
||||
icon: faStamp,
|
||||
label: 'Stamp',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PAYMENT,
|
||||
icon: faCreditCard,
|
||||
label: 'Payment',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PHONE,
|
||||
icon: faPhone,
|
||||
label: 'Phone',
|
||||
active: false
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* Changes the drawing tool
|
||||
@ -408,6 +306,19 @@ export const CreatePage = () => {
|
||||
|
||||
const handleRemoveUser = (pubkey: string) => {
|
||||
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey))
|
||||
|
||||
// Set counterpart to ''
|
||||
const drawnFilesCopy = _.cloneDeep(drawnFiles)
|
||||
drawnFilesCopy.forEach((s) => {
|
||||
s.pages?.forEach((p) => {
|
||||
p.drawnFields.forEach((d) => {
|
||||
if (d.counterpart === hexToNpub(pubkey)) {
|
||||
d.counterpart = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
setDrawnFiles(drawnFilesCopy)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -427,7 +338,15 @@ export const CreatePage = () => {
|
||||
|
||||
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files) {
|
||||
setSelectedFiles(Array.from(event.target.files))
|
||||
// Get the uploaded files
|
||||
const files = Array.from(event.target.files)
|
||||
|
||||
// Remove duplicates based on the file.name
|
||||
setSelectedFiles((p) =>
|
||||
[...p, ...files].filter(
|
||||
(file, i, array) => i === array.findIndex((t) => t.name === file.name)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -842,10 +761,6 @@ export const CreatePage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onDrawFieldsChange = (sigitFiles: SigitFile[]) => {
|
||||
setDrawnFiles(sigitFiles)
|
||||
}
|
||||
|
||||
if (authUrl) {
|
||||
return (
|
||||
<iframe
|
||||
@ -959,28 +874,36 @@ export const CreatePage = () => {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
|
||||
{toolbox.map((drawTool: DrawTool, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
{...(drawTool.active && {
|
||||
onClick: () => handleToolSelect(drawTool)
|
||||
})}
|
||||
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${!drawTool.active ? styles.comingSoon : ''}
|
||||
{DEFAULT_TOOLBOX.filter((drawTool) => !drawTool.isHidden).map(
|
||||
(drawTool: DrawTool, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
{...(!drawTool.isComingSoon && {
|
||||
onClick: () => handleToolSelect(drawTool)
|
||||
})}
|
||||
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${drawTool.isComingSoon ? styles.comingSoon : ''}
|
||||
`}
|
||||
>
|
||||
<FontAwesomeIcon fontSize={'15px'} icon={drawTool.icon} />
|
||||
{drawTool.label}
|
||||
{drawTool.active ? (
|
||||
<FontAwesomeIcon fontSize={'15px'} icon={faEllipsis} />
|
||||
) : (
|
||||
<span className={styles.comingSoonPlaceholder}>
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
fontSize={'15px'}
|
||||
icon={drawTool.icon}
|
||||
/>
|
||||
{drawTool.label}
|
||||
{!drawTool.isComingSoon ? (
|
||||
<FontAwesomeIcon
|
||||
fontSize={'15px'}
|
||||
icon={faEllipsis}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.comingSoonPlaceholder}>
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreate} variant="contained">
|
||||
@ -996,13 +919,17 @@ export const CreatePage = () => {
|
||||
centerIcon={faFile}
|
||||
rightIcon={faToolbox}
|
||||
>
|
||||
<DrawPDFFields
|
||||
metadata={metadata}
|
||||
users={users}
|
||||
selectedFiles={selectedFiles}
|
||||
onDrawFieldsChange={onDrawFieldsChange}
|
||||
selectedTool={selectedTool}
|
||||
/>
|
||||
{parsingPdf ? (
|
||||
<LoadingSpinner variant="small" />
|
||||
) : (
|
||||
<DrawPDFFields
|
||||
users={users}
|
||||
metadata={metadata}
|
||||
selectedTool={selectedTool}
|
||||
sigitFiles={drawnFiles}
|
||||
setSigitFiles={setDrawnFiles}
|
||||
/>
|
||||
)}
|
||||
</StickySideColumns>
|
||||
</Container>
|
||||
</>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
|
||||
import { truncate } from 'lodash'
|
||||
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@ -14,6 +13,7 @@ import { State } from '../../store/rootReducer'
|
||||
import { NostrJoiningBlock, ProfileMetadata } from '../../types'
|
||||
import {
|
||||
getNostrJoiningBlockNumber,
|
||||
getProfileUsername,
|
||||
getRoboHashPicture,
|
||||
hexToNpub,
|
||||
shorten
|
||||
@ -42,15 +42,7 @@ export const ProfilePage = () => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc] = useState('Fetching metadata')
|
||||
|
||||
const profileName =
|
||||
pubkey &&
|
||||
profileMetadata &&
|
||||
truncate(
|
||||
profileMetadata.display_name || profileMetadata.name || hexToNpub(pubkey),
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)
|
||||
const profileName = pubkey && getProfileUsername(pubkey, profileMetadata)
|
||||
|
||||
useEffect(() => {
|
||||
if (npub) {
|
||||
|
@ -233,6 +233,7 @@ export const RelaysPage = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Footer />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -428,7 +429,6 @@ const RelayItem = ({
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -22,7 +22,6 @@ import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
addMarks,
|
||||
convertToPdfBlob,
|
||||
FONT_SIZE,
|
||||
FONT_TYPE,
|
||||
groupMarksByFileNamePage,
|
||||
@ -399,8 +398,7 @@ export const VerifyPage = () => {
|
||||
for (const [fileName, file] of Object.entries(files)) {
|
||||
if (file.isPdf) {
|
||||
// Draw marks into PDF file and generate a brand new blob
|
||||
const pages = await addMarks(file, marksByPage[fileName])
|
||||
const blob = await convertToPdfBlob(pages)
|
||||
const blob = await addMarks(file, marksByPage[fileName])
|
||||
zip.file(`files/${fileName}`, blob)
|
||||
} else {
|
||||
zip.file(`files/${fileName}`, file)
|
||||
|
@ -61,6 +61,6 @@
|
||||
|
||||
[data-dev='true'] {
|
||||
.mark {
|
||||
border: 1px dotted black;
|
||||
outline: 1px dotted black;
|
||||
}
|
||||
}
|
||||
|
@ -31,7 +31,10 @@ export interface DrawTool {
|
||||
icon: IconDefinition
|
||||
defaultValue?: string
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
/** show or hide the toolbox item */
|
||||
isHidden?: boolean
|
||||
/** show or hide "coming soon" message on the toolbox item */
|
||||
isComingSoon?: boolean
|
||||
}
|
||||
|
||||
export enum MarkType {
|
||||
|
@ -1,6 +1,7 @@
|
||||
export interface ProfileMetadata {
|
||||
name?: string
|
||||
display_name?: string
|
||||
/** @deprecated use name instead */
|
||||
username?: string
|
||||
picture?: string
|
||||
banner?: string
|
||||
|
@ -21,6 +21,8 @@ export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export const SIGIT_RELAY = 'wss://relay.sigit.io'
|
||||
|
||||
export const SIGIT_BLOSSOM = 'https://blossom.sigit.io'
|
||||
|
||||
export const DEFAULT_LOOK_UP_RELAY_LIST = [
|
||||
SIGIT_RELAY,
|
||||
'wss://user.kindpag.es',
|
||||
|
@ -4,7 +4,6 @@ import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
|
||||
import { extractMarksFromSignedMeta } from './mark.ts'
|
||||
import {
|
||||
addMarks,
|
||||
convertToPdfBlob,
|
||||
groupMarksByFileNamePage,
|
||||
isPdf,
|
||||
pdfToImages
|
||||
@ -22,8 +21,7 @@ export const getZipWithFiles = async (
|
||||
for (const [fileName, file] of Object.entries(files)) {
|
||||
if (file.isPdf) {
|
||||
// Handle PDF Files
|
||||
const pages = await addMarks(file, marksByFileNamePage[fileName])
|
||||
const blob = await convertToPdfBlob(pages)
|
||||
const blob = await addMarks(file, marksByFileNamePage[fileName])
|
||||
zip.file(`files/${fileName}`, blob)
|
||||
} else {
|
||||
// Handle other files
|
||||
|
@ -3,6 +3,27 @@ import { hexToNpub } from './nostr.ts'
|
||||
import { Meta, SignedEventContent } from '../types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { EMPTY } from './const.ts'
|
||||
import { DrawTool, MarkType } from '../types/drawing.ts'
|
||||
import {
|
||||
faT,
|
||||
faSignature,
|
||||
faBriefcase,
|
||||
faIdCard,
|
||||
faClock,
|
||||
fa1,
|
||||
faCalendarDays,
|
||||
faCheckDouble,
|
||||
faCircleDot,
|
||||
faCreditCard,
|
||||
faHeading,
|
||||
faImage,
|
||||
faPaperclip,
|
||||
faPhone,
|
||||
faSquareCaretDown,
|
||||
faSquareCheck,
|
||||
faStamp,
|
||||
faTableCellsLarge
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
/**
|
||||
* Takes in an array of Marks already filtered by User.
|
||||
@ -131,6 +152,120 @@ const findOtherUserMarks = (marks: Mark[], pubkey: string): Mark[] => {
|
||||
return marks.filter((mark) => mark.npub !== hexToNpub(pubkey))
|
||||
}
|
||||
|
||||
export const DEFAULT_TOOLBOX: DrawTool[] = [
|
||||
{
|
||||
identifier: MarkType.FULLNAME,
|
||||
icon: faIdCard,
|
||||
label: 'Full Name',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.JOBTITLE,
|
||||
icon: faBriefcase,
|
||||
label: 'Job Title',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SIGNATURE,
|
||||
icon: faSignature,
|
||||
label: 'Signature',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATETIME,
|
||||
icon: faClock,
|
||||
label: 'Date Time',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.TEXT,
|
||||
icon: faT,
|
||||
label: 'Text'
|
||||
},
|
||||
{
|
||||
identifier: MarkType.NUMBER,
|
||||
icon: fa1,
|
||||
label: 'Number',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.INITIALS,
|
||||
icon: faHeading,
|
||||
label: 'Initials',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATE,
|
||||
icon: faCalendarDays,
|
||||
label: 'Date',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.IMAGES,
|
||||
icon: faImage,
|
||||
label: 'Images',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CHECKBOX,
|
||||
icon: faSquareCheck,
|
||||
label: 'Checkbox',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.MULTIPLE,
|
||||
icon: faCheckDouble,
|
||||
label: 'Multiple',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FILE,
|
||||
icon: faPaperclip,
|
||||
label: 'File',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.RADIO,
|
||||
icon: faCircleDot,
|
||||
label: 'Radio',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SELECT,
|
||||
icon: faSquareCaretDown,
|
||||
label: 'Select',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CELLS,
|
||||
icon: faTableCellsLarge,
|
||||
label: 'Cells',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.STAMP,
|
||||
icon: faStamp,
|
||||
label: 'Stamp',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PAYMENT,
|
||||
icon: faCreditCard,
|
||||
label: 'Payment',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PHONE,
|
||||
icon: faPhone,
|
||||
label: 'Phone',
|
||||
isHidden: true
|
||||
}
|
||||
]
|
||||
|
||||
export const getToolboxLabelByMarkType = (markType: MarkType) => {
|
||||
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
|
||||
}
|
||||
|
||||
export {
|
||||
getCurrentUserMarks,
|
||||
filterMarksByPubkey,
|
||||
|
@ -16,6 +16,8 @@ import { CreateSignatureEventContent, Meta } from '../types'
|
||||
import { hexToNpub, unixNow } from './nostr'
|
||||
import { parseJson } from './string'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { getHash } from './hash.ts'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
|
||||
/**
|
||||
* Uploads a file to a file storage service.
|
||||
@ -25,12 +27,18 @@ import { hexToBytes } from '@noble/hashes/utils'
|
||||
*/
|
||||
export const uploadToFileStorage = async (file: File) => {
|
||||
// Define event metadata for authorization
|
||||
const hash = await getHash(await file.arrayBuffer())
|
||||
if (!hash) {
|
||||
throw new Error("Can't get file hash.")
|
||||
}
|
||||
|
||||
const event: EventTemplate = {
|
||||
kind: 24242,
|
||||
content: 'Authorize Upload',
|
||||
created_at: unixNow(),
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', hash],
|
||||
['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
|
||||
['name', file.name],
|
||||
['size', String(file.size)]
|
||||
@ -47,11 +55,8 @@ export const uploadToFileStorage = async (file: File) => {
|
||||
// Sign the authorization event using the dedicated key stored in user app data
|
||||
const authEvent = finalizeEvent(event, hexToBytes(key))
|
||||
|
||||
// URL of the file storage service
|
||||
const FILE_STORAGE_URL = 'https://blossom.sigit.io' // REFACTOR: should be an env
|
||||
|
||||
// Upload the file to the file storage service using Axios
|
||||
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
|
||||
const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header
|
||||
'Content-Type': 'application/sigit' // Set content type header
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import _, { truncate } from 'lodash'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
@ -30,11 +30,12 @@ import {
|
||||
import { AuthState, Keys } from '../store/auth/types'
|
||||
import { RelaysState } from '../store/relays/types'
|
||||
import store from '../store/store'
|
||||
import { Meta, SignedEvent, UserAppData } from '../types'
|
||||
import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types'
|
||||
import { getDefaultRelayMap } from './relays'
|
||||
import { parseJson, removeLeadingSlash } from './string'
|
||||
import { timeout } from './utils'
|
||||
import { getHash } from './hash'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
|
||||
/**
|
||||
* Generates a `d` tag for userAppData
|
||||
@ -723,6 +724,11 @@ const uploadUserAppDataToBlossom = async (
|
||||
type: 'application/octet-stream'
|
||||
})
|
||||
|
||||
const hash = await getHash(await file.arrayBuffer())
|
||||
if (!hash) {
|
||||
throw new Error("Can't get file hash.")
|
||||
}
|
||||
|
||||
// Define event metadata for authorization
|
||||
const event: EventTemplate = {
|
||||
kind: 24242,
|
||||
@ -730,6 +736,7 @@ const uploadUserAppDataToBlossom = async (
|
||||
created_at: unixNow(),
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', hash],
|
||||
['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
|
||||
['name', file.name],
|
||||
['size', String(file.size)]
|
||||
@ -739,11 +746,8 @@ const uploadUserAppDataToBlossom = async (
|
||||
// Finalize the event with the private key
|
||||
const authEvent = finalizeEvent(event, hexToBytes(privateKey))
|
||||
|
||||
// URL of the file storage service
|
||||
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
|
||||
|
||||
// Upload the file to the file storage service using Axios
|
||||
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
|
||||
const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header
|
||||
}
|
||||
@ -970,3 +974,16 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user's name, first available in order: display_name, name, or npub as fallback
|
||||
* @param npub User identifier, it can be either pubkey or npub1 (we only show npub)
|
||||
* @param profile User profile
|
||||
*/
|
||||
export const getProfileUsername = (
|
||||
npub: `npub1${string}` | string,
|
||||
profile?: ProfileMetadata
|
||||
) =>
|
||||
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
|
||||
length: 16
|
||||
})
|
||||
|
120
src/utils/pdf.ts
120
src/utils/pdf.ts
@ -1,7 +1,6 @@
|
||||
import { PdfPage } from '../types/drawing.ts'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
|
||||
import { Mark } from '../types/mark.ts'
|
||||
|
||||
import * as PDFJS from 'pdfjs-dist'
|
||||
import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker'
|
||||
if (!PDFJS.GlobalWorkerOptions.workerPort) {
|
||||
@ -10,18 +9,23 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) {
|
||||
PDFJS.GlobalWorkerOptions.workerPort = worker
|
||||
}
|
||||
|
||||
import fontkit from '@pdf-lib/fontkit'
|
||||
import defaultFont from '../assets/fonts/roboto-regular.ttf'
|
||||
|
||||
/**
|
||||
* Defined font size used when generating a PDF. Currently it is difficult to fully
|
||||
* correlate font size used at the time of filling in / drawing on the PDF
|
||||
* because it is dynamically rendered, and the final size.
|
||||
* This should be fixed going forward.
|
||||
* Switching to PDF-Lib will most likely make this problem redundant.
|
||||
*/
|
||||
export const FONT_SIZE: number = 16
|
||||
/**
|
||||
* Current font type used when generating a PDF.
|
||||
*/
|
||||
export const FONT_TYPE: string = 'Arial'
|
||||
export const FONT_TYPE: string = 'Roboto'
|
||||
/**
|
||||
* Current line height used when generating a PDF.
|
||||
*/
|
||||
export const FONT_LINE_HEIGHT: number = 1
|
||||
|
||||
/**
|
||||
* A utility that transforms a drawing coordinate number into a CSS-compatible pixel string
|
||||
@ -115,56 +119,28 @@ export const pdfToImages = async (
|
||||
|
||||
/**
|
||||
* Takes in individual pdf file and an object with Marks grouped by Page number
|
||||
* Returns an array of encoded images where each image is a representation
|
||||
* of a PDF page with completed and signed marks from all users
|
||||
* Returns a PDF blob with embedded, completed and signed marks from all users as text
|
||||
*/
|
||||
export const addMarks = async (
|
||||
file: File,
|
||||
marksPerPage: { [key: string]: Mark[] }
|
||||
) => {
|
||||
const p = await readPdf(file)
|
||||
const pdf = await PDFJS.getDocument(p).promise
|
||||
const canvas = document.createElement('canvas')
|
||||
const pdf = await PDFDocument.load(p)
|
||||
const robotoFont = await embedFont(pdf)
|
||||
const pages = pdf.getPages()
|
||||
|
||||
const images: string[] = []
|
||||
|
||||
for (let i = 0; i < pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i + 1)
|
||||
const viewport = page.getViewport({ scale: 1 })
|
||||
const context = canvas.getContext('2d')
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport: viewport }).promise
|
||||
|
||||
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
|
||||
marksPerPage[i]?.forEach((mark) => draw(mark, context))
|
||||
}
|
||||
|
||||
images.push(canvas.toDataURL())
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
|
||||
marksPerPage[i]?.forEach((mark) =>
|
||||
drawMarkText(mark, pages[i], robotoFont)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.remove()
|
||||
const blob = await pdf.save()
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to scale mark in line with the PDF-to-PNG scale
|
||||
*/
|
||||
export const scaleMark = (mark: Mark, scale: number): Mark => {
|
||||
const { location } = mark
|
||||
return {
|
||||
...mark,
|
||||
location: {
|
||||
...location,
|
||||
width: location.width * scale,
|
||||
height: location.height * scale,
|
||||
left: location.left * scale,
|
||||
top: location.top * scale
|
||||
}
|
||||
}
|
||||
return blob
|
||||
}
|
||||
|
||||
/**
|
||||
@ -177,6 +153,7 @@ export const hasValue = (mark: Mark): boolean => !!mark.value
|
||||
* Draws a Mark on a Canvas representation of a PDF Page
|
||||
* @param mark to be drawn
|
||||
* @param ctx a Canvas representation of a specific PDF Page
|
||||
* @deprecated use drawMarkText
|
||||
*/
|
||||
export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => {
|
||||
const { location } = mark
|
||||
@ -191,27 +168,37 @@ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an array of encoded PDF pages and returns a blob that is a complete PDF file
|
||||
* @param markedPdfPages
|
||||
* Draws a Mark on a PDF Page
|
||||
* @param mark to be drawn
|
||||
* @param page PDF Page
|
||||
* @param font embedded font
|
||||
*/
|
||||
export const convertToPdfBlob = async (
|
||||
markedPdfPages: string[]
|
||||
): Promise<Blob> => {
|
||||
const pdfDoc = await PDFDocument.create()
|
||||
export const drawMarkText = (mark: Mark, page: PDFPage, font: PDFFont) => {
|
||||
const { location } = mark
|
||||
const { height } = page.getSize()
|
||||
|
||||
for (const page of markedPdfPages) {
|
||||
const pngImage = await pdfDoc.embedPng(page)
|
||||
const p = pdfDoc.addPage([pngImage.width, pngImage.height])
|
||||
p.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pngImage.width,
|
||||
height: pngImage.height
|
||||
})
|
||||
}
|
||||
// Convert the mark location origin (top, left) to PDF origin (bottom, left)
|
||||
const x = location.left
|
||||
const y = height - location.top
|
||||
|
||||
const pdfBytes = await pdfDoc.save()
|
||||
return new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
// Adjust y-coordinate for the text, drawText's y is the baseline for the font
|
||||
// We start from the y (top location border) and we need to bump it down
|
||||
// We move font baseline by the difference between rendered height and actual height (gap)
|
||||
// And finally move down by the height without descender to get the new baseline
|
||||
const adjustedY =
|
||||
y -
|
||||
(font.heightAtSize(FONT_SIZE) - FONT_SIZE) -
|
||||
font.heightAtSize(FONT_SIZE, { descender: false })
|
||||
|
||||
page.drawText(`${mark.value}`, {
|
||||
x,
|
||||
y: adjustedY,
|
||||
size: FONT_SIZE,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
maxWidth: location.width,
|
||||
lineHeight: FONT_SIZE * FONT_LINE_HEIGHT
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -249,3 +236,12 @@ export const byPage = (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function embedFont(pdf: PDFDocument) {
|
||||
const fontBytes = await fetch(defaultFont).then((res) => res.arrayBuffer())
|
||||
|
||||
pdf.registerFontkit(fontkit)
|
||||
|
||||
const embeddedFont = await pdf.embedFont(fontBytes)
|
||||
return embeddedFont
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user