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

Open
enes wants to merge 2 commits from 175-local-sigit-draft into staging
9 changed files with 254 additions and 59 deletions
Showing only changes of commit 9f4a891d50 - Show all commits

17
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "sigit", "name": "sigit",
"version": "0.0.0-beta", "version": "1.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sigit", "name": "sigit",
"version": "0.0.0-beta", "version": "1.0.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later ", "license": "AGPL-3.0-or-later ",
"dependencies": { "dependencies": {
@ -51,8 +51,7 @@
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4", "signature_pad": "^5.0.4",
"tseep": "1.2.1", "tseep": "1.2.1"
"use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0", "@saithodev/semantic-release-gitea": "^2.1.0",
@ -17265,16 +17264,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/use-sync-external-store": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", "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", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4", "signature_pad": "^5.0.4",
"tseep": "1.2.1", "tseep": "1.2.1"
"use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0", "@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 { useScale } from '../../hooks/useScale'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { UserAvatar } from '../UserAvatar' import { UserAvatar } from '../UserAvatar'
import { Updater } from 'use-immer'
import { FileItem } from './internal/FileItem' import { FileItem } from './internal/FileItem'
import { FileDivider } from '../FileDivider' import { FileDivider } from '../FileDivider'
import { Counterpart } from './internal/Counterpart' import { Counterpart } from './internal/Counterpart'
@ -28,6 +27,7 @@ const MINIMUM_RECT_SIZE = {
height: 10 height: 10
} as const } as const
import { NDKUserProfile } from '@nostr-dev-kit/ndk' import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import _ from 'lodash'
const DEFAULT_START_SIZE = { const DEFAULT_START_SIZE = {
width: 140, width: 140,
@ -45,7 +45,7 @@ interface DrawPdfFieldsProps {
users: User[] users: User[]
userProfiles: { [key: string]: NDKUserProfile } userProfiles: { [key: string]: NDKUserProfile }
sigitFiles: SigitFile[] sigitFiles: SigitFile[]
updateSigitFiles: Updater<SigitFile[]> setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>>
selectedTool?: DrawTool selectedTool?: DrawTool
} }
@ -53,11 +53,10 @@ export const DrawPDFFields = ({
selectedTool, selectedTool,
userProfiles, userProfiles,
sigitFiles, sigitFiles,
updateSigitFiles, setSigitFiles,
users users
}: DrawPdfFieldsProps) => { }: DrawPdfFieldsProps) => {
const { to, from } = useScale() const { to, from } = useScale()
const signers = users.filter((u) => u.role === UserRole.signer) const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : '' const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
const [lastSigner, setLastSigner] = useState(defaultSignerNpub) const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
@ -354,8 +353,10 @@ export const DrawPDFFields = ({
) => { ) => {
event.stopPropagation() event.stopPropagation()
updateSigitFiles((draft) => { setSigitFiles((prev) => {
draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1) 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 // Add new drawn field to the files
if (mouseState.clicked) { if (mouseState.clicked) {
updateSigitFiles((draft) => { setSigitFiles((prev) => {
draft[fileIndex].pages![pageIndex].drawnFields.push(field) const clone = _.cloneDeep(prev)
clone[fileIndex].pages![pageIndex].drawnFields.push(field)
return clone
}) })
} }
// Move // Move
if (mouseState.dragging) { if (mouseState.dragging) {
updateSigitFiles((draft) => { setSigitFiles((prev) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field const clone = _.cloneDeep(prev)
clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
return clone
}) })
} }
// Resize // Resize
if (mouseState.resizing) { if (mouseState.resizing) {
updateSigitFiles((draft) => { setSigitFiles((prev) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field const clone = _.cloneDeep(prev)
clone[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
return clone
}) })
} }
@ -446,7 +453,7 @@ export const DrawPDFFields = ({
mouseState.clicked, mouseState.clicked,
mouseState.dragging, mouseState.dragging,
mouseState.resizing, mouseState.resizing,
updateSigitFiles setSigitFiles
]) ])
/** /**

View File

@ -45,7 +45,10 @@ import {
uploadToFileStorage, uploadToFileStorage,
DEFAULT_TOOLBOX, DEFAULT_TOOLBOX,
settleAllFullfilfedPromises, settleAllFullfilfedPromises,
uploadMetaToFileStorage uploadMetaToFileStorage,
clearSigitDraft,
saveSigitDraft,
getSigitDraft
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss' 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 { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks/useNDKContext.ts' import { useNDKContext } from '../../hooks/useNDKContext.ts'
import { useNDK } from '../../hooks/useNDK.ts' import { useNDK } from '../../hooks/useNDK.ts'
import { useImmer } from 'use-immer'
import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx' import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx'
type FoundUser = NostrEvent & { npub: string } type FoundUser = NostrEvent & { npub: string }
@ -97,7 +99,9 @@ export const CreatePage = () => {
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) 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 fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => { const handleUploadButtonClick = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
@ -123,7 +127,7 @@ export const CreatePage = () => {
[key: string]: NDKUserProfile [key: string]: NDKUserProfile
}>({}) }>({})
const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([]) const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false) const [parsingPdf, setIsParsing] = useState<boolean>(false)
const searchFieldRef = useRef<HTMLInputElement>(null) const searchFieldRef = useRef<HTMLInputElement>(null)
@ -283,27 +287,29 @@ export const CreatePage = () => {
selectedFiles, selectedFiles,
getSigitFile getSigitFile
) )
updateDrawnFiles((draft) => { setDrawnFiles((prev) => {
const clone = _.cloneDeep(prev)
// Existing files are untouched // Existing files are untouched
// Handle removed files // Handle removed files
// Remove in reverse to avoid index issues // 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 ( if (
!files.some( !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 // Add new files
files.forEach((f) => { files.forEach((f) => {
if (!draft.some((d) => d.name === f.name && d.size === f.size)) { if (!clone.some((d) => d.name === f.name && d.size === f.size)) {
draft.push(f) clone.push(f)
} }
}) })
return clone
}) })
} }
@ -313,7 +319,52 @@ export const CreatePage = () => {
setIsParsing(false) 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 * 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) console.error(error)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
clearSigitDraft()
} }
} }
@ -1017,6 +1069,7 @@ export const CreatePage = () => {
console.error(error) console.error(error)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
clearSigitDraft()
} }
} }
@ -1285,7 +1338,7 @@ export const CreatePage = () => {
userProfiles={userProfiles} userProfiles={userProfiles}
selectedTool={selectedTool} selectedTool={selectedTool}
sigitFiles={drawnFiles} sigitFiles={drawnFiles}
updateSigitFiles={updateDrawnFiles} setSigitFiles={setDrawnFiles}
/> />
{parsingPdf && <LoadingSpinner variant="small" />} {parsingPdf && <LoadingSpinner variant="small" />}
</StickySideColumns> </StickySideColumns>

View File

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

View File

@ -11,9 +11,11 @@ export interface SigitDraft {
title: string title: string
users: User[] users: User[]
files: SigitFile[] files: SigitFile[]
lastUpdated: number
} }
export interface SerializedSigitDraft { export interface SerializedSigitDraft {
title: string title: string
lastUpdated: number
users: User[] users: User[]
files: SigitFileDraft[] files: SigitFileDraft[]
} }

View File

@ -10,7 +10,7 @@ import {
toFile, toFile,
getSigitFile getSigitFile
} from './file' } from './file'
const DRAFT_KEY = 'sigitDraft'
let saveSigitDraftTimeout: number | null = null let saveSigitDraftTimeout: number | null = null
const serializeSigitDraft = async ( const serializeSigitDraft = async (
draft: SigitDraft draft: SigitDraft
@ -48,6 +48,7 @@ const serializeSigitDraft = async (
const serializedFileDraft = await Promise.all(serializedFiles) const serializedFileDraft = await Promise.all(serializedFiles)
return { return {
title: draft.title, title: draft.title,
lastUpdated: draft.lastUpdated,
users: [...draft.users], users: [...draft.users],
files: serializedFileDraft files: serializedFileDraft
} }
@ -79,24 +80,31 @@ const deserializeSigitDraft = async (
} }
} }
export const saveSigitDraft = (draft: SigitDraft) => { export const saveSigitDraft = (draft: SigitDraft): Promise<void> => {
if (saveSigitDraftTimeout) { if (saveSigitDraftTimeout) {
clearTimeout(saveSigitDraftTimeout) clearTimeout(saveSigitDraftTimeout)
} }
saveSigitDraftTimeout = window.setTimeout(() => { return new Promise((resolve, reject) => {
serializeSigitDraft(draft) saveSigitDraftTimeout = window.setTimeout(() => {
.then((draftToSave) => { serializeSigitDraft(draft)
localStorage.setItem('sigitDraft', JSON.stringify(draftToSave)) .then((draftToSave) => {
}) localStorage.setItem(DRAFT_KEY, JSON.stringify(draftToSave))
.catch((error) => { resolve()
console.log(`Error while saving sigit draft. Error: `, error) })
}) .catch((error) => {
}, 1000) reject(error)
})
}, 1000)
})
}
export const hasSigitDraft = () => {
return DRAFT_KEY in localStorage
} }
export const getSigitDraft = async () => { export const getSigitDraft = async () => {
const sigitDraft = localStorage.getItem('sigitDraft') const sigitDraft = localStorage.getItem(DRAFT_KEY)
if (!sigitDraft) return null if (!sigitDraft) return null
try { try {
@ -108,5 +116,5 @@ export const getSigitDraft = async () => {
} }
export const clearSigitDraft = () => { export const clearSigitDraft = () => {
localStorage.removeItem('sigitDraft') localStorage.removeItem(DRAFT_KEY)
} }

View File

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