Create page - draft feature, save progress to local storage #320

Open
enes wants to merge 2 commits from 175-local-sigit-draft into staging
11 changed files with 375 additions and 46 deletions

17
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "sigit",
"version": "0.0.0-beta",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sigit",
"version": "0.0.0-beta",
"version": "1.0.3",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later ",
"dependencies": {
@ -51,8 +51,7 @@
"react-toastify": "10.0.4",
"redux": "5.0.1",
"signature_pad": "^5.0.4",
"tseep": "1.2.1",
"use-immer": "^0.11.0"
"tseep": "1.2.1"
},
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",
@ -17265,16 +17264,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/use-immer": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.11.0.tgz",
"integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==",
"license": "MIT",
"peerDependencies": {
"immer": ">=8.0.0",
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",

View File

@ -62,8 +62,7 @@
"react-toastify": "10.0.4",
"redux": "5.0.1",
"signature_pad": "^5.0.4",
"tseep": "1.2.1",
"use-immer": "^0.11.0"
"tseep": "1.2.1"
},
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",

View File

@ -0,0 +1,117 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Tooltip, Button, Divider } from '@mui/material'
import {
faCalendar,
faFile,
faFileCircleExclamation,
faPen,
faTrash
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { SigitDraft, UserRole } from '../../types'
import { appPrivateRoutes } from '../../routes'
import {
formatTimestamp,
getSigitDraft,
npubToHex,
SigitStatus,
SignStatus
} from '../../utils'
import { DisplaySigner } from '../DisplaySigner'
import { UserAvatarGroup } from '../UserAvatarGroup'
import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useAppSelector, useDidMount } from '../../hooks'
import styles from './style.module.scss'
interface LocalDraftSigitProps {
handleDraftDelete: () => void
}
export const LocalDraftSigit = ({
handleDraftDelete
}: LocalDraftSigitProps) => {
const [draft, setDraft] = useState<SigitDraft>()
useDidMount(async () => {
// Check if draft exists and add link to direct
const draft = await getSigitDraft()
if (draft) {
setDraft(draft)
}
})
const submittedBy = useAppSelector((state) => state.auth.usersPubkey)
if (!draft) return null
const extensions = draft.files.map((f) => f.extension)
const isSame = extensions.every((e) => extensions[0] === e)
return (
<div className={styles.itemWrapper}>
<Link className={styles.insetLink} to={appPrivateRoutes.create}></Link>
<p className={`line-clamp-2 ${styles.title}`}>{draft.title}</p>
<div className={styles.users}>
{submittedBy && (
<DisplaySigner status={SignStatus.Pending} pubkey={submittedBy} />
)}
{submittedBy && draft.users.length ? (
<Divider orientation="vertical" flexItem />
) : null}
<UserAvatarGroup max={7}>
{draft.users.map((user) => {
const pubkey = npubToHex(user.pubkey)!
return (
<DisplaySigner
key={pubkey}
status={
user.role === UserRole.signer
? SignStatus.Pending
: SignStatus.Viewer
}
pubkey={pubkey}
/>
)
})}
</UserAvatarGroup>
</div>
<div className={`${styles.details} ${styles.iconLabel}`}>
<FontAwesomeIcon icon={faCalendar} />
{formatTimestamp(draft.lastUpdated)}
</div>
<div className={`${styles.details} ${styles.status}`}>
<span className={styles.iconLabel}>
<FontAwesomeIcon icon={faPen} /> {SigitStatus.LocalDraft}
</span>
{extensions.length > 0 ? (
<span className={styles.iconLabel}>
{!isSame ? (
<>
<FontAwesomeIcon icon={faFile} /> Multiple File Types
</>
) : (
getExtensionIconLabel(extensions[0])
)}
</span>
) : (
<>
<FontAwesomeIcon icon={faFileCircleExclamation} /> &mdash;
</>
)}
</div>
<div className={styles.itemActions}>
<Tooltip title="Delete" arrow placement="top" disableInteractive>
<Button
onClick={handleDraftDelete}
sx={{
color: 'var(--primary-main)',
minWidth: '34px',
padding: '10px'
}}
variant={'text'}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</Tooltip>
</div>
</div>
)
}

