Compare commits

...

21 Commits

Author SHA1 Message Date
02bb71409a fix(create-page): show other file types in content 2024-08-20 11:23:16 +02:00
6a0402a74d fix: leaky styling and warnings
Closes #147
2024-08-20 11:23:16 +02:00
2fdaa485da fix(draw): add resize cursor to resize handle 2024-08-20 11:23:16 +02:00
9d5213ba14 refactor(create-page): toolbox update 2024-08-20 11:23:16 +02:00
98b9c6e535 refactor(create-page): users design update 2024-08-20 11:23:16 +02:00
b4d121b503 fix(create-page): file list 2024-08-20 11:23:16 +02:00
1787c3625c refactor(create-page): update designs and add files navigation 2024-08-20 11:23:16 +02:00
cb617ed386 fix(create-page): only show signers in counterpart select 2024-08-20 11:23:16 +02:00
aa04472277 refactor(create-page): styling 2024-08-20 11:23:16 +02:00
f64e90e99d fix: simplify events, more ts and clean up 2024-08-20 11:23:16 +02:00
88e1485dcc chore(lint): reduce max warnings 2024-08-20 11:23:16 +02:00
33da049428 feat(create-page): intial layout and page styling
Additional linting fixes
2024-08-20 11:23:16 +02:00
b6479db266 fix(marks): add file grouping for marks, fix read pdf types
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 33s
2024-08-20 11:18:30 +02:00
b2c3cf2aca fix(sigit): add to submittedBy avatar badge for verified sigit creation 2024-08-20 11:18:30 +02:00
423b6b6792 fix(verify-page): add mark styling 2024-08-20 11:18:30 +02:00
268a4db3ff revert: "feat(pdf-marking): adds file validity check"
Refs: ed7acd6cb4
2024-08-20 11:18:30 +02:00
7278485b76 fix(verify-page): export (download) files now includes files
The issue was noticed on the windows machine, removing forward slash made it work
2024-08-20 11:18:30 +02:00
78060fa15f fix(marks): assign selectedMarkValue to currentValue and mark.value 2024-08-20 11:18:30 +02:00
f88e2ad680 fix(verify-page): parse and show mark values 2024-08-20 11:18:30 +02:00
2c586f3c13 feat(verify-page): add files view and content images 2024-08-20 11:18:30 +02:00
79f37a842f fix: file path
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 30s
2024-08-19 11:07:19 +03:00
26 changed files with 1219 additions and 686 deletions

View File

@ -7,7 +7,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 25", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 14",
"lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:staged": "eslint --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:staged": "eslint --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",

View File

