sigit.io/src/pages/create/index.tsx

1116 lines
32 KiB
TypeScript
Raw Normal View History

import { DragHandle } from '@mui/icons-material'
2024-05-14 09:27:05 +00:00
import {
Button,
IconButton,
ListItem,
ListItemIcon,
ListItemText,
2024-05-14 09:27:05 +00:00
MenuItem,
Select,
TextField,
Tooltip
2024-05-14 09:27:05 +00:00
} from '@mui/material'
2024-06-28 09:24:14 +00:00
import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
2024-05-16 11:22:05 +00:00
import JSZip from 'jszip'
2024-06-28 09:24:14 +00:00
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
2024-06-28 09:24:14 +00:00
import { DndProvider, useDrag, useDrop } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
2024-05-16 11:22:05 +00:00
import { useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom'
2024-05-16 11:22:05 +00:00
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar'
2024-05-14 09:27:05 +00:00
import { MetadataController, NostrController } from '../../controllers'
2024-05-16 11:22:05 +00:00
import { appPrivateRoutes } from '../../routes'
import { State } from '../../store/rootReducer'
2024-07-05 08:38:04 +00:00
import {
CreateSignatureEventContent,
Meta,
ProfileMetadata,
User,
UserRole
} from '../../types'
import {
encryptArrayBuffer,
2024-07-08 20:16:47 +00:00
formatTimestamp,
generateEncryptionKey,
2024-06-28 09:24:14 +00:00
generateKeys,
generateKeysFile,
getHash,
2024-05-14 09:27:05 +00:00
hexToNpub,
isOnline,
unixNow,
npubToHex,
2024-05-14 09:27:05 +00:00
queryNip05,
2024-06-28 09:24:14 +00:00
sendNotification,
2024-05-14 09:27:05 +00:00
shorten,
signEventForMetaFile,
2024-06-28 09:24:14 +00:00
updateUsersAppData,
uploadToFileStorage
} from '../../utils'
2024-08-06 09:47:48 +00:00
import { Container } from '../../components/Container'
2024-05-14 09:27:05 +00:00
import styles from './style.module.scss'
import { 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 {
faEye,
faPen,
faPlus,
faTrash,
faUpload
} from '@fortawesome/free-solid-svg-icons'
2024-05-15 08:41:55 +00:00
export const CreatePage = () => {
const navigate = useNavigate()
const location = useLocation()
const { uploadedFiles } = location.state || {}
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
2024-05-14 09:27:05 +00:00
const [authUrl, setAuthUrl] = useState<string>()
2024-07-08 20:16:47 +00:00
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
2024-05-14 09:27:05 +00:00
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
2024-05-14 09:27:05 +00:00
const [userInput, setUserInput] = useState('')
const [userRole, setUserRole] = useState<UserRole>(UserRole.signer)
const [error, setError] = useState<string>()
const [users, setUsers] = useState<User[]>([])
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const [drawnPdfs, setDrawnPdfs] = useState<PdfFile[]>([])
useEffect(() => {
users.forEach((user) => {
if (!(user.pubkey in metadata)) {
const metadataController = new MetadataController()
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[user.pubkey]: metadataContent
}))
}
metadataController.on(user.pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(user.pubkey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${user.pubkey}`,
err
)
})
}
})
}, [metadata, users])
2024-06-28 09:24:14 +00:00
// Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
useEffect(() => {
if (uploadedFiles) {
setSelectedFiles([...uploadedFiles])
}
}, [uploadedFiles])
2024-05-15 08:41:55 +00:00
useEffect(() => {
if (usersPubkey) {
setUsers((prev) => {
const existingUserIndex = prev.findIndex(
(user) => user.pubkey === usersPubkey
)
// make logged in user the first signer by default
if (existingUserIndex === -1)
return [{ pubkey: usersPubkey, role: UserRole.signer }, ...prev]
return prev
})
}
}, [usersPubkey])
2024-05-14 09:27:05 +00:00
const handleAddUser = async () => {
setError(undefined)
2024-05-14 09:27:05 +00:00
const addUser = (pubkey: string) => {
setUsers((prev) => {
2024-05-20 08:28:46 +00:00
const signers = prev.filter((user) => user.role === UserRole.signer)
const viewers = prev.filter((user) => user.role === UserRole.viewer)
2024-05-14 09:27:05 +00:00
const existingUserIndex = prev.findIndex(
(user) => user.pubkey === pubkey
)
2024-05-14 09:27:05 +00:00
// add new
2024-05-20 08:28:46 +00:00
if (existingUserIndex === -1) {
if (userRole === UserRole.signer) {
return [...signers, { pubkey, role: userRole }, ...viewers]
} else {
return [...signers, ...viewers, { pubkey, role: userRole }]
}
}
2024-05-14 09:27:05 +00:00
const existingUser = prev[existingUserIndex]
2024-05-14 09:27:05 +00:00
// return existing
if (existingUser.role === userRole) return prev
2024-05-14 09:27:05 +00:00
// change user role
const updatedUsers = [...prev]
const updatedUser = { ...updatedUsers[existingUserIndex] }
updatedUser.role = userRole
updatedUsers[existingUserIndex] = updatedUser
2024-05-20 08:28:46 +00:00
// signers should be placed at the start of the array
return [
...updatedUsers.filter((user) => user.role === UserRole.signer),
...updatedUsers.filter((user) => user.role === UserRole.viewer)
]
2024-05-14 09:27:05 +00:00
})
}
const input = userInput.toLowerCase()
if (input.startsWith('npub')) {
const pubkey = npubToHex(input)
2024-05-14 09:27:05 +00:00
if (pubkey) {
addUser(pubkey)
setUserInput('')
} else {
setError('Provided npub is not valid. Please enter correct npub.')
}
return
}
if (input.includes('@')) {
2024-05-14 09:27:05 +00:00
setIsLoading(true)
setLoadingSpinnerDesc('Querying for nip05')
const nip05Profile = await queryNip05(input)
2024-05-14 09:27:05 +00:00
.catch((err) => {
console.error(`error occurred in querying nip05: ${input}`, err)
2024-05-14 09:27:05 +00:00
return null
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
if (nip05Profile && nip05Profile.pubkey) {
2024-05-14 09:27:05 +00:00
const pubkey = nip05Profile.pubkey
addUser(pubkey)
setUserInput('')
} else {
setError('Provided nip05 is not valid. Please enter correct nip05.')
}
return
}
setError('Invalid input! Make sure to provide correct npub or nip05.')
}
const handleUserRoleChange = (role: UserRole, pubkey: string) => {
setUsers((prevUsers) =>
prevUsers.map((user) => {
if (user.pubkey === pubkey) {
return {
...user,
role
}
}
return user
})
)
2024-05-14 09:27:05 +00:00
}
const handleRemoveUser = (pubkey: string) => {
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey))
}
2024-05-20 07:19:53 +00:00
/**
* changes the position of signer in the signers list
*
* @param dragIndex represents the current position of user
* @param hoverIndex represents the target position of user
*/
const moveSigner = (dragIndex: number, hoverIndex: number) => {
setUsers((prevUsers) => {
const updatedUsers = [...prevUsers]
const [draggedUser] = updatedUsers.splice(dragIndex, 1)
updatedUsers.splice(hoverIndex, 0, draggedUser)
return updatedUsers
})
}
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
setSelectedFiles(Array.from(event.target.files))
}
}
2024-05-14 09:27:05 +00:00
const handleFileClick = (name: string) => {
document.getElementById(name)?.scrollIntoView({ behavior: 'smooth' })
2024-05-14 09:27:05 +00:00
}
const handleRemoveFile = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
fileToRemove: File
) => {
event.stopPropagation()
2024-05-14 09:27:05 +00:00
setSelectedFiles((prevFiles) =>
prevFiles.filter((file) => file.name !== fileToRemove.name)
2024-04-22 11:24:50 +00:00
)
2024-05-14 09:27:05 +00:00
}
2024-04-22 11:24:50 +00:00
// Validate inputs before proceeding
const validateInputs = (): boolean => {
2024-06-03 17:59:51 +00:00
if (!title.trim()) {
toast.error('Title can not be empty')
return false
2024-06-03 17:59:51 +00:00
}
2024-05-14 09:27:05 +00:00
if (users.length === 0) {
2024-04-22 11:24:50 +00:00
toast.error(
'No signer/viewer is provided. At least add one signer or viewer.'
2024-04-22 11:24:50 +00:00
)
return false
2024-05-14 09:27:05 +00:00
}
2024-04-22 11:24:50 +00:00
2024-05-14 09:27:05 +00:00
if (selectedFiles.length === 0) {
toast.error('No file is selected. Select at least 1 file')
return false
2024-05-14 09:27:05 +00:00
}
return true
}
// Handle errors during file arrayBuffer conversion
const handleFileError = (file: File) => (err: unknown) => {
console.log(
`Error while getting arrayBuffer of file ${file.name} :>> `,
err
)
if (err instanceof Error) {
toast.error(
err.message || `Error while getting arrayBuffer of file ${file.name}`
)
}
return null
}
// Generate hash for each selected file
const generateFileHashes = async (): Promise<{
[key: string]: string
} | null> => {
const fileHashes: { [key: string]: string } = {}
2024-05-14 09:27:05 +00:00
for (const file of selectedFiles) {
const arraybuffer = await file.arrayBuffer().catch(handleFileError(file))
if (!arraybuffer) return null
2024-05-14 09:27:05 +00:00
const hash = await getHash(arraybuffer)
if (!hash) {
return null
}
2024-05-14 09:27:05 +00:00
fileHashes[file.name] = hash
}
return fileHashes
}
2024-08-14 09:24:15 +00:00
const createMarks = (fileHashes: { [key: string]: string }): Mark[] => {
return drawnPdfs
.flatMap((drawnPdf) => {
const fileHash = fileHashes[drawnPdf.file.name]
return drawnPdf.pages.flatMap((page, index) => {
return page.drawnFields.map((drawnField) => {
return {
type: drawnField.type,
location: {
page: index,
top: drawnField.top,
left: drawnField.left,
height: drawnField.height,
width: drawnField.width
},
npub: drawnField.counterpart,
pdfFileHash: fileHash,
fileName: drawnPdf.file.name
}
})
})
})
.map((mark, index) => {
2024-08-14 09:24:15 +00:00
return { ...mark, id: index }
})
}
// Handle errors during zip file generation
const handleZipError = (err: unknown) => {
console.log('Error in zip:>> ', err)
setIsLoading(false)
if (err instanceof Error) {
toast.error(err.message || 'Error occurred in generating zip file')
}
return null
}
2024-04-22 11:24:50 +00:00
// Generate the zip file
const generateZipFile = async (zip: JSZip): Promise<ArrayBuffer | null> => {
2024-05-14 09:27:05 +00:00
setLoadingSpinnerDesc('Generating zip file')
2024-04-22 11:24:50 +00:00
return await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
}
// Encrypt the zip file with the generated encryption key
const encryptZipFile = async (
arraybuffer: ArrayBuffer,
encryptionKey: string
): Promise<ArrayBuffer> => {
setLoadingSpinnerDesc('Encrypting zip file')
return encryptArrayBuffer(arraybuffer, encryptionKey)
}
2024-07-05 08:38:04 +00:00
// create final zip file for offline mode
const createFinalZipFile = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
): Promise<File | null> => {
// Get the current timestamp in seconds
const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed.sigit`, {
type: 'application/sigit'
})
const firstSigner = users.filter((user) => user.role === UserRole.signer)[0]
const keysFileContent = await generateKeysFile(
[firstSigner.pubkey],
encryptionKey
)
if (!keysFileContent) return null
const zip = new JSZip()
zip.file(`compressed.sigit`, file)
zip.file('keys.json', keysFileContent)
const arraybuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
if (!arraybuffer) return null
2024-08-14 09:24:15 +00:00
return new File([new Blob([arraybuffer])], `${unixNow}.sigit.zip`, {
type: 'application/zip'
})
}
// Handle errors during file upload
const handleUploadError = (err: unknown) => {
console.log('Error in upload:>> ', err)
setIsLoading(false)
if (err instanceof Error) {
toast.error(err.message || 'Error occurred in uploading file')
}
return null
}
2024-06-12 14:44:06 +00:00
// Upload the file to the storage
2024-07-05 08:38:04 +00:00
const uploadFile = async (
arrayBuffer: ArrayBuffer
): Promise<string | null> => {
const blob = new Blob([arrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed-${unixNow()}.sigit`, {
2024-07-05 08:38:04 +00:00
type: 'application/sigit'
})
return await uploadToFileStorage(file)
.then((url) => {
2024-07-05 08:38:04 +00:00
toast.success('files.zip uploaded to file storage')
return url
})
.catch(handleUploadError)
}
2024-05-14 09:27:05 +00:00
// Manage offline scenarios for signing or viewing the file
2024-06-12 14:44:06 +00:00
const handleOfflineFlow = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
) => {
const finalZipFile = await createFinalZipFile(
encryptedArrayBuffer,
encryptionKey
)
if (!finalZipFile) {
setIsLoading(false)
return
}
2024-06-12 14:44:06 +00:00
saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
2024-07-05 08:38:04 +00:00
const generateFilesZip = async (): Promise<ArrayBuffer | null> => {
const zip = new JSZip()
selectedFiles.forEach((file) => {
zip.file(file.name, file)
})
return await zip
2024-07-05 08:38:04 +00:00
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
}
const generateCreateSignature = async (
fileHashes: {
[key: string]: string
},
zipUrl: string
) => {
const signers = users.filter((user) => user.role === UserRole.signer)
const viewers = users.filter((user) => user.role === UserRole.viewer)
const markConfig = createMarks(fileHashes)
2024-07-05 08:38:04 +00:00
const content: CreateSignatureEventContent = {
signers: signers.map((signer) => hexToNpub(signer.pubkey)),
viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)),
fileHashes,
markConfig,
2024-07-05 08:38:04 +00:00
zipUrl,
title
}
setLoadingSpinnerDesc('Signing nostr event for create signature')
2024-07-08 09:26:36 +00:00
const createSignature = await signEventForMetaFile(
JSON.stringify(content),
nostrController,
setIsLoading
).catch(() => {
console.log('An error occurred in signing event for meta file', error)
toast.error('An error occurred in signing event for meta file')
return null
})
2024-07-05 08:38:04 +00:00
2024-07-08 09:26:36 +00:00
if (!createSignature) return null
2024-07-05 08:38:04 +00:00
2024-07-08 09:26:36 +00:00
return JSON.stringify(createSignature, null, 2)
2024-07-05 08:38:04 +00:00
}
// Send notifications to signers and viewers
const sendNotifications = (meta: Meta) => {
const signers = users.filter((user) => user.role === UserRole.signer)
const viewers = users.filter((user) => user.role === UserRole.viewer)
// no need to send notification to self so remove it from the list
const receivers = (
signers.length > 0
? [signers[0].pubkey]
: viewers.map((viewer) => viewer.pubkey)
).filter((receiver) => receiver !== usersPubkey)
2024-08-14 09:24:15 +00:00
return receivers.map((receiver) => sendNotification(receiver, meta))
2024-07-05 08:38:04 +00:00
}
const handleCreate = async () => {
if (!validateInputs()) return
setIsLoading(true)
2024-07-05 08:38:04 +00:00
setLoadingSpinnerDesc('Generating file hashes')
const fileHashes = await generateFileHashes()
2024-07-05 08:38:04 +00:00
if (!fileHashes) {
setIsLoading(false)
return
}
2024-07-05 08:38:04 +00:00
setLoadingSpinnerDesc('Generating encryption key')
2024-06-28 09:24:14 +00:00
const encryptionKey = await generateEncryptionKey()
2024-07-05 08:38:04 +00:00
if (await isOnline()) {
setLoadingSpinnerDesc('generating files.zip')
const arrayBuffer = await generateFilesZip()
if (!arrayBuffer) {
setIsLoading(false)
return
}
2024-07-05 08:38:04 +00:00
setLoadingSpinnerDesc('Encrypting files.zip')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
)
2024-07-05 08:38:04 +00:00
setLoadingSpinnerDesc('Uploading files.zip to file storage')
const fileUrl = await uploadFile(encryptedArrayBuffer)
if (!fileUrl) {
setIsLoading(false)
return
}
2024-07-05 08:38:04 +00:00
setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature(fileHashes, fileUrl)
if (!createSignature) {
setIsLoading(false)
return
}
2024-07-05 08:38:04 +00:00
setLoadingSpinnerDesc('Generating keys for decryption')
2024-07-05 08:38:04 +00:00
// generate key pairs for decryption
const pubkeys = users.map((user) => user.pubkey)
// also add creator in the list
if (pubkeys.includes(usersPubkey!)) {
pubkeys.push(usersPubkey!)
}
2024-07-05 08:38:04 +00:00
const keys = await generateKeys(pubkeys, encryptionKey)
if (!keys) {
setIsLoading(false)
return
}
const meta: Meta = {
createSignature,
keys,
modifiedAt: unixNow(),
2024-07-05 08:38:04 +00:00
docSignatures: {}
}
setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta)
if (!event) {
setIsLoading(false)
return
}
setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications(meta)
await Promise.all(promises)
.then(() => {
toast.success('Notifications sent successfully')
})
.catch(() => {
toast.error('Failed to publish notifications')
})
navigate(appPrivateRoutes.sign, { state: { meta: meta } })
2024-06-12 14:44:06 +00:00
} else {
2024-07-05 08:38:04 +00:00
const zip = new JSZip()
selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file)
})
setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature(fileHashes, '')
2024-07-08 09:26:36 +00:00
if (!createSignature) {
setIsLoading(false)
return
}
2024-07-05 08:38:04 +00:00
const meta: Meta = {
createSignature,
modifiedAt: unixNow(),
2024-07-05 08:38:04 +00:00
docSignatures: {}
}
// add meta to zip
try {
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
} catch (err) {
console.error(err)
toast.error('An error occurred in converting meta json to string')
return null
}
const arrayBuffer = await generateZipFile(zip)
if (!arrayBuffer) {
setIsLoading(false)
return
}
2024-07-05 08:38:04 +00:00
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
)
2024-06-12 14:44:06 +00:00
await handleOfflineFlow(encryptedArrayBuffer, encryptionKey)
}
}
const onDrawFieldsChange = (pdfFiles: PdfFile[]) => {
setDrawnPdfs(pdfFiles)
}
if (authUrl) {
return (
<iframe
2024-05-15 08:50:21 +00:00
title="Nsecbunker auth"
src={authUrl}
2024-05-15 08:50:21 +00:00
width="100%"
height="500px"
/>
)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
2024-08-06 09:46:42 +00:00
<Container className={styles.container}>
<StickySideColumns
left={
2024-08-16 09:08:03 +00:00
<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}`}>
2024-08-16 09:08:03 +00:00
{selectedFiles.length > 0 &&
selectedFiles.map((file, index) => (
<Button
key={index}
className={styles.file}
onClick={() => handleFileClick('file-' + file.name)}
>
<>
<span>{file.name}</span>
<Button
variant="text"
onClick={(event) => handleRemoveFile(event, file)}
sx={{
minWidth: '44px'
}}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</>
</Button>
2024-08-16 09:08:03 +00:00
))}
</ol>
<Button variant="contained" onClick={handleUploadButtonClick}>
<FontAwesomeIcon icon={faUpload} />
Upload new files
<input
ref={fileInputRef}
hidden={true}
multiple={true}
type="file"
onChange={handleSelectFiles}
/>
</Button>
2024-08-16 09:08:03 +00:00
</div>
}
right={
2024-08-16 09:08:03 +00:00
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
2024-08-16 09:08:03 +00:00
<TextField
placeholder="User (nip05 / npub)"
2024-08-16 09:08:03 +00:00
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
helperText={error}
error={!!error}
fullWidth
sx={{
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
2024-08-16 09:08:03 +00:00
/>
<Select
aria-label="role"
value={userRole}
variant="filled"
// Hide arrow for dropdown
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
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}>
<ListItem>
<ListItemIcon>
<FontAwesomeIcon icon={faPen} />
</ListItemIcon>
<ListItemText>{UserRole.signer}</ListItemText>
</ListItem>
</MenuItem>
<MenuItem value={UserRole.viewer}>
<ListItem>
<ListItemIcon>
<FontAwesomeIcon icon={faEye} />
</ListItemIcon>
<ListItemText>{UserRole.viewer}</ListItemText>
</ListItem>
</MenuItem>
</Select>
2024-08-16 09:08:03 +00:00
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
aria-label="Add"
sx={{
minWidth: '44px',
padding: '11.5px 12px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
2024-08-16 09:08:03 +00:00
>
<FontAwesomeIcon icon={faPlus} />
2024-08-16 09:08:03 +00:00
</Button>
</div>
2024-08-16 09:08:03 +00:00
<div className={styles.paperGroup}>
<DisplayUser
metadata={metadata}
users={users}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/>
</div>
<Button onClick={handleCreate} variant="contained">
Publish
</Button>
2024-08-16 09:08:03 +00:00
</div>
}
>
<DrawPDFFields
metadata={metadata}
users={users}
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
/>
</StickySideColumns>
2024-08-06 09:46:42 +00:00
</Container>
</>
)
}
2024-05-14 09:27:05 +00:00
type DisplayUsersProps = {
metadata: { [key: string]: ProfileMetadata }
2024-05-14 09:27:05 +00:00
users: User[]
handleUserRoleChange: (role: UserRole, pubkey: string) => void
2024-05-14 09:27:05 +00:00
handleRemoveUser: (pubkey: string) => void
moveSigner: (dragIndex: number, hoverIndex: number) => void
2024-05-14 09:27:05 +00:00
}
const DisplayUser = ({
metadata,
2024-05-14 09:27:05 +00:00
users,
handleUserRoleChange,
handleRemoveUser,
moveSigner
2024-05-14 09:27:05 +00:00
}: DisplayUsersProps) => {
return (
<>
<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}
/>
</div>
<Select
aria-label="role"
value={user.role}
variant="outlined"
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) =>
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
}
sx={{
fontSize: '16px',
minWidth: '44px',
'& .MuiInputBase-input': {
padding: '7px 14px!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>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)
})}
</>
2024-05-14 09:27:05 +00:00
)
}
interface DragItem {
index: number
id: string
type: string
}
type SignerRowProps = {
userMeta: ProfileMetadata
user: User
index: number
moveSigner: (dragIndex: number, hoverIndex: number) => void
handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void
}
const SignerRow = ({
userMeta,
user,
index,
moveSigner,
handleUserRoleChange,
handleRemoveUser
}: SignerRowProps) => {
const ref = useRef<HTMLTableRowElement>(null)
const [{ handlerId }, drop] = useDrop<
DragItem,
void,
{ handlerId: Identifier | null }
>({
accept: 'row',
collect(monitor) {
return {
handlerId: monitor.getHandlerId()
}
},
hover(item: DragItem, monitor) {
if (!ref.current) {
return
}
const dragIndex = item.index
const hoverIndex = index
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect()
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
// Determine mouse position
const clientOffset = monitor.getClientOffset()
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return
}
// Time to actually perform the action
moveSigner(dragIndex, hoverIndex)
// Note: we're mutating the monitor item here!
// Generally it's better to avoid mutations,
// but it's good here for the sake of performance
// to avoid expensive index searches.
item.index = hoverIndex
}
})
const [{ isDragging }, drag] = useDrag({
type: 'row',
item: () => {
return { id: user.pubkey, index }
},
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
})
const opacity = isDragging ? 0 : 1
drag(drop(ref))
return (
<div
className={styles.user}
style={{ cursor: 'move', opacity }}
data-handler-id={handlerId}
ref={ref}
>
<DragHandle />
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/>
</div>
<Select
aria-label="role"
value={user.role}
variant="outlined"
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon icon={value === UserRole.signer ? faPen : faEye} />
)}
onChange={(e) =>
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
}
sx={{
fontSize: '16px',
minWidth: '44px',
'& .MuiInputBase-input': {
padding: '7px 14px!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>
<IconButton onClick={() => handleRemoveUser(user.pubkey)}>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)
}