From 13b88516cac858dd481ba4974509de5b35dffdb1 Mon Sep 17 00:00:00 2001 From: en Date: Mon, 17 Feb 2025 19:06:19 +0100 Subject: [PATCH 1/2] feat(draft): serialize sigit and save/load to local storage --- src/types/draft.ts | 19 ++++++++ src/types/index.ts | 2 + src/utils/draft.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++ src/utils/index.ts | 1 + 4 files changed, 134 insertions(+) create mode 100644 src/types/draft.ts create mode 100644 src/utils/draft.ts diff --git a/src/types/draft.ts b/src/types/draft.ts new file mode 100644 index 0000000..900d87e --- /dev/null +++ b/src/types/draft.ts @@ -0,0 +1,19 @@ +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[] +} +export interface SerializedSigitDraft { + title: string + users: User[] + files: SigitFileDraft[] +} diff --git a/src/types/index.ts b/src/types/index.ts index 5c5b715..40b240b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,5 @@ export * from './nostr' export * from './relay' export * from './zip' export * from './event' +export * from './drawing' +export * from './draft' diff --git a/src/utils/draft.ts b/src/utils/draft.ts new file mode 100644 index 0000000..4e9ae56 --- /dev/null +++ b/src/utils/draft.ts @@ -0,0 +1,112 @@ +import { + DrawnField, + SerializedSigitDraft, + SigitDraft, + SigitFileDraft +} from '../types' +import { + getMediaType, + extractFileExtension, + toFile, + getSigitFile +} from './file' + +let saveSigitDraftTimeout: number | null = null +const serializeSigitDraft = async ( + draft: SigitDraft +): Promise => { + const serializedFiles = draft.files.map((file) => { + return new Promise((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, + users: [...draft.users], + files: serializedFileDraft + } +} + +const deserializeSigitDraft = async ( + serializedDraft: SerializedSigitDraft +): Promise => { + 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) => { + if (saveSigitDraftTimeout) { + clearTimeout(saveSigitDraftTimeout) + } + + saveSigitDraftTimeout = window.setTimeout(() => { + serializeSigitDraft(draft) + .then((draftToSave) => { + localStorage.setItem('sigitDraft', JSON.stringify(draftToSave)) + }) + .catch((error) => { + console.log(`Error while saving sigit draft. Error: `, error) + }) + }, 1000) +} + +export const getSigitDraft = async () => { + const sigitDraft = localStorage.getItem('sigitDraft') + if (!sigitDraft) return null + + try { + const serializedDraft = JSON.parse(sigitDraft) as SerializedSigitDraft + return await deserializeSigitDraft(serializedDraft) + } catch { + return null + } +} + +export const clearSigitDraft = () => { + localStorage.removeItem('sigitDraft') +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 274ceab..9c4464a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,3 +12,4 @@ export * from './string' export * from './url' export * from './utils' export * from './zip' +export * from './draft' -- 2.34.1 From 9f4a891d5002532b72e9e068a85bd809491aeeef Mon Sep 17 00:00:00 2001 From: en Date: Tue, 18 Feb 2025 16:56:40 +0100 Subject: [PATCH 2/2] feat(create): add local draft, save progress to local storage Closes #175 --- package-lock.json | 17 +-- package.json | 3 +- .../DisplaySigit/LocalDraftSigit.tsx | 117 ++++++++++++++++++ src/components/DrawPDFFields/index.tsx | 33 +++-- src/pages/create/index.tsx | 79 ++++++++++-- src/pages/home/index.tsx | 25 +++- src/types/draft.ts | 2 + src/utils/draft.ts | 34 +++-- src/utils/meta.ts | 3 +- 9 files changed, 254 insertions(+), 59 deletions(-) create mode 100644 src/components/DisplaySigit/LocalDraftSigit.tsx diff --git a/package-lock.json b/package-lock.json index 6235019..9e327f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3081a75..d650959 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/DisplaySigit/LocalDraftSigit.tsx b/src/components/DisplaySigit/LocalDraftSigit.tsx new file mode 100644 index 0000000..f59da16 --- /dev/null +++ b/src/components/DisplaySigit/LocalDraftSigit.tsx @@ -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() + 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 ( +
+ +

{draft.title}

+
+ {submittedBy && ( + + )} + {submittedBy && draft.users.length ? ( + + ) : null} + + {draft.users.map((user) => { + const pubkey = npubToHex(user.pubkey)! + return ( + + ) + })} + +
+
+ + {formatTimestamp(draft.lastUpdated)} +
+
+ + {SigitStatus.LocalDraft} + + {extensions.length > 0 ? ( + + {!isSame ? ( + <> + Multiple File Types + + ) : ( + getExtensionIconLabel(extensions[0]) + )} + + ) : ( + <> + — + + )} +
+
+ + + +
+
+ ) +} diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 71fef1c..ee39939 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -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 + setSigitFiles: React.Dispatch> 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 ]) /** diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index d5424a9..60011b7 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -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([...uploadedFiles]) + const [selectedFiles, setSelectedFiles] = useState([ + ...(uploadedFiles || []) + ]) const fileInputRef = useRef(null) const handleUploadButtonClick = () => { if (fileInputRef.current) { @@ -123,7 +127,7 @@ export const CreatePage = () => { [key: string]: NDKUserProfile }>({}) - const [drawnFiles, updateDrawnFiles] = useImmer([]) + const [drawnFiles, setDrawnFiles] = useState([]) const [parsingPdf, setIsParsing] = useState(false) const searchFieldRef = useRef(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 && } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index abd3b4e..44779b4 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -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(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 (

No results

@@ -260,7 +269,17 @@ export const HomePage = () => { )} -
{renderSubmissions()}
+
+ {showDraft && ( + { + clearSigitDraft() + setShowDraft(false) + }} + /> + )} + {renderSubmissions()} +
diff --git a/src/types/draft.ts b/src/types/draft.ts index 900d87e..23709a7 100644 --- a/src/types/draft.ts +++ b/src/types/draft.ts @@ -11,9 +11,11 @@ export interface SigitDraft { title: string users: User[] files: SigitFile[] + lastUpdated: number } export interface SerializedSigitDraft { title: string + lastUpdated: number users: User[] files: SigitFileDraft[] } diff --git a/src/utils/draft.ts b/src/utils/draft.ts index 4e9ae56..e9ba239 100644 --- a/src/utils/draft.ts +++ b/src/utils/draft.ts @@ -10,7 +10,7 @@ import { toFile, getSigitFile } from './file' - +const DRAFT_KEY = 'sigitDraft' let saveSigitDraftTimeout: number | null = null const serializeSigitDraft = async ( draft: SigitDraft @@ -48,6 +48,7 @@ const serializeSigitDraft = async ( const serializedFileDraft = await Promise.all(serializedFiles) return { title: draft.title, + lastUpdated: draft.lastUpdated, users: [...draft.users], files: serializedFileDraft } @@ -79,24 +80,31 @@ const deserializeSigitDraft = async ( } } -export const saveSigitDraft = (draft: SigitDraft) => { +export const saveSigitDraft = (draft: SigitDraft): Promise => { if (saveSigitDraftTimeout) { clearTimeout(saveSigitDraftTimeout) } - saveSigitDraftTimeout = window.setTimeout(() => { - serializeSigitDraft(draft) - .then((draftToSave) => { - localStorage.setItem('sigitDraft', JSON.stringify(draftToSave)) - }) - .catch((error) => { - console.log(`Error while saving sigit draft. Error: `, error) - }) - }, 1000) + 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('sigitDraft') + const sigitDraft = localStorage.getItem(DRAFT_KEY) if (!sigitDraft) return null try { @@ -108,5 +116,5 @@ export const getSigitDraft = async () => { } export const clearSigitDraft = () => { - localStorage.removeItem('sigitDraft') + localStorage.removeItem(DRAFT_KEY) } diff --git a/src/utils/meta.ts b/src/utils/meta.ts index 75e1654..fd68541 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -31,7 +31,8 @@ export enum SignStatus { export enum SigitStatus { Partial = 'In-Progress', - Complete = 'Completed' + Complete = 'Completed', + LocalDraft = 'Draft' } export interface SigitCardDisplayInfo { -- 2.34.1