@ -1,5 +1,5 @@
import { Meta } from '../../types' import { Meta } from '../../types'
import { SigitCardDisplayInfo, SigitStatus } from '../../utils' import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils' import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils'
import { appPublicRoutes, appPrivateRoutes } from '../../routes' import { appPublicRoutes, appPrivateRoutes } from '../../routes'
@ -13,7 +13,6 @@ import {
faFile faFile
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { UserAvatar } from '../UserAvatar'
import { UserAvatarGroup } from '../UserAvatarGroup' import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss' import styles from './style.module.scss'
@ -34,7 +33,8 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
submittedBy, submittedBy,
signers, signers,
signedStatus, signedStatus,
fileExtensions fileExtensions,
isValid
} = parsedMeta } = parsedMeta
const { signersStatus } = useSigitMeta(meta) const { signersStatus } = useSigitMeta(meta)
@ -62,6 +62,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
const profile = profiles[submittedBy] const profile = profiles[submittedBy]
return ( return (
<Tooltip <Tooltip
key={submittedBy}
title={ title={
profile?.display_name || profile?.display_name ||
profile?.name || profile?.name ||
@ -72,7 +73,11 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
disableInteractive disableInteractive
> >
<TooltipChild> <TooltipChild>
<UserAvatar pubkey={submittedBy} image={profile?.picture} /> <DisplaySigner
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
profile={profile}
pubkey={submittedBy}
/>
</TooltipChild> </TooltipChild>
</Tooltip> </Tooltip>
) )

View File

@ -1,40 +1,27 @@
import { import { Close } from '@mui/icons-material'
AccessTime,
CalendarMonth,
ExpandMore,
Gesture,
PictureAsPdf,
Badge,
Work,
Close
} from '@mui/icons-material'
import { import {
Box, Box,
Typography,
Accordion,
AccordionDetails,
AccordionSummary,
CircularProgress, CircularProgress,
Divider,
FormControl, FormControl,
InputLabel, InputLabel,
MenuItem, MenuItem,
Select Select
} from '@mui/material' } from '@mui/material'
import styles from './style.module.scss' import styles from './style.module.scss'
import { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import * as PDFJS from 'pdfjs-dist' import * as PDFJS from 'pdfjs-dist'
import { ProfileMetadata, User } from '../../types' import { ProfileMetadata, User, UserRole } from '../../types'
import { import {
PdfFile, PdfFile,
DrawTool,
MouseState, MouseState,
PdfPage, PdfPage,
DrawnField, DrawnField,
MarkType DrawTool
} from '../../types/drawing' } from '../../types/drawing'
import { truncate } from 'lodash' import { truncate } from 'lodash'
import { hexToNpub } from '../../utils' import { extractFileExtension, hexToNpub } from '../../utils'
import { toPdfFiles } from '../../utils/pdf.ts' import { toPdfFiles } from '../../utils/pdf.ts'
PDFJS.GlobalWorkerOptions.workerSrc = new URL( PDFJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs', 'pdfjs-dist/build/pdf.worker.min.mjs',
@ -46,48 +33,14 @@ interface Props {
users: User[] users: User[]
metadata: { [key: string]: ProfileMetadata } metadata: { [key: string]: ProfileMetadata }
onDrawFieldsChange: (pdfFiles: PdfFile[]) => void onDrawFieldsChange: (pdfFiles: PdfFile[]) => void
selectedTool?: DrawTool
} }
export const DrawPDFFields = (props: Props) => { export const DrawPDFFields = (props: Props) => {
const { selectedFiles } = props const { selectedFiles, selectedTool } = props
const [pdfFiles, setPdfFiles] = useState<PdfFile[]>([]) const [pdfFiles, setPdfFiles] = useState<PdfFile[]>([])
const [parsingPdf, setParsingPdf] = useState<boolean>(false) const [parsingPdf, setParsingPdf] = useState<boolean>(false)
const [showDrawToolBox, setShowDrawToolBox] = useState<boolean>(false)
const [selectedTool, setSelectedTool] = useState<DrawTool | null>()
const [toolbox] = useState<DrawTool[]>([
{
identifier: MarkType.SIGNATURE,
icon: <Gesture />,
label: 'Signature',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: <Badge />,
label: 'Full Name',
active: true
},
{
identifier: MarkType.JOBTITLE,
icon: <Work />,
label: 'Job Title',
active: false
},
{
identifier: MarkType.DATE,
icon: <CalendarMonth />,
label: 'Date',
active: false
},
{
identifier: MarkType.DATETIME,
icon: <AccessTime />,
label: 'Datetime',
active: false
}
])
const [mouseState, setMouseState] = useState<MouseState>({ const [mouseState, setMouseState] = useState<MouseState>({
clicked: false clicked: false
@ -95,6 +48,16 @@ export const DrawPDFFields = (props: Props) => {
useEffect(() => { useEffect(() => {
if (selectedFiles) { if (selectedFiles) {
/**
* Reads the pdf binary files and converts it's pages to images
* creates the pdfFiles object and sets to a state
*/
const parsePdfPages = async () => {
const pdfFiles: PdfFile[] = await toPdfFiles(selectedFiles)
setPdfFiles(pdfFiles)
}
setParsingPdf(true) setParsingPdf(true)
parsePdfPages().finally(() => { parsePdfPages().finally(() => {
@ -105,7 +68,7 @@ export const DrawPDFFields = (props: Props) => {
useEffect(() => { useEffect(() => {
if (pdfFiles) props.onDrawFieldsChange(pdfFiles) if (pdfFiles) props.onDrawFieldsChange(pdfFiles)
}, [pdfFiles]) }, [pdfFiles, props])
/** /**
* Drawing events * Drawing events
@ -132,14 +95,14 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param page PdfPage where press happened * @param page PdfPage where press happened
*/ */
const onMouseDown = (event: any, page: PdfPage) => { const onMouseDown = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
page: PdfPage
) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
// Only allow drawing if mouse is not over other drawn element if (!selectedTool) {
const isOverPdfImageWrapper = event.target.tagName === 'IMG'
if (!selectedTool || !isOverPdfImageWrapper) {
return return
} }
@ -185,11 +148,20 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param page PdfPage where moving is happening * @param page PdfPage where moving is happening
*/ */
const onMouseMove = (event: any, page: PdfPage) => { const onMouseMove = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
page: PdfPage
) => {
if (mouseState.clicked && selectedTool) { if (mouseState.clicked && selectedTool) {
const lastElementIndex = page.drawnFields.length - 1 const lastElementIndex = page.drawnFields.length - 1
const lastDrawnField = page.drawnFields[lastElementIndex] const lastDrawnField = page.drawnFields[lastElementIndex]
// Return early if we don't have lastDrawnField
// Issue noticed in the console when dragging out of bounds
// to the page below (without releaseing mouse click)
if (!lastDrawnField) return
const { mouseX, mouseY } = getMouseCoordinates(event) const { mouseX, mouseY } = getMouseCoordinates(event)
const width = mouseX - lastDrawnField.left const width = mouseX - lastDrawnField.left
@ -216,7 +188,9 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField Which we are moving * @param drawnField Which we are moving
*/ */
const onDrawnFieldMouseDown = (event: any) => { const onDrawnFieldMouseDown = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
event.stopPropagation() event.stopPropagation()
// Proceed only if left click // Proceed only if left click
@ -239,11 +213,14 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField which we are moving * @param drawnField which we are moving
*/ */
const onDranwFieldMouseMove = (event: any, drawnField: DrawnField) => { const onDrawnFieldMouseMove = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
drawnField: DrawnField
) => {
if (mouseState.dragging) { if (mouseState.dragging) {
const { mouseX, mouseY, rect } = getMouseCoordinates( const { mouseX, mouseY, rect } = getMouseCoordinates(
event, event,
event.target.parentNode event.currentTarget.parentElement
) )
const coordsOffset = mouseState.coordsInWrapper const coordsOffset = mouseState.coordsInWrapper
@ -272,7 +249,9 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField which we are resizing * @param drawnField which we are resizing
*/ */
const onResizeHandleMouseDown = (event: any) => { const onResizeHandleMouseDown = (
event: React.MouseEvent<HTMLSpanElement, MouseEvent>
) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
@ -288,11 +267,17 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event * @param event Mouse event
* @param drawnField which we are resizing * @param drawnField which we are resizing
*/ */
const onResizeHandleMouseMove = (event: any, drawnField: DrawnField) => { const onResizeHandleMouseMove = (
event: React.MouseEvent<HTMLSpanElement, MouseEvent>,
drawnField: DrawnField
) => {
if (mouseState.resizing) { if (mouseState.resizing) {
const { mouseX, mouseY } = getMouseCoordinates( const { mouseX, mouseY } = getMouseCoordinates(
event, event,
event.target.parentNode.parentNode // currentTarget = span handle
// 1st parent = drawnField
// 2nd parent = img
event.currentTarget.parentElement?.parentElement
) )
const width = mouseX - drawnField.left const width = mouseX - drawnField.left
@ -313,7 +298,7 @@ export const DrawPDFFields = (props: Props) => {
* @param drawnFileIndex drawn file index * @param drawnFileIndex drawn file index
*/ */
const onRemoveHandleMouseDown = ( const onRemoveHandleMouseDown = (
event: any, event: React.MouseEvent<HTMLSpanElement, MouseEvent>,
pdfFileIndex: number, pdfFileIndex: number,
pdfPageIndex: number, pdfPageIndex: number,
drawnFileIndex: number drawnFileIndex: number
@ -331,7 +316,9 @@ export const DrawPDFFields = (props: Props) => {
* so select can work properly * so select can work properly
* @param event Mouse event * @param event Mouse event
*/ */
const onUserSelectHandleMouseDown = (event: any) => { const onUserSelectHandleMouseDown = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
event.stopPropagation() event.stopPropagation()
} }
@ -341,8 +328,11 @@ export const DrawPDFFields = (props: Props) => {
* @param customTarget mouse coordinates relative to this element, if not provided * @param customTarget mouse coordinates relative to this element, if not provided
* event.target will be used * event.target will be used
*/ */
const getMouseCoordinates = (event: any, customTarget?: any) => { const getMouseCoordinates = (
const target = customTarget ? customTarget : event.target event: React.MouseEvent<HTMLElement, MouseEvent>,
customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect() const rect = target.getBoundingClientRect()
const mouseX = event.clientX - rect.left //x position within the element. const mouseX = event.clientX - rect.left //x position within the element.
const mouseY = event.clientY - rect.top //y position within the element. const mouseY = event.clientY - rect.top //y position within the element.
@ -354,74 +344,26 @@ export const DrawPDFFields = (props: Props) => {
} }
} }
/**
* Reads the pdf binary files and converts it's pages to images
* creates the pdfFiles object and sets to a state
*/
const parsePdfPages = async () => {
const pdfFiles: PdfFile[] = await toPdfFiles(selectedFiles)
setPdfFiles(pdfFiles)
}
/**
*
* @returns if expanded pdf accordion is present
*/
const hasExpandedPdf = () => {
return !!pdfFiles.filter((pdfFile) => !!pdfFile.expanded).length
}
const handleAccordionExpandChange = (expanded: boolean, pdfFile: PdfFile) => {
pdfFile.expanded = expanded
refreshPdfFiles()
setShowDrawToolBox(hasExpandedPdf())
}
/**
* Changes the drawing tool
* @param drawTool to draw with
*/
const handleToolSelect = (drawTool: DrawTool) => {
// If clicked on the same tool, unselect
if (drawTool.identifier === selectedTool?.identifier) {
setSelectedTool(null)
return
}
setSelectedTool(drawTool)
}
/** /**
* Renders the pdf pages and drawing elements * Renders the pdf pages and drawing elements
*/ */
const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => { const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => {
return ( return (
<Box <>
sx={{
width: '100%'
}}
>
{pdfFile.pages.map((page, pdfPageIndex: number) => { {pdfFile.pages.map((page, pdfPageIndex: number) => {
return ( return (
<div <div
key={pdfPageIndex} key={pdfPageIndex}
style={{
border: '1px solid #c4c4c4',
marginBottom: '10px'
}}
className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`} className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onMouseDown={(event) => {
onMouseDown(event, page)
}}
> >
<img <img
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onMouseDown={(event) => {
onMouseDown(event, page)
}}
draggable="false" draggable="false"
style={{ width: '100%' }}
src={page.image} src={page.image}
/> />
@ -431,7 +373,7 @@ export const DrawPDFFields = (props: Props) => {
key={drawnFieldIndex} key={drawnFieldIndex}
onMouseDown={onDrawnFieldMouseDown} onMouseDown={onDrawnFieldMouseDown}
onMouseMove={(event) => { onMouseMove={(event) => {
onDranwFieldMouseMove(event, drawnField) onDrawnFieldMouseMove(event, drawnField)
}} }}
className={styles.drawingRectangle} className={styles.drawingRectangle}
style={{ style={{
@ -477,36 +419,38 @@ export const DrawPDFFields = (props: Props) => {
labelId="counterparts" labelId="counterparts"
label="Counterparts" label="Counterparts"
> >
{props.users.map((user, index) => { {props.users
let displayValue = truncate( .filter((u) => u.role === UserRole.signer)
hexToNpub(user.pubkey), .map((user, index) => {
{ let displayValue = truncate(
length: 16 hexToNpub(user.pubkey),
}
)
const metadata = props.metadata[user.pubkey]
if (metadata) {
displayValue = truncate(
metadata.name ||
metadata.display_name ||
metadata.username,
{ {
length: 16 length: 16
} }
) )
}
return ( const metadata = props.metadata[user.pubkey]
<MenuItem
key={index} if (metadata) {
value={hexToNpub(user.pubkey)} displayValue = truncate(
> metadata.name ||
{displayValue} metadata.display_name ||
</MenuItem> metadata.username,
) {
})} length: 16
}
)
}
return (
<MenuItem
key={index}
value={hexToNpub(user.pubkey)}
>
{displayValue}
</MenuItem>
)
})}
</Select> </Select>
</FormControl> </FormControl>
</div> </div>
@ -516,7 +460,7 @@ export const DrawPDFFields = (props: Props) => {
</div> </div>
) )
})} })}
</Box> </>
) )
} }
@ -533,57 +477,38 @@ export const DrawPDFFields = (props: Props) => {
} }
return ( return (
<Box> <div className={styles.view}>
<Box sx={{ mt: 1 }}> {selectedFiles.map((file, i) => {
<Typography sx={{ mb: 1 }}>Draw fields on the PDFs:</Typography> const name = file.name
const extension = extractFileExtension(name)
{pdfFiles.map((pdfFile, pdfFileIndex: number) => { const pdfFile = pdfFiles.find((pdf) => pdf.file.name === name)
return ( return (
<Accordion <React.Fragment key={name}>
key={pdfFileIndex} <div
expanded={pdfFile.expanded} className={`${styles.fileWrapper} ${styles.scrollTarget}`}
onChange={(_event, expanded) => { id={`file-${name}`}
handleAccordionExpandChange(expanded, pdfFile)
}}
> >
<AccordionSummary {pdfFile ? (
expandIcon={<ExpandMore />} getPdfPages(pdfFile, i)
aria-controls={`panel${pdfFileIndex}-content`} ) : (
id={`panel${pdfFileIndex}header`} <div className={styles.otherFile}>
This is a {extension} file
</div>
)}
</div>
{i < selectedFiles.length - 1 && (
<Divider
sx={{
fontSize: '12px',
color: 'rgba(0,0,0,0.15)'
}}
> >
<PictureAsPdf sx={{ mr: 1 }} /> File Separator
{pdfFile.file.name} </Divider>
</AccordionSummary> )}
<AccordionDetails> </React.Fragment>
{getPdfPages(pdfFile, pdfFileIndex)} )
</AccordionDetails> })}
</Accordion> </div>
)
})}
</Box>
{showDrawToolBox && (
<Box className={styles.drawToolBoxContainer}>
<Box className={styles.drawToolBox}>
{toolbox
.filter((drawTool) => drawTool.active)
.map((drawTool: DrawTool, index: number) => {
return (
<Box
key={index}
onClick={() => {
handleToolSelect(drawTool)
}}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''}`}
>
{drawTool.icon}
{drawTool.label}
</Box>
)
})}
</Box>
</Box>
)}
</Box>
) )
} }

View File

@ -1,3 +1,5 @@
@import '../../styles/sizes.scss';
.pdfFieldItem { .pdfFieldItem {
background: white; background: white;
padding: 10px; padding: 10px;
@ -5,53 +7,10 @@
cursor: pointer; cursor: pointer;
} }
.drawToolBoxContainer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
z-index: 50;
.drawToolBox {
display: flex;
gap: 10px;
min-width: 100px;
background-color: white;
padding: 15px;
box-shadow: 0 0 10px 1px #0000003b;
border-radius: 4px;
.toolItem {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.137);
padding: 5px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
&.selected {
border-color: #01aaad;
color: #01aaad;
}
&:not(.selected) {
&:hover {
border-color: #01aaad79;
}
}
}
}
}
.pdfImageWrapper { .pdfImageWrapper {
position: relative; position: relative;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
margin-bottom: 10px;
> img { > img {
display: block; display: block;
@ -81,7 +40,7 @@
} }
&.edited { &.edited {
border: 1px dotted #01aaad border: 1px dotted #01aaad;
} }
.resizeHandle { .resizeHandle {
@ -93,7 +52,14 @@
background-color: #fff; background-color: #fff;
border: 1px solid rgb(160, 160, 160); border: 1px solid rgb(160, 160, 160);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: nwse-resize;
// Increase the area a bit so it's easier to click
&::after {
content: '';
position: absolute;
inset: -14px;
}
} }
.removeHandle { .removeHandle {
@ -124,3 +90,29 @@
padding: 5px 0; padding: 5px 0;
} }
} }
.fileWrapper {
display: flex;
flex-direction: column;
gap: 15px;
position: relative;
scroll-margin-top: $header-height + $body-vertical-padding;
}
.view {
display: flex;
flex-direction: column;
gap: 25px;
}
.otherFile {
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: rgba(0, 0, 0, 0.25);
font-size: 14px;
}

View File

@ -1,8 +1,6 @@
import { CurrentUserFile } from '../../types/file.ts' import { CurrentUserFile } from '../../types/file.ts'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Button } from '@mui/material' import { Button } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
interface FileListProps { interface FileListProps {
files: CurrentUserFile[] files: CurrentUserFile[]
@ -28,14 +26,8 @@ const FileList = ({
className={`${styles.fileItem} ${isActive(file) && styles.active}`} className={`${styles.fileItem} ${isActive(file) && styles.active}`}
onClick={() => setCurrentFile(file)} onClick={() => setCurrentFile(file)}
> >
<div className={styles.fileNumber}>{file.id}</div> <span className={styles.fileNumber}>{file.id}</span>
<div className={styles.fileInfo}> <span className={styles.fileName}>{file.filename}</span>
<div className={styles.fileName}>{file.filename}</div>
</div>
<div className={styles.fileVisual}>
{file.isHashValid && <FontAwesomeIcon icon={faCheck} />}
</div>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -17,17 +17,10 @@
ul { ul {
list-style-type: none; /* Removes bullet points */ list-style-type: none; /* Removes bullet points */
margin: 0; /* Removes default margin */ margin: 0; /* Removes default margin */
padding: 0; /* Removes default padding */ padding: 0; /* Removes default padding */
} }
li {
list-style-type: none; /* Removes the bullets */
margin: 0; /* Removes any default margin */
padding: 0; /* Removes any default padding */
}
.wrap { .wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -50,7 +43,7 @@ li {
} }
.files::-webkit-scrollbar-track { .files::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0.15); background-color: rgba(0, 0, 0, 0.15);
} }
.files::-webkit-scrollbar-thumb { .files::-webkit-scrollbar-thumb {
@ -70,12 +63,12 @@ li {
background: #ffffff; background: #ffffff;
padding: 5px 10px; padding: 5px 10px;
align-items: center; align-items: center;
color: rgba(0,0,0,0.5); color: rgba(0, 0, 0, 0.5);
cursor: pointer; cursor: pointer;
flex-grow: 1; flex-grow: 1;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
min-height: 45px;
&.active { &.active {
background: #4c82a3; background: #4c82a3;
@ -84,22 +77,16 @@ li {
} }
.fileItem:hover { .fileItem:hover {
transition: ease 0.2s;
background: #4c82a3; background: #4c82a3;
color: white; color: white;
} }
.fileInfo {
flex-grow: 1;
font-size: 16px;
font-weight: 500;
}
.fileName { .fileName {
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
-webkit-line-clamp: 1; -webkit-line-clamp: 1;
line-clamp: 1;
} }
.fileNumber { .fileNumber {
@ -109,15 +96,4 @@ li {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 10px;
}
.fileVisual {
display: flex;
flex-shrink: 0;
flex-direction: column;
justify-content: center;
align-items: center;
height: 25px;
width: 25px;
} }

View File

@ -8,6 +8,39 @@
left: 0; left: 0;
align-items: center; align-items: center;
z-index: 1000; z-index: 1000;
button {
transition: ease 0.2s;
width: auto;
border-radius: 4px;
outline: unset;
border: unset;
background: unset;
color: #ffffff;
background: #4c82a3;
font-weight: 500;
font-size: 14px;
padding: 8px 15px;
white-space: nowrap;
display: flex;
flex-direction: row;
grid-gap: 12px;
justify-content: center;
align-items: center;
text-decoration: unset;
position: relative;
cursor: pointer;
}
button:hover {
background: #5e8eab;
color: white;
}
button:active {
background: #447592;
color: white;
}
} }
.actions { .actions {
@ -19,7 +52,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
grid-gap: 15px; grid-gap: 15px;
box-shadow: 0 -2px 4px 0 rgb(0,0,0,0.1); box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1);
max-width: 750px; max-width: 750px;
&.expanded { &.expanded {
@ -73,7 +106,7 @@
.textInput { .textInput {
height: 100px; height: 100px;
background: rgba(0,0,0,0.1); background: rgba(0, 0, 0, 0.1);
border-radius: 4px; border-radius: 4px;
border: solid 2px #4c82a3; border: solid 2px #4c82a3;
display: flex; display: flex;
@ -84,17 +117,19 @@
.input { .input {
border-radius: 4px; border-radius: 4px;
border: solid 1px rgba(0,0,0,0.15); border: solid 1px rgba(0, 0, 0, 0.15);
padding: 5px 10px; padding: 5px 10px;
font-size: 16px; font-size: 16px;
width: 100%; width: 100%;
background: linear-gradient(rgba(0,0,0,0.00), rgba(0,0,0,0.00) 100%), linear-gradient(white, white); background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0) 100%),
linear-gradient(white, white);
} }
.input:focus { .input:focus {
border: solid 1px rgba(0,0,0,0.15); border: solid 1px rgba(0, 0, 0, 0.15);
outline: none; outline: none;
background: linear-gradient(rgba(0,0,0,0.05), rgba(0,0,0,0.05) 100%), linear-gradient(white, white); background: linear-gradient(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.05) 100%),
linear-gradient(white, white);
} }
.actionsBottom { .actionsBottom {
@ -105,41 +140,6 @@
align-items: center; align-items: center;
} }
button {
transition: ease 0.2s;
width: auto;
border-radius: 4px;
outline: unset;
border: unset;
background: unset;
color: #ffffff;
background: #4c82a3;
font-weight: 500;
font-size: 14px;
padding: 8px 15px;
white-space: nowrap;
display: flex;
flex-direction: row;
grid-gap: 12px;
justify-content: center;
align-items: center;
text-decoration: unset;
position: relative;
cursor: pointer;
}
button:hover {
transition: ease 0.2s;
background: #5e8eab;
color: white;
}
button:active {
transition: ease 0.2s;
background: #447592;
color: white;
}
.submitButton { .submitButton {
width: 100%; width: 100%;
max-width: 300px; max-width: 300px;
@ -172,18 +172,18 @@ button:active {
font-size: 12px; font-size: 12px;
padding: 5px 10px; padding: 5px 10px;
border-radius: 3px; border-radius: 3px;
background: rgba(0,0,0,0.1); background: rgba(0, 0, 0, 0.1);
color: rgba(0,0,0,0.5); color: rgba(0, 0, 0, 0.5);
} }
.paginationButton:hover { .paginationButton:hover {
background: #447592; background: #447592;
color: rgba(255,255,255,0.5); color: rgba(255, 255, 255, 0.5);
} }
.paginationButtonDone { .paginationButtonDone {
background: #5e8eab; background: #5e8eab;
color: rgb(255,255,255); color: rgb(255, 255, 255);
} }
.paginationButtonCurrent { .paginationButtonCurrent {
@ -204,7 +204,7 @@ button:active {
background: white; background: white;
color: #434343; color: #434343;
padding: 5px 30px; padding: 5px 30px;
box-shadow: 0px -3px 4px 0 rgb(0,0,0,0.1); box-shadow: 0px -3px 4px 0 rgb(0, 0, 0, 0.1);
position: absolute; position: absolute;
top: -25px; top: -25px;
} }

View File

@ -30,7 +30,7 @@ export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => {
padding: 0 padding: 0
}} }}
/> />
{name ? <label className={styles.username}>{name}</label> : null} {name ? <span className={styles.username}>{name}</span> : null}
</Link> </Link>
) )
} }

View File

@ -10,4 +10,8 @@
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
color: var(--text-color); color: var(--text-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
} }

View File

@ -9,7 +9,6 @@ import {
shorten, shorten,
SignStatus SignStatus
} from '../../utils' } from '../../utils'
import { UserAvatar } from '../UserAvatar'
import { useSigitMeta } from '../../hooks/useSigitMeta' import { useSigitMeta } from '../../hooks/useSigitMeta'
import { UserAvatarGroup } from '../UserAvatarGroup' import { UserAvatarGroup } from '../UserAvatarGroup'
@ -44,7 +43,8 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
createdAt, createdAt,
completedAt, completedAt,
parsedSignatureEvents, parsedSignatureEvents,
signedStatus signedStatus,
isValid
} = useSigitMeta(meta) } = useSigitMeta(meta)
const { usersPubkey } = useSelector((state: State) => state.auth) const { usersPubkey } = useSelector((state: State) => state.auth)
const profiles = useSigitProfiles([ const profiles = useSigitProfiles([
@ -56,7 +56,7 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
typeof usersPubkey !== 'undefined' && typeof usersPubkey !== 'undefined' &&
signers.includes(hexToNpub(usersPubkey)) signers.includes(hexToNpub(usersPubkey))
const ext = extractFileExtensions(Object.keys(fileHashes)) const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
return submittedBy ? ( return submittedBy ? (
<div className={styles.container}> <div className={styles.container}>
@ -68,6 +68,7 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
const profile = profiles[submittedBy] const profile = profiles[submittedBy]
return ( return (
<Tooltip <Tooltip
key={submittedBy}
title={ title={
profile?.display_name || profile?.display_name ||
profile?.name || profile?.name ||
@ -78,7 +79,11 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
disableInteractive disableInteractive
> >
<TooltipChild> <TooltipChild>
<UserAvatar pubkey={submittedBy} image={profile?.picture} /> <DisplaySigner
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
profile={profile}
pubkey={submittedBy}
/>
</TooltipChild> </TooltipChild>
</Tooltip> </Tooltip>
) )
@ -196,14 +201,14 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<span className={styles.detailsItem}> <span className={styles.detailsItem}>
<FontAwesomeIcon icon={faEye} /> {signedStatus} <FontAwesomeIcon icon={faEye} /> {signedStatus}
</span> </span>
{ext.length > 0 ? ( {extensions.length > 0 ? (
<span className={styles.detailsItem}> <span className={styles.detailsItem}>
{ext.length > 1 ? ( {!isSame ? (
<> <>
<FontAwesomeIcon icon={faFile} /> Multiple File Types <FontAwesomeIcon icon={faFile} /> Multiple File Types
</> </>
) : ( ) : (
getExtensionIconLabel(ext[0]) getExtensionIconLabel(extensions[0])
)} )}
</span> </span>
) : ( ) : (

View File

@ -1,5 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { CreateSignatureEventContent, Meta, SignedEventContent } from '../types' import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent
} from '../types'
import { Mark } from '../types/mark' import { Mark } from '../types/mark'
import { import {
fromUnixTimestamp, fromUnixTimestamp,
@ -38,7 +43,9 @@ export interface FlatMeta
encryptionKey: string | null encryptionKey: string | null
// Parsed Document Signatures // Parsed Document Signatures
parsedSignatureEvents: { [signer: `npub1${string}`]: Event } parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time // Calculated completion time
completedAt?: number completedAt?: number
@ -74,7 +81,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [zipUrl, setZipUrl] = useState<string>('') const [zipUrl, setZipUrl] = useState<string>('')
const [parsedSignatureEvents, setParsedSignatureEvents] = useState<{ const [parsedSignatureEvents, setParsedSignatureEvents] = useState<{
[signer: `npub1${string}`]: Event [signer: `npub1${string}`]: DocSignatureEvent
}>({}) }>({})
const [completedAt, setCompletedAt] = useState<number>() const [completedAt, setCompletedAt] = useState<number>()
@ -141,7 +148,10 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
} }
// Temp. map to hold events and signers // Temp. map to hold events and signers
const parsedSignatureEventsMap = new Map<`npub1${string}`, Event>() const parsedSignatureEventsMap = new Map<
`npub1${string}`,
DocSignatureEvent
>()
const signerStatusMap = new Map<`npub1${string}`, SignStatus>() const signerStatusMap = new Map<`npub1${string}`, SignStatus>()
const getPrevSignerSig = (npub: `npub1${string}`) => { const getPrevSignerSig = (npub: `npub1${string}`) => {
@ -183,9 +193,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
if (isValidSignature) { if (isValidSignature) {
// get the signature of prev signer from the content of current signers signedEvent // get the signature of prev signer from the content of current signers signedEvent
const prevSignersSig = getPrevSignerSig(npub) const prevSignersSig = getPrevSignerSig(npub)
try { try {
const obj: SignedEventContent = JSON.parse(event.content) const obj: SignedEventContent = JSON.parse(event.content)
parsedSignatureEventsMap.set(npub, {
...event,
parsedContent: obj
})
if ( if (
obj.prevSig && obj.prevSig &&
prevSignersSig && prevSignersSig &&

View File

@ -10,6 +10,9 @@
.sidesWrap { .sidesWrap {
position: relative; position: relative;
// HACK: Stop grid column from growing
min-width: 0;
} }
.sides { .sides {
@ -23,7 +26,11 @@
grid-gap: 15px; grid-gap: 15px;
} }
.content { .content {
max-width: 550px; padding: 10px;
width: 550px; border: 10px solid $overlay-background-color;
border-radius: 4px;
max-width: 590px;
width: 590px;
margin: 0 auto; margin: 0 auto;
} }

View File

@ -1,27 +1,16 @@
import { Clear, DragHandle } from '@mui/icons-material'
import { import {
Box,
Button, Button,
FormControl, FormHelperText,
IconButton, ListItemIcon,
InputLabel, ListItemText,
MenuItem, MenuItem,
Paper,
Select, Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField, TextField,
Tooltip, Tooltip
Typography
} from '@mui/material' } from '@mui/material'
import type { Identifier, XYCoord } from 'dnd-core' import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver' import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd' import { DndProvider, useDrag, useDrop } from 'react-dnd'
@ -61,14 +50,46 @@ import {
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import styles from './style.module.scss' import styles from './style.module.scss'
import { PdfFile } from '../../types/drawing' import fileListStyles from '../../components/FileList/style.module.scss'
import { DrawTool, MarkType, PdfFile } from '../../types/drawing'
import { DrawPDFFields } from '../../components/DrawPDFFields' import { DrawPDFFields } from '../../components/DrawPDFFields'
import { Mark } from '../../types/mark.ts' 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,
faGripLines,
faHeading,
faIdCard,
faImage,
faPaperclip,
faPen,
faPhone,
faPlus,
faSignature,
faSquareCaretDown,
faSquareCheck,
faStamp,
faT,
faTableCellsLarge,
faTrash,
faUpload
} from '@fortawesome/free-solid-svg-icons'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { uploadedFiles } = location.state || {} const { uploadedFiles } = location.state || {}
const [currentFile, setCurrentFile] = useState<File>()
const isActive = (file: File) => file.name === currentFile?.name
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -76,9 +97,22 @@ export const CreatePage = () => {
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
const [userInput, setUserInput] = useState('') const [userInput, setUserInput] = useState('')
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
handleAddUser()
}
}
const [userRole, setUserRole] = useState<UserRole>(UserRole.signer) const [userRole, setUserRole] = useState<UserRole>(UserRole.signer)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
@ -93,6 +127,132 @@ export const CreatePage = () => {
) )
const [drawnPdfs, setDrawnPdfs] = useState<PdfFile[]>([]) const [drawnPdfs, setDrawnPdfs] = useState<PdfFile[]>([])
const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [toolbox] = useState<DrawTool[]>([
{
identifier: MarkType.TEXT,
icon: <FontAwesomeIcon icon={faT} />,
label: 'Text',
active: false
},
{
identifier: MarkType.SIGNATURE,
icon: <FontAwesomeIcon icon={faSignature} />,
label: 'Signature',
active: false
},
{
identifier: MarkType.JOBTITLE,
icon: <FontAwesomeIcon icon={faBriefcase} />,
label: 'Job Title',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: <FontAwesomeIcon icon={faIdCard} />,
label: 'Full Name',
active: true
},
{
identifier: MarkType.INITIALS,
icon: <FontAwesomeIcon icon={faHeading} />,
label: 'Initials',
active: false
},
{
identifier: MarkType.DATETIME,
icon: <FontAwesomeIcon icon={faClock} />,
label: 'Date Time',
active: false
},
{
identifier: MarkType.DATE,
icon: <FontAwesomeIcon icon={faCalendarDays} />,
label: 'Date',
active: false
},
{
identifier: MarkType.NUMBER,
icon: <FontAwesomeIcon icon={fa1} />,
label: 'Number',
active: false
},
{
identifier: MarkType.IMAGES,
icon: <FontAwesomeIcon icon={faImage} />,
label: 'Images',
active: false
},
{
identifier: MarkType.CHECKBOX,
icon: <FontAwesomeIcon icon={faSquareCheck} />,
label: 'Checkbox',
active: false
},
{
identifier: MarkType.MULTIPLE,
icon: <FontAwesomeIcon icon={faCheckDouble} />,
label: 'Multiple',
active: false
},
{
identifier: MarkType.FILE,
icon: <FontAwesomeIcon icon={faPaperclip} />,
label: 'File',
active: false
},
{
identifier: MarkType.RADIO,
icon: <FontAwesomeIcon icon={faCircleDot} />,
label: 'Radio',
active: false
},
{
identifier: MarkType.SELECT,
icon: <FontAwesomeIcon icon={faSquareCaretDown} />,
label: 'Select',
active: false
},
{
identifier: MarkType.CELLS,
icon: <FontAwesomeIcon icon={faTableCellsLarge} />,
label: 'Cells',
active: false
},
{
identifier: MarkType.STAMP,
icon: <FontAwesomeIcon icon={faStamp} />,
label: 'Stamp',
active: false
},
{
identifier: MarkType.PAYMENT,
icon: <FontAwesomeIcon icon={faCreditCard} />,
label: 'Payment',
active: false
},
{
identifier: MarkType.PHONE,
icon: <FontAwesomeIcon icon={faPhone} />,
label: 'Phone',
active: false
}
])
/**
* Changes the drawing tool
* @param drawTool to draw with
*/
const handleToolSelect = (drawTool: DrawTool) => {
// If clicked on the same tool, unselect
if (drawTool.identifier === selectedTool?.identifier) {
setSelectedTool(undefined)
return
}
setSelectedTool(drawTool)
}
useEffect(() => { useEffect(() => {
users.forEach((user) => { users.forEach((user) => {
if (!(user.pubkey in metadata)) { if (!(user.pubkey in metadata)) {
@ -268,19 +428,22 @@ export const CreatePage = () => {
}) })
} }
const handleSelectFiles = (files: File[]) => { const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
setSelectedFiles((prev) => { if (event.target.files) {
const prevFileNames = prev.map((file) => file.name) setSelectedFiles(Array.from(event.target.files))
}
const newFiles = files.filter(
(file) => !prevFileNames.includes(file.name)
)
return [...prev, ...newFiles]
})
} }
const handleRemoveFile = (fileToRemove: File) => { const handleFileClick = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
}
const handleRemoveFile = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
fileToRemove: File
) => {
event.stopPropagation()
setSelectedFiles((prevFiles) => setSelectedFiles((prevFiles) =>
prevFiles.filter((file) => file.name !== fileToRemove.name) prevFiles.filter((file) => file.name !== fileToRemove.name)
) )
@ -702,94 +865,200 @@ export const CreatePage = () => {
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}> <Container className={styles.container}>
<TextField <StickySideColumns
label="Title" left={
value={title} <div className={styles.flexWrap}>
onChange={(e) => setTitle(e.target.value)} <div className={styles.inputWrapper}>
variant="outlined" <TextField
/> placeholder="Title"
size="small"
<Box> type="text"
<MuiFileInput value={title}
fullWidth onChange={(e) => setTitle(e.target.value)}
multiple sx={{
placeholder="Choose Files" width: '100%',
value={selectedFiles} fontSize: '16px',
onChange={(value) => handleSelectFiles(value)} '& .MuiInputBase-input': {
/> padding: '7px 14px'
},
{selectedFiles.length > 0 && ( '& .MuiOutlinedInput-notchedOutline': {
<ul> display: 'none'
{selectedFiles.map((file, index) => ( }
<li key={index}> }}
<Typography component="label">{file.name}</Typography> />
<IconButton onClick={() => handleRemoveFile(file)}> </div>
<Clear style={{ color: 'red' }} />{' '} <ol className={`${styles.paperGroup} ${styles.orderedFilesList}`}>
</IconButton> {selectedFiles.length > 0 &&
</li> selectedFiles.map((file, index) => (
))} <div
</ul> key={index}
)} className={`${fileListStyles.fileItem} ${isActive(file) && fileListStyles.active}`}
</Box> onClick={() => {
handleFileClick('file-' + file.name)
<Typography component="label" variant="h6"> setCurrentFile(file)
Add Counterparts }}
</Typography> >
<Box className={styles.inputBlock}> <>
<Box className={styles.inputBlock}> <span className={styles.fileName}>{file.name}</span>
<TextField <Button
label="nip05 / npub" variant="text"
value={userInput} onClick={(event) => handleRemoveFile(event, file)}
onChange={(e) => setUserInput(e.target.value)} sx={{
helperText={error} minWidth: '44px'
error={!!error} }}
/> >
<FormControl fullWidth> <FontAwesomeIcon icon={faTrash} />
<InputLabel id="select-role-label">Role</InputLabel> </Button>
<Select </>
labelId="select-role-label" </div>
id="demo-simple-select" ))}
value={userRole} </ol>
label="Role" <Button variant="contained" onClick={handleUploadButtonClick}>
onChange={(e) => setUserRole(e.target.value as UserRole)} <FontAwesomeIcon icon={faUpload} />
> <span className={styles.uploadFileText}>Upload new files</span>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem> <input
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem> ref={fileInputRef}
</Select> hidden={true}
</FormControl> multiple={true}
type="file"
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}> onChange={handleSelectFiles}
<Button />
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
>
Add
</Button> </Button>
</Box> </div>
</Box> }
</Box> right={
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
<TextField
placeholder="User (nip05 / npub)"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleInputKeyDown}
error={!!error}
fullWidth
sx={{
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
/>
<Select
name="add-user-role"
aria-label="role"
value={userRole}
variant="filled"
// Hide arrow for dropdown
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
color="var(--primary-main)"
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) => setUserRole(e.target.value as UserRole)}
sx={{
fontSize: '16px',
minWidth: '44px',
'& .MuiInputBase-input': {
padding: '7px 14px!important',
textOverflow: 'unset!important'
}
}}
>
<MenuItem value={UserRole.signer}>
<ListItemIcon>
<FontAwesomeIcon icon={faPen} />
</ListItemIcon>
<ListItemText>{UserRole.signer}</ListItemText>
</MenuItem>
<MenuItem value={UserRole.viewer} sx={{}}>
<ListItemIcon>
<FontAwesomeIcon icon={faEye} />
</ListItemIcon>
<ListItemText>{UserRole.viewer}</ListItemText>
</MenuItem>
</Select>
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
aria-label="Add"
sx={{
minWidth: '44px',
padding: '11.5px 12px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
</div>
<DisplayUser <div className={styles.paperGroup}>
metadata={metadata} <DisplayUser
users={users} metadata={metadata}
handleUserRoleChange={handleUserRoleChange} users={users}
handleRemoveUser={handleRemoveUser} handleUserRoleChange={handleUserRoleChange}
moveSigner={moveSigner} handleRemoveUser={handleRemoveUser}
/> moveSigner={moveSigner}
/>
</div>
<DrawPDFFields <Button onClick={handleCreate} variant="contained">
metadata={metadata} Publish
users={users} </Button>
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
/>
<Box sx={{ mt: 1, mb: 5, display: 'flex', justifyContent: 'center' }}> <div className={`${styles.paperGroup} ${styles.toolbox}`}>
<Button onClick={handleCreate} variant="contained"> {toolbox.map((drawTool: DrawTool, index: number) => {
Create return (
</Button> <div
</Box> key={index}
onClick={
drawTool.active
? () => {
handleToolSelect(drawTool)
}
: () => null
}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${!drawTool.active ? styles.comingSoon : ''}
`}
>
{drawTool.icon}
{drawTool.label}
{drawTool.active ? (
<FontAwesomeIcon icon={faEllipsis} />
) : (
<span
style={{
fontSize: '10px'
}}
>
Coming soon
</span>
)}
</div>
)
})}
</div>
{!!error && (
<FormHelperText error={!!error}>{error}</FormHelperText>
)}
</div>
}
>
<DrawPDFFields
metadata={metadata}
users={users}
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
selectedTool={selectedTool}
/>
</StickySideColumns>
</Container> </Container>
</> </>
) )
@ -811,80 +1080,93 @@ const DisplayUser = ({
moveSigner moveSigner
}: DisplayUsersProps) => { }: DisplayUsersProps) => {
return ( return (
<TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}> <>
<Table> <DndProvider backend={HTML5Backend}>
<TableHead> {users
<TableRow> .filter((user) => user.role === UserRole.signer)
<TableCell className={styles.tableHeaderCell}>User</TableCell> .map((user, index) => (
<TableCell className={styles.tableHeaderCell}>Role</TableCell> <SignerRow
<TableCell>Action</TableCell> key={`signer-${index}`}
</TableRow> userMeta={metadata[user.pubkey]}
</TableHead> user={user}
<TableBody> index={index}
<DndProvider backend={HTML5Backend}> moveSigner={moveSigner}
{users handleUserRoleChange={handleUserRoleChange}
.filter((user) => user.role === UserRole.signer) handleRemoveUser={handleRemoveUser}
.map((user, index) => ( />
<SignerRow ))}
key={`signer-${index}`} </DndProvider>
userMeta={metadata[user.pubkey]} {users
user={user} .filter((user) => user.role === UserRole.viewer)
index={index} .map((user, index) => {
moveSigner={moveSigner} const userMeta = metadata[user.pubkey]
handleUserRoleChange={handleUserRoleChange} return (
handleRemoveUser={handleRemoveUser} <div className={styles.user} key={index}>
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/> />
))} </div>
</DndProvider> <Select
{users name={`change-user-role-${user.pubkey}`}
.filter((user) => user.role === UserRole.viewer) aria-label="role"
.map((user, index) => { value={user.role}
const userMeta = metadata[user.pubkey] variant="outlined"
return ( IconComponent={() => null}
<TableRow key={index}> renderValue={(value) => (
<TableCell className={styles.tableCell}> <FontAwesomeIcon
<UserAvatar fontSize={'14px'}
pubkey={user.pubkey} color="var(--primary-main)"
name={ icon={value === UserRole.signer ? faPen : faEye}
userMeta?.display_name || />
userMeta?.name || )}
shorten(hexToNpub(user.pubkey)) onChange={(e) =>
} handleUserRoleChange(e.target.value as UserRole, user.pubkey)
image={userMeta?.picture} }
/> sx={{
</TableCell> fontSize: '16px',
<TableCell className={styles.tableCell}> minWidth: '34px',
<Select maxWidth: '34px',
fullWidth minHeight: '34px',
value={user.role} maxHeight: '34px',
onChange={(e) => '& .MuiInputBase-input': {
handleUserRoleChange( padding: '10px !important',
e.target.value as UserRole, textOverflow: 'unset!important'
user.pubkey },
) '& .MuiOutlinedInput-notchedOutline': {
} display: 'none'
> }
<MenuItem value={UserRole.signer}> }}
{UserRole.signer} >
</MenuItem> <MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}> <MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
{UserRole.viewer} </Select>
</MenuItem> <Tooltip title="Remove User" arrow>
</Select> <Button
</TableCell> onClick={() => handleRemoveUser(user.pubkey)}
<TableCell> sx={{
<Tooltip title="Remove User" arrow> minWidth: '34px',
<IconButton onClick={() => handleRemoveUser(user.pubkey)}> height: '34px',
<Clear style={{ color: 'red' }} /> padding: 0,
</IconButton> color: 'rgba(0, 0, 0, 0.35)',
</Tooltip> '&:hover': {
</TableCell> color: 'white'
</TableRow> }
) }}
})} >
</TableBody> <FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Table> </Button>
</TableContainer> </Tooltip>
</div>
)
})}
</>
) )
} }
@ -988,16 +1270,14 @@ const SignerRow = ({
drag(drop(ref)) drag(drop(ref))
return ( return (
<TableRow <div
sx={{ cursor: 'move', opacity }} className={styles.user}
style={{ cursor: 'move', opacity }}
data-handler-id={handlerId} data-handler-id={handlerId}
ref={ref} ref={ref}
> >
<TableCell <FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
className={styles.tableCell} <div className={styles.avatar}>
sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<DragHandle />
<UserAvatar <UserAvatar
pubkey={user.pubkey} pubkey={user.pubkey}
name={ name={
@ -1007,26 +1287,57 @@ const SignerRow = ({
} }
image={userMeta?.picture} image={userMeta?.picture}
/> />
</TableCell> </div>
<TableCell className={styles.tableCell}> <Select
<Select name={`change-user-role-${user.pubkey}`}
fullWidth aria-label="role"
value={user.role} value={user.role}
onChange={(e) => variant="outlined"
handleUserRoleChange(e.target.value as UserRole, user.pubkey) IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
fontSize={'14px'}
color="var(--primary-main)"
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) =>
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
}
sx={{
fontSize: '16px',
minWidth: '34px',
maxWidth: '34px',
minHeight: '34px',
maxHeight: '34px',
'& .MuiInputBase-input': {
padding: '10px !important',
textOverflow: 'unset!important'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
} }
}}
>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
</Select>
<Tooltip title="Remove User" arrow>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
sx={{
minWidth: '34px',
height: '34px',
padding: 0,
color: 'rgba(0, 0, 0, 0.35)',
'&:hover': {
color: 'white'
}
}}
> >
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem> <FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem> </Button>
</Select> </Tooltip>
</TableCell> </div>
<TableCell>
<Tooltip title="Remove User" arrow>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<Clear style={{ color: 'red' }} />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
) )
} }

View File

@ -1,41 +1,176 @@
@import '../../styles/colors.scss'; @import '../../styles/colors.scss';
.container { .flexWrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: $text-color; gap: 15px;
margin-top: 10px;
gap: 10px;
width: 550px;
max-width: 550px;
.inputBlock {
display: flex;
flex-direction: column;
gap: 25px;
}
} }
.subHeader { .orderedFilesList {
border-bottom: 0.5px solid; counter-reset: item;
} list-style-type: none;
margin: 0;
.tableHeaderCell { li {
border-right: 1px solid rgba(224, 224, 224, 1);
}
.tableCell {
border-right: 1px solid rgba(224, 224, 224, 1);
height: 56px;
.user {
display: flex; display: flex;
align-items: center; align-items: center;
transition: ease 0.4s;
border-radius: 4px;
background: #ffffff;
padding: 7px 10px;
color: rgba(0, 0, 0, 0.5);
min-height: 45px;
cursor: pointer;
gap: 10px; gap: 10px;
.name { &::before {
text-align: center; content: counter(item) ' ';
cursor: pointer; counter-increment: item;
font-size: 14px;
}
:nth-child(1) {
flex-grow: 1;
font-size: 16px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
button {
color: $primary-main;
}
&:hover,
&.active,
&:focus-within {
background: $primary-main;
color: white;
button {
color: white;
}
} }
} }
} }
.uploadFileText {
margin-left: 12px;
}
.paperGroup {
border-radius: 4px;
background: white;
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
// Automatic scrolling if paper-group gets large enough
// used for files on the left and users on the right
max-height: 350px;
overflow-x: hidden;
overflow-y: auto;
}
.inputWrapper {
display: flex;
align-items: center;
height: 34px;
overflow: hidden;
border-radius: 4px;
outline: solid 1px #dddddd;
background: white;
width: 100%;
&:focus-within {
outline-color: $primary-main;
}
}
.user {
display: flex;
gap: 10px;
font-size: 14px;
text-align: start;
justify-content: center;
align-items: center;
a:hover {
text-decoration: none;
}
}
.avatar {
flex-grow: 1;
min-width: 0;
&:first-child {
margin-left: 24px;
}
img {
// Override the default avatar size
width: 30px;
height: 30px;
}
}
.fileName {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-grow: 1;
}
.toolbox {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 15px;
max-height: 450px;
overflow-x: hidden;
overflow-y: auto;
}
.toolItem {
width: 90px;
height: 90px;
transition: ease 0.2s;
display: inline-flex;
flex-direction: column;
gap: 5px;
border-radius: 4px;
padding: 10px 5px 5px 5px;
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
&.selected {
background: $primary-main;
color: white;
}
&:not(.selected) {
&:hover {
background: $primary-light;
color: white;
}
}
&.comingSoon {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@ -225,11 +225,11 @@ export const HomePage = () => {
type="button" type="button"
aria-label="upload files" aria-label="upload files"
> >
<input {...getInputProps()} /> <input id="file-upload" {...getInputProps()} />
{isDragActive ? ( {isDragActive ? (
<p>Drop the files here ...</p> <label htmlFor="file-upload">Drop the files here ...</label>
) : ( ) : (
<p>Click or drag files to upload!</p> <label htmlFor="file-upload">Click or drag files to upload!</label>
)} )}
</button> </button>
<div className={styles.submissions}> <div className={styles.submissions}>

View File

@ -952,7 +952,7 @@ export const SignPage = () => {
return ( return (
<PdfMarking <PdfMarking
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)} files={getCurrentUserFiles(files, currentFileHashes)}
currentUserMarks={currentUserMarks} currentUserMarks={currentUserMarks}
setIsReadyToSign={setIsReadyToSign} setIsReadyToSign={setIsReadyToSign}
setCurrentUserMarks={setCurrentUserMarks} setCurrentUserMarks={setCurrentUserMarks}

View File

@ -1,12 +1,16 @@
import { Box, Button, Tooltip, Typography, useTheme } from '@mui/material' import { Box, Button, Divider, Tooltip, Typography } from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { CreateSignatureEventContent, Meta } from '../../types' import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta
} from '../../types'
import { import {
decryptArrayBuffer, decryptArrayBuffer,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
@ -16,10 +20,10 @@ import {
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
signEventForMetaFile, signEventForMetaFile,
shorten shorten,
getCurrentUserFiles
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Cancel, CheckCircle } from '@mui/icons-material'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { PdfFile } from '../../types/drawing.ts' import { PdfFile } from '../../types/drawing.ts'
@ -27,7 +31,8 @@ import {
addMarks, addMarks,
convertToPdfBlob, convertToPdfBlob,
convertToPdfFile, convertToPdfFile,
groupMarksByPage groupMarksByFileNamePage,
inPx
} from '../../utils/pdf.ts' } from '../../utils/pdf.ts'
import { State } from '../../store/rootReducer.ts' import { State } from '../../store/rootReducer.ts'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
@ -40,13 +45,101 @@ import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx'
import { UserAvatar } from '../../components/UserAvatar/index.tsx' import { UserAvatar } from '../../components/UserAvatar/index.tsx'
import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx' import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx'
import { TooltipChild } from '../../components/TooltipChild.tsx' import { TooltipChild } from '../../components/TooltipChild.tsx'
import FileList from '../../components/FileList'
import { CurrentUserFile } from '../../types/file.ts'
import { Mark } from '../../types/mark.ts'
interface PdfViewProps {
files: CurrentUserFile[]
currentFile: CurrentUserFile | null
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
}
const SlimPdfView = ({
files,
currentFile,
parsedSignatureEvents
}: PdfViewProps) => {
const pdfRefs = useRef<(HTMLDivElement | null)[]>([])
useEffect(() => {
if (currentFile !== null && !!pdfRefs.current[currentFile.id]) {
pdfRefs.current[currentFile.id]?.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
}
}, [currentFile])
return (
<div className={styles.view}>
{files.map((currentUserFile, i) => {
const { hash, filename, pdfFile, id } = currentUserFile
const signatureEvents = Object.keys(parsedSignatureEvents)
if (!hash) return
return (
<>
<div
id={filename}
ref={(el) => (pdfRefs.current[id] = el)}
key={filename}
className={styles.fileWrapper}
>
{pdfFile.pages.map((page, i) => {
const marks: Mark[] = []
signatureEvents.forEach((e) => {
const m = parsedSignatureEvents[
e as `npub1${string}`
].parsedContent?.marks.filter(
(m) => m.pdfFileHash == hash && m.location.page == i
)
if (m) {
marks.push(...m)
}
})
return (
<div className={styles.imageWrapper} key={i}>
<img draggable="false" src={page.image} />
{marks.map((m) => {
return (
<div
className={styles.mark}
key={m.id}
style={{
left: inPx(m.location.left),
top: inPx(m.location.top),
width: inPx(m.location.width),
height: inPx(m.location.height)
}}
>
{m.value}
</div>
)
})}
</div>
)
})}
</div>
{i < files.length - 1 && (
<Divider
sx={{
fontSize: '12px',
color: 'rgba(0,0,0,0.15)'
}}
>
File Separator
</Divider>
)}
</>
)
})}
</div>
)
}
export const VerifyPage = () => { export const VerifyPage = () => {
const theme = useTheme()
const textColor = theme.palette.getContrastText(
theme.palette.background.paper
)
const location = useLocation() const location = useLocation()
/** /**
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json
@ -54,8 +147,15 @@ export const VerifyPage = () => {
*/ */
const { uploadedZip, meta } = location.state || {} const { uploadedZip, meta } = location.state || {}
const { submittedBy, zipUrl, encryptionKey, signers, viewers, fileHashes } = const {
useSigitMeta(meta) submittedBy,
zipUrl,
encryptionKey,
signers,
viewers,
fileHashes,
parsedSignatureEvents
} = useSigitMeta(meta)
const profiles = useSigitProfiles([ const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []), ...(submittedBy ? [submittedBy] : []),
@ -72,6 +172,15 @@ export const VerifyPage = () => {
[key: string]: string | null [key: string]: string | null
}>(fileHashes) }>(fileHashes)
const [files, setFiles] = useState<{ [filename: string]: PdfFile }>({}) const [files, setFiles] = useState<{ [filename: string]: PdfFile }>({})
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
useEffect(() => {
if (Object.entries(files).length > 0) {
const tmp = getCurrentUserFiles(files, fileHashes)
setCurrentFile(tmp[0])
}
}, [fileHashes, files])
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
@ -203,7 +312,6 @@ export const VerifyPage = () => {
} }
} }
console.log('fileHashes :>> ', fileHashes)
setCurrentFileHashes(fileHashes) setCurrentFileHashes(fileHashes)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
@ -307,12 +415,12 @@ export const VerifyPage = () => {
zip.file('meta.json', stringifiedMeta) zip.file('meta.json', stringifiedMeta)
const marks = extractMarksFromSignedMeta(updatedMeta) const marks = extractMarksFromSignedMeta(updatedMeta)
const marksByPage = groupMarksByPage(marks) const marksByPage = groupMarksByFileNamePage(marks)
for (const [fileName, pdf] of Object.entries(files)) { for (const [fileName, pdf] of Object.entries(files)) {
const pages = await addMarks(pdf.file, marksByPage) const pages = await addMarks(pdf.file, marksByPage[fileName])
const blob = await convertToPdfBlob(pages) const blob = await convertToPdfBlob(pages)
zip.file(`/files/${fileName}`, blob) zip.file(`files/${fileName}`, blob)
} }
const arrayBuffer = await zip const arrayBuffer = await zip
@ -414,51 +522,25 @@ export const VerifyPage = () => {
<StickySideColumns <StickySideColumns
left={ left={
<> <>
<Box className={styles.filesWrapper}> {currentFile !== null && (
{Object.entries(currentFileHashes).map( <FileList
([filename, hash], index) => { files={getCurrentUserFiles(files, currentFileHashes)}
const isValidHash = fileHashes[filename] === hash currentFile={currentFile}
setCurrentFile={setCurrentFile}
return ( handleDownload={handleExport}
<Box key={`file-${index}`} className={styles.file}> />
<Typography )}
component="label"
sx={{
color: textColor,
flexGrow: 1
}}
>
{filename}
</Typography>
{isValidHash && (
<Tooltip title="File integrity check passed" arrow>
<CheckCircle
sx={{ color: theme.palette.success.light }}
/>
</Tooltip>
)}
{!isValidHash && (
<Tooltip title="File integrity check failed" arrow>
<Cancel
sx={{ color: theme.palette.error.main }}
/>
</Tooltip>
)}
</Box>
)
}
)}
</Box>
{displayExportedBy()} {displayExportedBy()}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
</> </>
} }
right={<UsersDetails meta={meta} />} right={<UsersDetails meta={meta} />}
/> >
<SlimPdfView
currentFile={currentFile}
files={getCurrentUserFiles(files, currentFileHashes)}
parsedSignatureEvents={parsedSignatureEvents}
/>
</StickySideColumns>
)} )}
</Container> </Container>
</> </>

View File

@ -50,3 +50,36 @@
} }
} }
} }
.view {
width: 550px;
max-width: 550px;
display: flex;
flex-direction: column;
gap: 25px;
}
.imageWrapper {
position: relative;
img {
width: 100%;
display: block;
}
}
.fileWrapper {
display: flex;
flex-direction: column;
gap: 15px;
}
.mark {
position: absolute;
border: 1px dotted black;
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -1,5 +1,6 @@
import { Mark } from './mark' import { Mark } from './mark'
import { Keys } from '../store/auth/types' import { Keys } from '../store/auth/types'
import { Event } from 'nostr-tools'
export enum UserRole { export enum UserRole {
signer = 'Signer', signer = 'Signer',
@ -44,3 +45,7 @@ export interface UserAppData {
keyPair?: Keys // this key pair is used for blossom requests authentication keyPair?: Keys // this key pair is used for blossom requests authentication
blossomUrls: string[] // array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom blossomUrls: string[] // array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom
} }
export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent
}

View File

@ -41,9 +41,22 @@ export interface DrawTool {
} }
export enum MarkType { export enum MarkType {
TEXT = 'TEXT',
SIGNATURE = 'SIGNATURE', SIGNATURE = 'SIGNATURE',
JOBTITLE = 'JOBTITLE', JOBTITLE = 'JOBTITLE',
FULLNAME = 'FULLNAME', FULLNAME = 'FULLNAME',
INITIALS = 'INITIALS',
DATETIME = 'DATETIME',
DATE = 'DATE', DATE = 'DATE',
DATETIME = 'DATETIME' NUMBER = 'NUMBER',
IMAGES = 'IMAGES',
CHECKBOX = 'CHECKBOX',
MULTIPLE = 'MULTIPLE',
FILE = 'FILE',
RADIO = 'RADIO',
SELECT = 'SELECT',
CELLS = 'CELLS',
STAMP = 'STAMP',
PAYMENT = 'PAYMENT',
PHONE = 'PHONE'
} }

View File

@ -5,5 +5,4 @@ export interface CurrentUserFile {
pdfFile: PdfFile pdfFile: PdfFile
filename: string filename: string
hash?: string hash?: string
isHashValid: boolean
} }

View File

@ -1,6 +1,6 @@
import { Meta } from '../types' import { Meta } from '../types'
import { extractMarksFromSignedMeta } from './mark.ts' import { extractMarksFromSignedMeta } from './mark.ts'
import { addMarks, convertToPdfBlob, groupMarksByPage } from './pdf.ts' import { addMarks, convertToPdfBlob, groupMarksByFileNamePage } from './pdf.ts'
import JSZip from 'jszip' import JSZip from 'jszip'
import { PdfFile } from '../types/drawing.ts' import { PdfFile } from '../types/drawing.ts'
@ -10,12 +10,12 @@ const getZipWithFiles = async (
): Promise<JSZip> => { ): Promise<JSZip> => {
const zip = new JSZip() const zip = new JSZip()
const marks = extractMarksFromSignedMeta(meta) const marks = extractMarksFromSignedMeta(meta)
const marksByPage = groupMarksByPage(marks) const marksByFileNamePage = groupMarksByFileNamePage(marks)
for (const [fileName, pdf] of Object.entries(files)) { for (const [fileName, pdf] of Object.entries(files)) {
const pages = await addMarks(pdf.file, marksByPage) const pages = await addMarks(pdf.file, marksByFileNamePage[fileName])
const blob = await convertToPdfBlob(pages) const blob = await convertToPdfBlob(pages)
zip.file(`/files/${fileName}`, blob) zip.file(`files/${fileName}`, blob)
} }
return zip return zip

View File

@ -119,7 +119,11 @@ const getUpdatedMark = (
return { return {
...selectedMark, ...selectedMark,
currentValue: selectedMarkValue, currentValue: selectedMarkValue,
isCompleted: !!selectedMarkValue isCompleted: !!selectedMarkValue,
mark: {
...selectedMark.mark,
value: selectedMarkValue
}
} }
} }

View File

@ -1,6 +1,6 @@
import { CreateSignatureEventContent, Meta } from '../types' import { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.' import { fromUnixTimestamp, parseJson } from '.'
import { Event } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
export enum SignStatus { export enum SignStatus {
@ -75,6 +75,7 @@ export interface SigitCardDisplayInfo {
signers: `npub1${string}`[] signers: `npub1${string}`[]
fileExtensions: string[] fileExtensions: string[]
signedStatus: SigitStatus signedStatus: SigitStatus
isValid: boolean
} }
/** /**
@ -128,12 +129,15 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
const sigitInfo: SigitCardDisplayInfo = { const sigitInfo: SigitCardDisplayInfo = {
signers: [], signers: [],
fileExtensions: [], fileExtensions: [],
signedStatus: SigitStatus.Partial signedStatus: SigitStatus.Partial,
isValid: false
} }
try { try {
const createSignatureEvent = await parseNostrEvent(meta.createSignature) const createSignatureEvent = await parseNostrEvent(meta.createSignature)
sigitInfo.isValid = verifyEvent(createSignatureEvent)
// created_at in nostr events are stored in seconds // created_at in nostr events are stored in seconds
sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at) sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at)
@ -142,7 +146,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
) )
const files = Object.keys(createSignatureContent.fileHashes) const files = Object.keys(createSignatureContent.fileHashes)
const extensions = extractFileExtensions(files) const { extensions } = extractFileExtensions(files)
const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[]
const isCompletelySigned = createSignatureContent.signers.every((signer) => const isCompletelySigned = createSignatureContent.signers.every((signer) =>
@ -169,6 +173,10 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
} }
} }
/**
* @param fileNames - List of filenames to check
* @returns List of extensions and if all are same
*/
export const extractFileExtensions = (fileNames: string[]) => { export const extractFileExtensions = (fileNames: string[]) => {
const extensions = fileNames.reduce((result: string[], file: string) => { const extensions = fileNames.reduce((result: string[], file: string) => {
const extension = file.split('.').pop() const extension = file.split('.').pop()
@ -178,5 +186,15 @@ export const extractFileExtensions = (fileNames: string[]) => {
return result return result
}, []) }, [])
return extensions const isSame = extensions.every((ext) => ext === extensions[0])
return { extensions, isSame }
}
/**
* @param fileName - Filename to check
* @returns Extension string
*/
export const extractFileExtension = (fileName: string) => {
return fileName.split('.').pop()
} }

View File

@ -71,14 +71,19 @@ const isPdf = (file: File) => file.type.toLowerCase().includes('pdf')
/** /**
* Reads the pdf file binaries * Reads the pdf file binaries
*/ */
const readPdf = (file: File): Promise<string> => { const readPdf = (file: File): Promise<string | ArrayBuffer> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e: any) => { reader.onload = (e) => {
const data = e.target.result const data = e.target?.result
// Make sure we only resolve for string or ArrayBuffer type
resolve(data) // They are accepted by PDFJS.getDocument function
if (data && typeof data !== 'undefined') {
resolve(data)
} else {
reject(new Error('File is null or undefined'))
}
} }
reader.onerror = (err) => { reader.onerror = (err) => {
@ -94,7 +99,7 @@ const readPdf = (file: File): Promise<string> => {
* Converts pdf to the images * Converts pdf to the images
* @param data pdf file bytes * @param data pdf file bytes
*/ */
const pdfToImages = async (data: any): Promise<PdfPage[]> => { const pdfToImages = async (data: string | ArrayBuffer): Promise<PdfPage[]> => {
const images: string[] = [] const images: string[] = []
const pdf = await PDFJS.getDocument(data).promise const pdf = await PDFJS.getDocument(data).promise
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
@ -142,7 +147,8 @@ const addMarks = async (
canvas.width = viewport.width canvas.width = viewport.width
await page.render({ canvasContext: context!, viewport: viewport }).promise await page.render({ canvasContext: context!, viewport: viewport }).promise
marksPerPage[i]?.forEach((mark) => draw(mark, context!)) if (marksPerPage && Object.hasOwn(marksPerPage, i))
marksPerPage[i]?.forEach((mark) => draw(mark, context!))
images.push(canvas.toDataURL()) images.push(canvas.toDataURL())
} }
@ -230,11 +236,11 @@ const convertToPdfFile = async (
* @function scaleMark scales remaining marks in line with SCALE * @function scaleMark scales remaining marks in line with SCALE
* @function byPage groups remaining Marks by their page marks.location.page * @function byPage groups remaining Marks by their page marks.location.page
*/ */
const groupMarksByPage = (marks: Mark[]) => { const groupMarksByFileNamePage = (marks: Mark[]) => {
return marks return marks
.filter(hasValue) .filter(hasValue)
.map(scaleMark) .map(scaleMark)
.reduce<{ [key: number]: Mark[] }>(byPage, {}) .reduce<{ [filename: string]: { [page: number]: Mark[] } }>(byPage, {})
} }
/** /**
@ -245,10 +251,21 @@ const groupMarksByPage = (marks: Mark[]) => {
* @param obj - accumulator in the reducer callback * @param obj - accumulator in the reducer callback
* @param mark - current value, i.e. Mark being examined * @param mark - current value, i.e. Mark being examined
*/ */
const byPage = (obj: { [key: number]: Mark[] }, mark: Mark) => { const byPage = (
const key = mark.location.page obj: { [filename: string]: { [page: number]: Mark[] } },
const curGroup = obj[key] ?? [] mark: Mark
return { ...obj, [key]: [...curGroup, mark] } ) => {
const filename = mark.fileName
const pageNumber = mark.location.page
const pages = obj[filename] ?? {}
const marks = pages[pageNumber] ?? []
return {
...obj,
[filename]: {
...pages,
[pageNumber]: [...marks, mark]
}
}
} }
export { export {
@ -259,5 +276,5 @@ export {
convertToPdfFile, convertToPdfFile,
addMarks, addMarks,
convertToPdfBlob, convertToPdfBlob,
groupMarksByPage groupMarksByFileNamePage
} }

View File

@ -72,20 +72,17 @@ export const timeout = (ms: number = 60000) => {
* including its name, hash, and content * including its name, hash, and content
* @param files * @param files
* @param fileHashes * @param fileHashes
* @param creatorFileHashes
*/ */
export const getCurrentUserFiles = ( export const getCurrentUserFiles = (
files: { [filename: string]: PdfFile }, files: { [filename: string]: PdfFile },
fileHashes: { [key: string]: string | null }, fileHashes: { [key: string]: string | null }
creatorFileHashes: { [key: string]: string }
): CurrentUserFile[] => { ): CurrentUserFile[] => {
return Object.entries(files).map(([filename, pdfFile], index) => { return Object.entries(files).map(([filename, pdfFile], index) => {
return { return {
pdfFile, pdfFile,
filename, filename,
id: index + 1, id: index + 1,
...(!!fileHashes[filename] && { hash: fileHashes[filename]! }), ...(!!fileHashes[filename] && { hash: fileHashes[filename]! })
isHashValid: creatorFileHashes[filename] === fileHashes[filename]
} }
}) })
} }