feat(create-page): new create page design #153

Merged
enes merged 17 commits from 148-create-page into staging 2024-08-20 12:43:20 +00:00
17 changed files with 939 additions and 558 deletions

View File

@ -7,7 +7,7 @@
"scripts": {
"dev": "vite",
"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 2",
"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",
"formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",

View File

@ -1,40 +1,27 @@
import {
AccessTime,
CalendarMonth,
ExpandMore,
Gesture,
PictureAsPdf,
Badge,
Work,
Close
} from '@mui/icons-material'
import { Close } from '@mui/icons-material'
import {
Box,
Typography,
Accordion,
AccordionDetails,
AccordionSummary,
CircularProgress,
Divider,
FormControl,
InputLabel,
MenuItem,
Select
} from '@mui/material'
import styles from './style.module.scss'
import { useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import * as PDFJS from 'pdfjs-dist'
import { ProfileMetadata, User } from '../../types'
import { ProfileMetadata, User, UserRole } from '../../types'
import {
PdfFile,
DrawTool,
MouseState,
PdfPage,
DrawnField,
MarkType
DrawTool
} from '../../types/drawing'
import { truncate } from 'lodash'
import { hexToNpub } from '../../utils'
import { extractFileExtension, hexToNpub } from '../../utils'
import { toPdfFiles } from '../../utils/pdf.ts'
PDFJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@ -46,64 +33,31 @@ interface Props {
users: User[]
metadata: { [key: string]: ProfileMetadata }
onDrawFieldsChange: (pdfFiles: PdfFile[]) => void
selectedTool?: DrawTool
}
export const DrawPDFFields = (props: Props) => {
const { selectedFiles, onDrawFieldsChange, users } = props
const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props
const [pdfFiles, setPdfFiles] = useState<PdfFile[]>([])
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>({
clicked: false
})
useEffect(() => {
/**
* 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)
}
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)
parsePdfPages().finally(() => {
@ -148,11 +102,7 @@ export const DrawPDFFields = (props: Props) => {
// Proceed only if left click
if (event.button !== 0) return
// Only allow drawing if mouse is not over other drawn element
const target = event.target as HTMLElement
const isOverPdfImageWrapper = target.tagName === 'IMG'
if (!selectedTool || !isOverPdfImageWrapper) {
if (!selectedTool) {
return
}
@ -204,8 +154,14 @@ export const DrawPDFFields = (props: Props) => {
) => {
if (mouseState.clicked && selectedTool) {
const lastElementIndex = page.drawnFields.length - 1
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 width = mouseX - lastDrawnField.left
@ -255,15 +211,14 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @param drawnField which we are moving
*/
const onDranwFieldMouseMove = (
const onDrawnFieldMouseMove = (
event: React.MouseEvent<HTMLDivElement>,
drawnField: DrawnField
) => {
const target = event.target as HTMLElement | null
if (mouseState.dragging) {
const { mouseX, mouseY, rect } = getMouseCoordinates(
event,
target?.parentNode as HTMLElement
event.currentTarget.parentElement
)
const coordsOffset = mouseState.coordsInWrapper
@ -292,7 +247,9 @@ export const DrawPDFFields = (props: Props) => {
* @param event Mouse event
* @param drawnField which we are resizing
*/
const onResizeHandleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
const onResizeHandleMouseDown = (
event: React.MouseEvent<HTMLSpanElement>
) => {
// Proceed only if left click
if (event.button !== 0) return
@ -309,14 +266,16 @@ export const DrawPDFFields = (props: Props) => {
* @param drawnField which we are resizing
*/
const onResizeHandleMouseMove = (
event: React.MouseEvent<HTMLSpanElement | HTMLDivElement>,
event: React.MouseEvent<HTMLSpanElement>,
drawnField: DrawnField
) => {
const target = event.target as HTMLElement | null
if (mouseState.resizing) {
const { mouseX, mouseY } = getMouseCoordinates(
event,
target?.parentNode?.parentNode as HTMLElement
// currentTarget = span handle
// 1st parent = drawnField
// 2nd parent = img
event.currentTarget.parentElement?.parentElement
)
const width = mouseX - drawnField.left
@ -337,7 +296,7 @@ export const DrawPDFFields = (props: Props) => {
* @param drawnFileIndex drawn file index
*/
const onRemoveHandleMouseDown = (
event: React.MouseEvent<HTMLDivElement | HTMLSpanElement>,
event: React.MouseEvent<HTMLSpanElement>,
pdfFileIndex: number,
pdfPageIndex: number,
drawnFileIndex: number
@ -368,10 +327,10 @@ export const DrawPDFFields = (props: Props) => {
* event.target will be used
*/
const getMouseCoordinates = (
event: React.MouseEvent<HTMLDivElement | HTMLSpanElement>,
customTarget?: HTMLElement
event: React.MouseEvent<HTMLElement>,
customTarget?: HTMLElement | null
) => {
const target = (customTarget ? customTarget : event.target) as HTMLElement
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect()
const mouseX = event.clientX - rect.left //x position within the element.
const mouseY = event.clientY - rect.top //y position within the element.
@ -383,64 +342,26 @@ export const DrawPDFFields = (props: Props) => {
}
}
/**
*
* @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
*/
const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => {
return (
<Box
sx={{
width: '100%'
}}
>
<>
{pdfFile.pages.map((page, pdfPageIndex: number) => {
return (
<div
key={pdfPageIndex}
style={{
border: '1px solid #c4c4c4',
marginBottom: '10px'
}}
className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onMouseDown={(event) => {
onMouseDown(event, page)
}}
>
<img
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onMouseDown={(event) => {
onMouseDown(event, page)
}}
draggable="false"
style={{ width: '100%' }}
src={page.image}
/>
@ -450,7 +371,7 @@ export const DrawPDFFields = (props: Props) => {
key={drawnFieldIndex}
onMouseDown={onDrawnFieldMouseDown}
onMouseMove={(event) => {
onDranwFieldMouseMove(event, drawnField)
onDrawnFieldMouseMove(event, drawnField)
}}
className={styles.drawingRectangle}
style={{
@ -496,36 +417,38 @@ export const DrawPDFFields = (props: Props) => {
labelId="counterparts"
label="Counterparts"
>
{users.map((user, index) => {
let displayValue = truncate(
hexToNpub(user.pubkey),
{
length: 16
}
)
const metadata = props.metadata[user.pubkey]
if (metadata) {
displayValue = truncate(
metadata.name ||
metadata.display_name ||
metadata.username,
{users
.filter((u) => u.role === UserRole.signer)
.map((user, index) => {
let displayValue = truncate(
hexToNpub(user.pubkey),
{
length: 16
}
)
}
return (
<MenuItem
key={index}
value={hexToNpub(user.pubkey)}
>
{displayValue}
</MenuItem>
)
})}
const metadata = props.metadata[user.pubkey]
if (metadata) {
displayValue = truncate(
metadata.name ||
metadata.display_name ||
metadata.username,
{
length: 16
}
)
}
return (
<MenuItem
key={index}
value={hexToNpub(user.pubkey)}
>
{displayValue}
</MenuItem>
)
})}
</Select>
</FormControl>
</div>
@ -535,7 +458,7 @@ export const DrawPDFFields = (props: Props) => {
</div>
)
})}
</Box>
</>
)
}
@ -552,57 +475,38 @@ export const DrawPDFFields = (props: Props) => {
}
return (
<Box>
<Box sx={{ mt: 1 }}>
<Typography sx={{ mb: 1 }}>Draw fields on the PDFs:</Typography>
{pdfFiles.map((pdfFile, pdfFileIndex: number) => {
return (
<Accordion
key={pdfFileIndex}
expanded={pdfFile.expanded}
onChange={(_event, expanded) => {
handleAccordionExpandChange(expanded, pdfFile)
}}
<div className={styles.view}>
{selectedFiles.map((file, i) => {
const name = file.name
const extension = extractFileExtension(name)
const pdfFile = pdfFiles.find((pdf) => pdf.file.name === name)
return (
<React.Fragment key={name}>
<div
className={`${styles.fileWrapper} ${styles.scrollTarget}`}
id={`file-${name}`}
>
<AccordionSummary
expandIcon={<ExpandMore />}
aria-controls={`panel${pdfFileIndex}-content`}
id={`panel${pdfFileIndex}header`}
{pdfFile ? (
getPdfPages(pdfFile, i)
) : (
<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 }} />
{pdfFile.file.name}
</AccordionSummary>
<AccordionDetails>
{getPdfPages(pdfFile, pdfFileIndex)}
</AccordionDetails>
</Accordion>
)
})}
</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>
File Separator
</Divider>
)}
</React.Fragment>
)
})}
</div>
)
}

View File

@ -1,3 +1,5 @@
@import '../../styles/sizes.scss';
.pdfFieldItem {
background: white;
padding: 10px;
@ -5,53 +7,10 @@
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 {
position: relative;
-webkit-user-select: none;
user-select: none;
margin-bottom: 10px;
> img {
display: block;
@ -81,7 +40,7 @@
}
&.edited {
border: 1px dotted #01aaad
border: 1px dotted #01aaad;
}
.resizeHandle {
@ -93,7 +52,14 @@
background-color: #fff;
border: 1px solid rgb(160, 160, 160);
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 {
@ -124,3 +90,29 @@
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

@ -17,8 +17,8 @@
ul {
list-style-type: none; /* Removes bullet points */
margin: 0; /* Removes default margin */
padding: 0; /* Removes default padding */
margin: 0; /* Removes default margin */
padding: 0; /* Removes default padding */
}
li {
@ -27,7 +27,6 @@ li {
padding: 0; /* Removes any default padding */
}
.wrap {
display: flex;
flex-direction: column;
@ -50,7 +49,7 @@ li {
}
.files::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0.15);
background-color: rgba(0, 0, 0, 0.15);
}
.files::-webkit-scrollbar-thumb {
@ -70,12 +69,12 @@ li {
background: #ffffff;
padding: 5px 10px;
align-items: center;
color: rgba(0,0,0,0.5);
color: rgba(0, 0, 0, 0.5);
cursor: pointer;
flex-grow: 1;
font-size: 16px;
font-weight: 500;
min-height: 45px;
&.active {
background: #4c82a3;
@ -84,7 +83,6 @@ li {
}
.fileItem:hover {
transition: ease 0.2s;
background: #4c82a3;
color: white;
}
@ -100,6 +98,7 @@ li {
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
line-clamp: 1;
}
.fileNumber {
@ -120,4 +119,4 @@ li {
align-items: center;
height: 25px;
width: 25px;
}
}

View File

@ -8,6 +8,39 @@
left: 0;
align-items: center;
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 {
@ -19,7 +52,7 @@
flex-direction: column;
align-items: center;
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;
&.expanded {
@ -73,7 +106,7 @@
.textInput {
height: 100px;
background: rgba(0,0,0,0.1);
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
border: solid 2px #4c82a3;
display: flex;
@ -84,17 +117,19 @@
.input {
border-radius: 4px;
border: solid 1px rgba(0,0,0,0.15);
border: solid 1px rgba(0, 0, 0, 0.15);
padding: 5px 10px;
font-size: 16px;
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 {
border: solid 1px rgba(0,0,0,0.15);
border: solid 1px rgba(0, 0, 0, 0.15);
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 {
@ -105,41 +140,6 @@
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 {
width: 100%;
max-width: 300px;
@ -172,18 +172,18 @@ button:active {
font-size: 12px;
padding: 5px 10px;
border-radius: 3px;
background: rgba(0,0,0,0.1);
color: rgba(0,0,0,0.5);
background: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.5);
}
.paginationButton:hover {
background: #447592;
color: rgba(255,255,255,0.5);
color: rgba(255, 255, 255, 0.5);
}
.paginationButtonDone {
background: #5e8eab;
color: rgb(255,255,255);
color: rgb(255, 255, 255);
}
.paginationButtonCurrent {
@ -204,7 +204,7 @@ button:active {
background: white;
color: #434343;
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;
top: -25px;
}

View File

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

View File

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

View File

@ -10,6 +10,9 @@
.sidesWrap {
position: relative;
// HACK: Stop grid column from growing
min-width: 0;
}
.sides {

View File

@ -1,27 +1,16 @@
import { Clear, DragHandle } from '@mui/icons-material'
import {
Box,
Button,
FormControl,
IconButton,
InputLabel,
FormHelperText,
ListItemIcon,
ListItemText,
MenuItem,
Paper,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography
Tooltip
} from '@mui/material'
import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd'
@ -61,14 +50,46 @@ import {
} from '../../utils'
import { Container } from '../../components/Container'
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 { 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 = () => {
const navigate = useNavigate()
const location = useLocation()
const { uploadedFiles } = location.state || {}
const [currentFile, setCurrentFile] = useState<File>()
const isActive = (file: File) => file.name === currentFile?.name
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -76,9 +97,22 @@ export const CreatePage = () => {
const [authUrl, setAuthUrl] = useState<string>()
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
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 [error, setError] = useState<string>()
@ -93,6 +127,132 @@ export const CreatePage = () => {
)
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(() => {
users.forEach((user) => {
if (!(user.pubkey in metadata)) {
@ -268,19 +428,22 @@ export const CreatePage = () => {
})
}
const handleSelectFiles = (files: File[]) => {
setSelectedFiles((prev) => {
const prevFileNames = prev.map((file) => file.name)
const newFiles = files.filter(
(file) => !prevFileNames.includes(file.name)
)
return [...prev, ...newFiles]
})
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
setSelectedFiles(Array.from(event.target.files))
}
}
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) =>
prevFiles.filter((file) => file.name !== fileToRemove.name)
)
@ -702,94 +865,200 @@ export const CreatePage = () => {
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}>
<TextField
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
variant="outlined"
/>
<Box>
<MuiFileInput
fullWidth
multiple
placeholder="Choose Files"
value={selectedFiles}
onChange={(value) => handleSelectFiles(value)}
/>
{selectedFiles.length > 0 && (
<ul>
{selectedFiles.map((file, index) => (
<li key={index}>
<Typography component="label">{file.name}</Typography>
<IconButton onClick={() => handleRemoveFile(file)}>
<Clear style={{ color: 'red' }} />{' '}
</IconButton>
</li>
))}
</ul>
)}
</Box>
<Typography component="label" variant="h6">
Add Counterparts
</Typography>
<Box className={styles.inputBlock}>
<Box className={styles.inputBlock}>
<TextField
label="nip05 / npub"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
helperText={error}
error={!!error}
/>
<FormControl fullWidth>
<InputLabel id="select-role-label">Role</InputLabel>
<Select
labelId="select-role-label"
id="demo-simple-select"
value={userRole}
label="Role"
onChange={(e) => setUserRole(e.target.value as UserRole)}
>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
</Select>
</FormControl>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
>
Add
<StickySideColumns
left={
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
<TextField
placeholder="Title"
size="small"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{
width: '100%',
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
/>
</div>
<ol className={`${styles.paperGroup} ${styles.orderedFilesList}`}>
{selectedFiles.length > 0 &&
selectedFiles.map((file, index) => (
<div
key={index}
className={`${fileListStyles.fileItem} ${isActive(file) && fileListStyles.active}`}
onClick={() => {
handleFileClick('file-' + file.name)
setCurrentFile(file)
}}
>
<>
<span className={styles.fileName}>{file.name}</span>
<Button
variant="text"
onClick={(event) => handleRemoveFile(event, file)}
sx={{
minWidth: '44px'
}}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</>
</div>
))}
</ol>
<Button variant="contained" onClick={handleUploadButtonClick}>
<FontAwesomeIcon icon={faUpload} />
<span className={styles.uploadFileText}>Upload new files</span>
<input
ref={fileInputRef}
hidden={true}
multiple={true}
type="file"
onChange={handleSelectFiles}
/>
</Button>
</Box>
</Box>
</Box>
</div>
}
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
metadata={metadata}
users={users}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/>
<div className={styles.paperGroup}>
<DisplayUser
metadata={metadata}
users={users}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/>
</div>
<DrawPDFFields
metadata={metadata}
users={users}
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
/>
<Button onClick={handleCreate} variant="contained">
Publish
</Button>
<Box sx={{ mt: 1, mb: 5, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleCreate} variant="contained">
Create
</Button>
</Box>
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
{toolbox.map((drawTool: DrawTool, index: number) => {
return (
<div
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>
</>
)
@ -811,80 +1080,93 @@ const DisplayUser = ({
moveSigner
}: DisplayUsersProps) => {
return (
<TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}>
<Table>
<TableHead>
<TableRow>
<TableCell className={styles.tableHeaderCell}>User</TableCell>
<TableCell className={styles.tableHeaderCell}>Role</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
<DndProvider backend={HTML5Backend}>
{users
.filter((user) => user.role === UserRole.signer)
.map((user, index) => (
<SignerRow
key={`signer-${index}`}
userMeta={metadata[user.pubkey]}
user={user}
index={index}
moveSigner={moveSigner}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
<>
<DndProvider backend={HTML5Backend}>
{users
.filter((user) => user.role === UserRole.signer)
.map((user, index) => (
<SignerRow
key={`signer-${index}`}
userMeta={metadata[user.pubkey]}
user={user}
index={index}
moveSigner={moveSigner}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
/>
))}
</DndProvider>
{users
.filter((user) => user.role === UserRole.viewer)
.map((user, index) => {
const userMeta = metadata[user.pubkey]
return (
<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}
/>
))}
</DndProvider>
{users
.filter((user) => user.role === UserRole.viewer)
.map((user, index) => {
const userMeta = metadata[user.pubkey]
return (
<TableRow key={index}>
<TableCell className={styles.tableCell}>
<UserAvatar
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/>
</TableCell>
<TableCell className={styles.tableCell}>
<Select
fullWidth
value={user.role}
onChange={(e) =>
handleUserRoleChange(
e.target.value as UserRole,
user.pubkey
)
}
>
<MenuItem value={UserRole.signer}>
{UserRole.signer}
</MenuItem>
<MenuItem value={UserRole.viewer}>
{UserRole.viewer}
</MenuItem>
</Select>
</TableCell>
<TableCell>
<Tooltip title="Remove User" arrow>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<Clear style={{ color: 'red' }} />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
</div>
<Select
name={`change-user-role-${user.pubkey}`}
aria-label="role"
value={user.role}
variant="outlined"
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'
}
}}
>
<FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Button>
</Tooltip>
</div>
)
})}
</>
)
}
@ -988,16 +1270,14 @@ const SignerRow = ({
drag(drop(ref))
return (
<TableRow
sx={{ cursor: 'move', opacity }}
<div
className={styles.user}
style={{ cursor: 'move', opacity }}
data-handler-id={handlerId}
ref={ref}
>
<TableCell
className={styles.tableCell}
sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<DragHandle />
<FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
@ -1007,26 +1287,57 @@ const SignerRow = ({
}
image={userMeta?.picture}
/>
</TableCell>
<TableCell className={styles.tableCell}>
<Select
fullWidth
value={user.role}
onChange={(e) =>
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
</div>
<Select
name={`change-user-role-${user.pubkey}`}
aria-label="role"
value={user.role}
variant="outlined"
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>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
</Select>
</TableCell>
<TableCell>
<Tooltip title="Remove User" arrow>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<Clear style={{ color: 'red' }} />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
<FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Button>
</Tooltip>
</div>
)
}

View File

@ -1,41 +1,176 @@
@import '../../styles/colors.scss';
.container {
.flexWrap {
display: flex;
flex-direction: column;
color: $text-color;
margin-top: 10px;
gap: 10px;
width: 550px;
max-width: 550px;
.inputBlock {
display: flex;
flex-direction: column;
gap: 25px;
}
gap: 15px;
}
.subHeader {
border-bottom: 0.5px solid;
}
.orderedFilesList {
counter-reset: item;
list-style-type: none;
margin: 0;
.tableHeaderCell {
border-right: 1px solid rgba(224, 224, 224, 1);
}
.tableCell {
border-right: 1px solid rgba(224, 224, 224, 1);
height: 56px;
.user {
li {
display: flex;
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;
.name {
text-align: center;
cursor: pointer;
&::before {
content: counter(item) ' ';
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"
aria-label="upload files"
>
<input {...getInputProps()} />
<input id="file-upload" {...getInputProps()} />
{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>
<div className={styles.submissions}>

View File

@ -236,9 +236,11 @@ export const SignPage = () => {
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
} catch (error: any) {
} catch (error) {
console.log('error in zip:>> ', error)
toast.error(error.message || 'Error occurred in generating zip file')
if (error instanceof Error) {
toast.error(error.message || 'Error occurred in generating zip file')
}
}
}

View File

@ -141,7 +141,7 @@ export const DisplayMeta = ({
})
}
})
}, [users, submittedBy])
}, [users, submittedBy, metadata])
const downloadFile = async (filename: string) => {
const arrayBuffer = await files[filename].file.arrayBuffer()

View File

@ -41,9 +41,22 @@ export interface DrawTool {
}
export enum MarkType {
TEXT = 'TEXT',
SIGNATURE = 'SIGNATURE',
JOBTITLE = 'JOBTITLE',
FULLNAME = 'FULLNAME',
INITIALS = 'INITIALS',
DATETIME = 'DATETIME',
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

@ -1,18 +1,26 @@
export class DecryptionError extends Error {
public message: string = ''
constructor(public inputError: any) {
constructor(public inputError: unknown) {
super()
if (inputError.message.toLowerCase().includes('expected')) {
this.message = `The decryption key length or format is invalid.`
} else if (
inputError.message.includes('The JWK "alg" member was inconsistent')
) {
this.message = `The decryption key is invalid.`
// Make sure inputError has access to the .message
if (inputError instanceof Error) {
if (inputError.message.toLowerCase().includes('expected')) {
this.message = `The decryption key length or format is invalid.`
} else if (
inputError.message.includes('The JWK "alg" member was inconsistent')
) {
this.message = `The decryption key is invalid.`
} else {
this.message =
inputError.message || 'An error occurred while decrypting file.'
}
} else {
// We don't have message on the inputError
// Stringify whole error and set that as a message
this.message =
inputError.message || 'An error occurred while decrypting file.'
JSON.stringify(inputError) || 'An error occurred while decrypting file.'
}
this.name = 'DecryptionError'

View File

@ -190,3 +190,11 @@ export const extractFileExtensions = (fileNames: string[]) => {
return { extensions, isSame }
}
/**
* @param fileName - Filename to check
* @returns Extension string
*/
export const extractFileExtension = (fileName: string) => {
return fileName.split('.').pop()
}

View File

@ -37,9 +37,11 @@ const readContentOfZipEntry = async <T extends OutputType>(
const loadZip = async (data: InputFileFormat): Promise<JSZip | null> => {
try {
return await JSZip.loadAsync(data)
} catch (err: any) {
} catch (err) {
console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.')
if (err instanceof Error) {
toast.error(err.message || 'An error occurred in loading zip file.')
}
return null
}
}