diff --git a/package.json b/package.json index 2425340f..ce550023 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "react-router-bootstrap": "^0.26.2", "react-router-dom": "^6.12.0", "react-simple-code-editor": "^0.13.1", + "react-uuid": "^2.0.0", "remark": "^14.0.2", "remark-gfm": "^3.0.1", "resolve": "^1.20.0", diff --git a/src/components/photo.tsx b/src/components/photo.tsx index da9fcd02..2dee6f47 100644 --- a/src/components/photo.tsx +++ b/src/components/photo.tsx @@ -1,6 +1,6 @@ import {isEmpty} from 'lodash' import React, {FC, useEffect, useState} from 'react' -import {Button, Card, Image} from 'react-bootstrap' +import {Button, Card, Image, Modal, ModalBody, Popover} from 'react-bootstrap' import GpsCoordStr from './gps_coord_str' @@ -13,6 +13,7 @@ interface PhotoProps { metadata: PhotoMetadata, photo: Blob | undefined, required: boolean, + deletePhoto?: (id: string) => void, } /** @@ -27,8 +28,8 @@ interface PhotoProps { * photo attachement in the data store with the given id. When set, the Photo component * will always show and the Photo component will indicate when the photo is missing. */ -const Photo: FC = ({description, label, metadata, photo, required}) => { - +const Photo: FC = ({description, label, metadata, photo, required, deletePhoto, id}) => { + const [showDeleteModal, setShowDeleteModal] = useState(false) return (photo || required)? ( <> @@ -55,7 +56,18 @@ const Photo: FC = ({description, label, metadata, photo, required}) : Missing } +
+ {deletePhoto && } + {deletePhoto && + +

Are you sure you want to delete this photo?

+
+ + +
+
+
} ) : required && ( Missing Photo diff --git a/src/components/photo_input.tsx b/src/components/photo_input.tsx index 85dc769c..0183bcd5 100644 --- a/src/components/photo_input.tsx +++ b/src/components/photo_input.tsx @@ -1,127 +1,104 @@ +import React, { ChangeEvent, FC, MouseEvent, useEffect, useRef, useState } from 'react' +import { Button, Card, Image } from 'react-bootstrap' +import { TfiCamera, TfiGallery } from 'react-icons/tfi' +import Collapsible from './collapsible' import ImageBlobReduce from 'image-blob-reduce' -import {isEmpty} from 'lodash' -import React, {ChangeEvent, FC, MouseEvent, useEffect, useRef, useState} from 'react' -import {Button, Card, Image} from 'react-bootstrap' -import {TfiGallery} from 'react-icons/tfi' - +import Photo from './photo' +import PhotoMetadata from '../types/photo_metadata.type' -import Collapsible from './collapsible' -import GpsCoordStr from './gps_coord_str' -import PhotoMetaData from '../types/photo_metadata.type' interface PhotoInputProps { - children: React.ReactNode, - label: string, - metadata: PhotoMetaData, - photo: Blob | undefined, - upsertPhoto: (file: Blob) => void, + children: React.ReactNode + label: string + photos: {id: string, data: {blob: Blob, metadata: PhotoMetadata}}[] + upsertPhoto: (file: Blob, id: string) => void + removeAttachment: (id: string) => void + id: string } -// TODO: Determine whether or not the useEffect() method is needed. -// We don't seem to need a separate camera button on an Android phone. -// However, we may need to request access to the camera -// before it can me used. Then clean up the corresponding code that is currently -// commented out. -/** - * Component for photo input - * - * @param children Content (most commonly markdown text) describing the photo requirement - * @param label Label for the photo requirement - * @param metadata Abreviated photo metadata including timestamp and geolocation - * @param photo Blob containing the photo itself - * @param upsertPhoto Function used to update/insert a photo into the store - */ -const PhotoInput: FC = ({children, label, metadata, photo, upsertPhoto}) => { - // Create references to the hidden file inputs - const hiddenPhotoCaptureInputRef = useRef(null) +const PhotoInput: FC = ({ children, label, photos, upsertPhoto, removeAttachment, id }) => { const hiddenPhotoUploadInputRef = useRef(null) - - const [cameraAvailable, setCameraAvailable] = useState(false) - - // Handle button clicks - const handlePhotoCaptureButtonClick = (event: MouseEvent) => { - hiddenPhotoCaptureInputRef.current && hiddenPhotoCaptureInputRef.current.click() - } + const hiddenCameraInputRef = useRef(null) + const photoIds = [photos.map((photo) => photo.id.split('~').pop())] + const [photoId, setPhotoId] = useState(photoIds.length > 0 ? Math.max(photoIds as any as number) : 0) + const isMobile = /Mobi/.test(navigator.userAgent) const handlePhotoGalleryButtonClick = (event: MouseEvent) => { + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + navigator.mediaDevices.getUserMedia({ video: true }) + } hiddenPhotoUploadInputRef.current && hiddenPhotoUploadInputRef.current.click() } - useEffect(() => { - if(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { - navigator.mediaDevices.getUserMedia({ video: true }) - .then(() => { - setCameraAvailable(true) - }); - } + const handleCameraButtonClick = (event: MouseEvent) => { + hiddenCameraInputRef.current && hiddenCameraInputRef.current.click() + } - }) + const handleFileInputChange = async (event: ChangeEvent) => { + if (event.target.files && event.target.files.length > 0) { + const files = Array.from(event.target.files) + for (const file of files) { + const imageBlobReduce = new ImageBlobReduce() + setPhotoId(photoId + 1) + const blob = await imageBlobReduce.toBlob(file) + const blobId = `${id}~${photoId.toString()}` + upsertPhoto(blob, blobId) + } - const handleFileInputChange = (event: ChangeEvent) => { - if (event.target.files) { - const file = event.target.files[0] - upsertPhoto(file) + event.target.value = '' } } + const handleFileDelete = (id: string) => { + removeAttachment(id) + } + return ( <> - + - {/* Card.Text renders a

by defult. The children come from markdown - and may be a

. Nested

s are not allowed, so we use a

*/} - - {children} - + {children}
- {/* {(cameraAvailable || cameraAvailable) && - - } */} - + {( isMobile && + + )} +
- {/* */} + capture="environment" + /> - {photo && ( + {photos.length > 0 && ( <> - -
- - Timestamp: { - metadata?.timestamp ? ({metadata.timestamp}) : - (Missing) - } -
- Geolocation: { - metadata?.geolocation?.latitude && metadata?.geolocation?.latitude?.deg.toString() !== 'NaN' && - metadata?.geolocation?.longitude && metadata?.geolocation?.longitude?.deg.toString() !== 'NaN' ? - : - Missing - } -
+ {photos.map((photo, index) => ( +
+ +
+ ))} )} - ); -}; + ) +} export default PhotoInput diff --git a/src/components/photo_input_wrapper.tsx b/src/components/photo_input_wrapper.tsx index 15d0aa37..73ae0638 100644 --- a/src/components/photo_input_wrapper.tsx +++ b/src/components/photo_input_wrapper.tsx @@ -1,5 +1,5 @@ import ImageBlobReduce from 'image-blob-reduce' -import React, {FC} from 'react' +import React, {FC, useState} from 'react' import {StoreContext} from './store' import PhotoInput from './photo_input' @@ -14,6 +14,12 @@ interface PhotoInputWrapperProps { label: string } +export const filterAttachmentsByIdPrefix = (attachments: { [s: string]: unknown } | ArrayLike, idPrefix: string)=> { + return Object.entries(attachments).filter((attachment: any) => attachment[0].startsWith(idPrefix)).map((attachment: any) => { + return {id: attachment[0], data: attachment[1] } + }) +} + /** * A component that wraps a PhotoInput component in order to tie it to the data store * @@ -27,22 +33,21 @@ const PhotoInputWrapper: FC = ({children, id, label}) => return ( - {({attachments, upsertAttachment}) => { - - const upsertPhoto = (img_file: Blob) => { + {({attachments, upsertAttachment, removeAttachment}) => { + const upsertPhoto = (img_file: Blob, photoId: string) => { // Reduce the image size as needed ImageBlobReduce() .toBlob(img_file, {max: MAX_IMAGE_DIM}) .then(blob => { - upsertAttachment(blob, id) + upsertAttachment(blob, photoId) }) } + const photos = filterAttachmentsByIdPrefix(attachments, id) return ( ) }} diff --git a/src/components/photo_wrapper.tsx b/src/components/photo_wrapper.tsx index 50582891..ec5adae2 100644 --- a/src/components/photo_wrapper.tsx +++ b/src/components/photo_wrapper.tsx @@ -3,6 +3,7 @@ import React, {FC} from 'react' import {StoreContext} from './store' import Photo from './photo' import PhotoMetadata from '../types/photo_metadata.type' +import { filterAttachmentsByIdPrefix } from './photo_input_wrapper' interface PhotoWrapperProps { @@ -29,11 +30,22 @@ const PhotoWrapper: FC = ({children, id, label, required}) => return ( {({attachments, data}) => { + const photos = filterAttachmentsByIdPrefix(attachments, id) + return ( - +
+ { photos.map((photo, index) => { + return( +
1 ? '50%' : '100%' }}> + +
+ ) + }) + } +
) }}
diff --git a/src/components/store.tsx b/src/components/store.tsx index 58762e3c..622bddd2 100644 --- a/src/components/store.tsx +++ b/src/components/store.tsx @@ -16,6 +16,10 @@ interface UpsertAttachment { (blob: Blob, id: string): void; } +interface RemoveAttachment { + (id: string): void; +} + interface UpsertData { (pathStr: string, data: any): void; } @@ -31,7 +35,11 @@ export const StoreContext = React.createContext({ metadata: {} as any, upsertAttachment: ((blob: Blob, id: any) => {}) as UpsertAttachment, upsertData: ((pathStr: string, data: any) => {}) as UpsertData, +<<<<<<< HEAD + removeAttachment: ((id: string) => {}) as RemoveAttachment, +======= upsertDoc: ((pathStr: string, data: any) => {}) as UpsertDoc, +>>>>>>> origin }); @@ -307,8 +315,43 @@ export const StoreProvider: FC = ({ children, dbName, docId } }; + const removeAttachment: RemoveAttachment = async (id: string) => { + // Remove the attachment from memory + const newAttachments = { ...attachments } + delete newAttachments[id] + setAttachments(newAttachments) + + // Remove the attachment from the database + const removeBlobDB = async (rev: string): Promise => { + let result = null + if (db) { + try { + result = await db.removeAttachment(docId, id, rev) + } catch (err) { + // Try again with the latest rev value + const doc = await db.get(docId) + result = await removeBlobDB(doc._rev) + } finally { + if (result) { + revisionRef.current = result.rev + } + } + } + return result + } + + if (revisionRef.current) { + await removeBlobDB(revisionRef.current) + } + }; + + return ( +<<<<<<< HEAD + +======= +>>>>>>> origin {children} ); diff --git a/yarn.lock b/yarn.lock index dbbfebb1..370ac625 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9439,6 +9439,11 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" +react-uuid@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-uuid/-/react-uuid-2.0.0.tgz#e3c97e190db9eef53cb9dacfcc7314d913a411fa" + integrity sha512-FNUH/8WR/FEtx0Bu6gmt1eONfc413hhvrEXFWUSFGvznUhI4dYoVZA09p7JHoTpnM4WC2D/bG2YSxGKXF4oVLg== + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"