View File

@ -18,7 +18,6 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
import { useScale } from '../../hooks/useScale'
import { AvatarIconButton } from '../UserAvatarIconButton'
import { UserAvatar } from '../UserAvatar'
import { Updater } from 'use-immer'
import { FileItem } from './internal/FileItem'
import { FileDivider } from '../FileDivider'
import { Counterpart } from './internal/Counterpart'
@ -28,6 +27,7 @@ const MINIMUM_RECT_SIZE = {
height: 10
} as const
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import _ from 'lodash'
const DEFAULT_START_SIZE = {
width: 140,
@ -45,7 +45,7 @@ interface DrawPdfFieldsProps {
users: User[]
userProfiles: { [key: string]: NDKUserProfile }
sigitFiles: SigitFile[]
updateSigitFiles: Updater<SigitFile[]>
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>>
selectedTool?: DrawTool
}
@ -53,11 +53,10 @@ export const DrawPDFFields = ({
selectedTool,
userProfiles,
sigitFiles,
updateSigitFiles,
setSigitFiles,
users
}: DrawPdfFieldsProps) => {
const { to, from } = useScale()
const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
@ -354,8 +353,10 @@ export const DrawPDFFields = ({
) => {
event.stopPropagation()
updateSigitFiles((draft) => {
draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1)
setSigitFiles((prev) => {
const clone = _.cloneDeep(prev)
clone[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1)
return clone
})
}
@ -416,22 +417,28 @@ export const DrawPDFFields = ({
// Add new drawn field to the files
if (mouseState.clicked) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields.push(field)
setSigitFiles((prev) => {
const clone = _.cloneDeep(prev)
clone[fileIndex].pages![pageIndex].drawnFields.push(field)
return clone
})
}
// Move
if (mouseState.dragging) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
setSigitFiles((prev) => {
const clone = _.cloneDeep(prev)
clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
return clone
})
}
// Resize
if (mouseState.resizing) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
setSigitFiles((prev) => {
const clone = _.cloneDeep(prev)
clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
return clone
})
}
@ -446,7 +453,7 @@ export const DrawPDFFields = ({
mouseState.clicked,
mouseState.dragging,
mouseState.resizing,
updateSigitFiles
setSigitFiles
])
/**

View File

@ -45,7 +45,10 @@ import {
uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises,
uploadMetaToFileStorage
uploadMetaToFileStorage,
clearSigitDraft,
saveSigitDraft,
getSigitDraft
} from '../../utils'
import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss'
@ -77,7 +80,6 @@ import { AvatarIconButton } from '../../components/UserAvatarIconButton'
import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks/useNDKContext.ts'
import { useNDK } from '../../hooks/useNDK.ts'
import { useImmer } from 'use-immer'
import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx'
type FoundUser = NostrEvent & { npub: string }
@ -97,7 +99,9 @@ export const CreatePage = () => {
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles])
const [selectedFiles, setSelectedFiles] = useState<File[]>([
...(uploadedFiles || [])
])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
@ -123,7 +127,7 @@ export const CreatePage = () => {
[key: string]: NDKUserProfile
}>({})
const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([])
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false)
const searchFieldRef = useRef<HTMLInputElement>(null)
@ -283,27 +287,29 @@ export const CreatePage = () => {
selectedFiles,
getSigitFile
)
updateDrawnFiles((draft) => {
setDrawnFiles((prev) => {
const clone = _.cloneDeep(prev)
// Existing files are untouched
// Handle removed files
// Remove in reverse to avoid index issues
for (let i = draft.length - 1; i >= 0; i--) {
for (let i = clone.length - 1; i >= 0; i--) {
if (
!files.some(
(f) => f.name === draft[i].name && f.size === draft[i].size
(f) => f.name === clone[i].name && f.size === clone[i].size
)
) {
draft.splice(i, 1)
clone.splice(i, 1)
}
}
// Add new files
files.forEach((f) => {
if (!draft.some((d) => d.name === f.name && d.size === f.size)) {
draft.push(f)
if (!clone.some((d) => d.name === f.name && d.size === f.size)) {
clone.push(f)
}
})
return clone
})
}
@ -313,7 +319,52 @@ export const CreatePage = () => {
setIsParsing(false)
})
}
}, [selectedFiles, updateDrawnFiles])
}, [selectedFiles])
const [draftEnabled, setDraftEnabled] = useState(true)
useEffect(() => {
// Only proceed if we have no uploaded files
if (uploadedFiles?.length ?? 0) return
getSigitDraft().then((draft) => {
if (draft) {
setSelectedFiles(draft.files)
setDrawnFiles((prev) => {
const clone = _.cloneDeep(prev)
clone.splice(0, clone.length, ...draft.files)
return clone
})
setUsers(draft.users)
setTitle(draft.title)
// After loading draft clear it
clearSigitDraft()
}
})
}, [uploadedFiles])
useEffect(() => {
if (draftEnabled) {
saveSigitDraft({
title,
users,
lastUpdated: Date.now(),
files: drawnFiles
}).catch((error) => {
if (
error instanceof DOMException &&
error.name === 'QuotaExceededError'
) {
// Disable draft if we hit size error
setDraftEnabled(false)
console.warn(
'Draft functionality disabled temporarily. File size exceeds local storage limit.'
)
clearSigitDraft()
}
// Ignore other errors
})
}
}, [draftEnabled, drawnFiles, title, users])
/**
* Changes the drawing tool
@ -504,7 +555,7 @@ export const CreatePage = () => {
})
})
})
updateDrawnFiles(drawnFilesCopy)
setDrawnFiles(drawnFilesCopy)
}
/**
@ -940,6 +991,7 @@ export const CreatePage = () => {
console.error(error)
} finally {
setIsLoading(false)
clearSigitDraft()
}
}
@ -1017,6 +1069,7 @@ export const CreatePage = () => {
console.error(error)
} finally {
setIsLoading(false)
clearSigitDraft()
}
}
@ -1285,7 +1338,7 @@ export const CreatePage = () => {
userProfiles={userProfiles}
selectedTool={selectedTool}
sigitFiles={drawnFiles}
updateSigitFiles={updateDrawnFiles}
setSigitFiles={setDrawnFiles}
/>
{parsingPdf && <LoadingSpinner variant="small" />}
</StickySideColumns>

View File

@ -2,7 +2,7 @@ import { Button, TextField } from '@mui/material'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { useAppSelector } from '../../hooks'
import { useAppSelector, useDidMount } from '../../hooks'
import { appPrivateRoutes } from '../../routes'
import { Meta } from '../../types'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
@ -13,12 +13,15 @@ import { useDropzone } from 'react-dropzone'
import { Container } from '../../components/Container'
import styles from './style.module.scss'
import {
clearSigitDraft,
extractSigitCardDisplayInfo,
hasSigitDraft,
navigateFromZip,
SigitCardDisplayInfo,
SigitStatus
} from '../../utils'
import { Footer } from '../../components/Footer/Footer'
import { LocalDraftSigit } from '../../components/DisplaySigit/LocalDraftSigit'
// Unsupported Filter options are commented
const FILTERS = [
@ -44,6 +47,12 @@ export const HomePage = () => {
const [searchParams, setSearchParams] = useSearchParams()
const q = searchParams.get('q') ?? ''
const [showDraft, setShowDraft] = useState<boolean>(false)
useDidMount(async () => {
// Check if draft exists and add link to direct
setShowDraft(hasSigitDraft())
})
useEffect(() => {
const searchInput = document.getElementById('q') as HTMLInputElement | null
if (searchInput) {
@ -152,7 +161,7 @@ export const HomePage = () => {
meta={sigits[key]}
/>
))
} else {
} else if (!showDraft) {
return (
<div className={styles.noResults}>
<p>No results</p>
@ -260,7 +269,17 @@ export const HomePage = () => {
)}
</button>
<div className={styles.submissions}>{renderSubmissions()}</div>
<div className={styles.submissions}>
{showDraft && (
<LocalDraftSigit
handleDraftDelete={() => {
clearSigitDraft()
setShowDraft(false)
}}
/>
)}
{renderSubmissions()}
</div>
</Container>
<Footer />
</div>

21
src/types/draft.ts Normal file
View File

@ -0,0 +1,21 @@
import { SigitFile } from '../utils/file'
import { User } from './core'
import { DrawnField } from './drawing'
export interface SigitFileDraft {
name: string
file: string
pages: DrawnField[][]
}
export interface SigitDraft {
title: string
users: User[]
files: SigitFile[]
lastUpdated: number
}
export interface SerializedSigitDraft {
title: string
lastUpdated: number
users: User[]
files: SigitFileDraft[]
}

View File

@ -4,3 +4,5 @@ export * from './nostr'
export * from './relay'
export * from './zip'
export * from './event'
export * from './drawing'
export * from './draft'

120
src/utils/draft.ts Normal file
View File

@ -0,0 +1,120 @@
import {
DrawnField,
SerializedSigitDraft,
SigitDraft,
SigitFileDraft
} from '../types'
import {
getMediaType,
extractFileExtension,
toFile,
getSigitFile
} from './file'
const DRAFT_KEY = 'sigitDraft'
let saveSigitDraftTimeout: number | null = null
const serializeSigitDraft = async (
draft: SigitDraft
): Promise<SerializedSigitDraft> => {
const serializedFiles = draft.files.map((file) => {
return new Promise<SigitFileDraft>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const pages = file.pages
? file.pages.map((page) =>
page.drawnFields.map(
(field) =>
({
left: field.left,
top: field.top,
width: field.width,
height: field.height,
type: field.type,
counterpart: field.counterpart
}) as DrawnField
)
)
: []
resolve({
name: file.name,
pages: pages,
file: reader.result as string
})
}
reader.onerror = (error) => reject(error)
reader.readAsDataURL(file)
})
})
const serializedFileDraft = await Promise.all(serializedFiles)
return {
title: draft.title,
lastUpdated: draft.lastUpdated,
users: [...draft.users],
files: serializedFileDraft
}
}
const deserializeSigitDraft = async (
serializedDraft: SerializedSigitDraft
): Promise<SigitDraft> => {
const files = await Promise.all(
serializedDraft.files.map(async (draft) => {
const response = await fetch(draft.file)
const arrayBuffer = await response.arrayBuffer()
const type = getMediaType(extractFileExtension(draft.name))
const file = toFile(arrayBuffer, draft.name, type)
const sigitFile = await getSigitFile(file)
if (draft.pages) {
for (let i = 0; i < draft.pages.length; i++) {
const drawnFields = draft.pages[i]
if (sigitFile.pages) sigitFile.pages[i].drawnFields = [...drawnFields]
}
}
return sigitFile
})
)
return {
...serializedDraft,
files: files
}
}
export const saveSigitDraft = (draft: SigitDraft): Promise<void> => {
if (saveSigitDraftTimeout) {
clearTimeout(saveSigitDraftTimeout)
}
return new Promise((resolve, reject) => {
saveSigitDraftTimeout = window.setTimeout(() => {
serializeSigitDraft(draft)
.then((draftToSave) => {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draftToSave))
resolve()
})
.catch((error) => {
reject(error)
})
}, 1000)
})
}
export const hasSigitDraft = () => {
return DRAFT_KEY in localStorage
}
export const getSigitDraft = async () => {
const sigitDraft = localStorage.getItem(DRAFT_KEY)
if (!sigitDraft) return null
try {
const serializedDraft = JSON.parse(sigitDraft) as SerializedSigitDraft
return await deserializeSigitDraft(serializedDraft)
} catch {
return null
}
}
export const clearSigitDraft = () => {
localStorage.removeItem(DRAFT_KEY)
}

View File

@ -12,3 +12,4 @@ export * from './string'
export * from './url'
export * from './utils'
export * from './zip'
export * from './draft'

View File

@ -31,7 +31,8 @@ export enum SignStatus {
export enum SigitStatus {
Partial = 'In-Progress',
Complete = 'Completed'
Complete = 'Completed',
LocalDraft = 'Draft'
}
export interface SigitCardDisplayInfo {