diff --git a/public/print.css b/public/print.css index abd76314..0e1b372f 100644 --- a/public/print.css +++ b/public/print.css @@ -175,4 +175,39 @@ h3 { /* stylizes tab component here to avoid putting styles in editor*/ .tab-content { margin-top: 1rem; +} + +.photo-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.photo-input-delete-button { + cursor: 'pointer'; + border-width: '1px'; + border-radius: '6px'; +} + +.delete-modal-delete { + cursor: 'pointer'; + color: 'red'; + border-width: '1px'; + border-radius: '6px'; +} + +.photo-input-photos-container { + display: 'flex'; + flex-wrap: 'wrap'; +} + +.delete-modal-container { + display: 'flex'; + align-items: 'center'; + justify-content: 'space-between'; +} + +.report-print-photo-notes { + display: flex; + flex-direction: column; } \ No newline at end of file diff --git a/src/App.css b/src/App.css index abd76314..b5e43d8a 100644 --- a/src/App.css +++ b/src/App.css @@ -175,4 +175,49 @@ h3 { /* stylizes tab component here to avoid putting styles in editor*/ .tab-content { margin-top: 1rem; +} + +.photo-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.photo-input-delete-button { + cursor: 'pointer'; + border-width: '1px'; + border-radius: '6px'; +} + +.delete-modal-delete { + cursor: 'pointer'; + color: 'red'; + border-width: '1px'; + border-radius: '6px'; +} + +.photo-input-photos-container { + display: 'flex'; + flex-wrap: 'wrap'; +} + +.delete-modal-container { + display: 'flex'; + align-items: 'center'; + justify-content: 'space-between'; +} + +.report-print-photo-notes { + display: flex; + flex-direction: column; +} + +.photo-wrapper { + display: 'flex'; + flex-wrap: 'wrap'; +} + +.photo-display-container { + display: flex; + width: fit-content; } \ No newline at end of file diff --git a/src/components/notes.tsx b/src/components/notes.tsx new file mode 100644 index 00000000..54b951c7 --- /dev/null +++ b/src/components/notes.tsx @@ -0,0 +1,41 @@ +import { FC, useState } from "react"; +import TextInput from "./text_input" +import { pathToId } from "../utilities/paths_utils"; + +interface NotesInputProps { + value?: string | null, + updateValue: ((id: string, notes: string) => void) | undefined; + id: string, +} +const NotesInput: FC =({ + value, + updateValue, + id +}) => { + const [notes, updateNotes] = useState(value) + const notesId = `${id}.notes` + const handleNotesChange = (inputValue: string) => { + updateNotes(inputValue) + updateValue && updateValue(notesId, inputValue) + } + return ( + { + handleNotesChange(value) + }} + min={0} + max={280} + regexp={/.*/} + disabled={ + !updateValue + } + /> + ) +} + +export default NotesInput diff --git a/src/components/photo.tsx b/src/components/photo.tsx index 640403d8..2f834f15 100644 --- a/src/components/photo.tsx +++ b/src/components/photo.tsx @@ -1,10 +1,12 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import type { FC } from 'react' -import { Card, Image } from 'react-bootstrap' +import { Button, Card, Image, Modal, ModalBody, Popover } from 'react-bootstrap' import DateTimeStr from './date_time_str' import GpsCoordStr from './gps_coord_str' import type PhotoMetadata from '../types/photo_metadata.type' +import TextInput from './text_input' +import { pathToId } from '../utilities/paths_utils' interface PhotoProps { description: React.ReactNode @@ -13,6 +15,11 @@ interface PhotoProps { metadata: PhotoMetadata photo: Blob | undefined required: boolean + deletePhoto?: (id: string) => void + updateNotes?: (id: string, notes: string) => void + count?: string + disallowNotes?: boolean + notes?: any } /** @@ -33,12 +40,17 @@ const Photo: FC = ({ metadata, photo, required, + count, + disallowNotes, + notes }) => { return photo || required ? ( <> - {label} +
+ {label} {count} +
{/* Card.Text renders a

by defult. The description comes from markdown and may be a

. Nested

s are not allowed, so we use a

*/} {description} @@ -46,23 +58,31 @@ const Photo: FC = ({ <>
- - Timestamp:{' '} - {metadata?.timestamp ? ( - - ) : ( - Missing +
+ + Timestamp:{' '} + {metadata?.timestamp ? ( + + ) : ( + Missing + )} +
+ Geolocation:{' '} + { + + {' '} + + } +
+ {(notes && !disallowNotes) && ( +
+ Notes: + {notes} +
)} -
- Geolocation:{' '} - { - - {' '} - - } -
+
) : ( required && Missing Photo diff --git a/src/components/photo_input.tsx b/src/components/photo_input.tsx index ea02af8a..fcb927f8 100644 --- a/src/components/photo_input.tsx +++ b/src/components/photo_input.tsx @@ -1,18 +1,25 @@ import React, { useEffect, useRef, useState } from 'react' import type { ChangeEvent, FC, MouseEvent } from 'react' -import { Button, Card, Image } from 'react-bootstrap' +import { Button, Card, Image, Modal, ModalBody } from 'react-bootstrap' import { TfiGallery } from 'react-icons/tfi' import Collapsible from './collapsible' -import DateTimeStr from './date_time_str' +import PhotoMetadata from '../types/photo_metadata.type' +import ImageBlobReduce from 'image-blob-reduce' import GpsCoordStr from './gps_coord_str' -import type PhotoMetaData from '../types/photo_metadata.type' +import DateTimeStr from './date_time_str' +import NotesInput from './notes' interface PhotoInputProps { children: React.ReactNode label: string - metadata: PhotoMetaData - photo: Blob | undefined - upsertPhoto: (file: Blob) => void + photos: { id: string; data: { blob: Blob; metadata: PhotoMetadata } }[] + upsertPhoto: (file: Blob, id: string) => void + removeAttachment: (id: string) => void + id: string + maxPhotos?: number + updateNotes?: (id: string, notes: string) => void + disallowNotes?: boolean, + metadata?: any } // TODO: Determine whether or not the useEffect() method is needed. @@ -33,16 +40,31 @@ interface PhotoInputProps { const PhotoInput: FC = ({ children, label, - metadata, - photo, + photos, upsertPhoto, + removeAttachment, + maxPhotos = 1, + id, + updateNotes, + disallowNotes, + metadata }) => { // Create references to the hidden file inputs const hiddenPhotoCaptureInputRef = useRef(null) const hiddenPhotoUploadInputRef = useRef(null) + 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 [showDeleteModal, setShowDeleteModal] = useState(false) const [cameraAvailable, setCameraAvailable] = useState(false) + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + navigator.mediaDevices.getUserMedia({ video: true }) + } + // Handle button clicks const handlePhotoCaptureButtonClick = ( event: MouseEvent, @@ -65,18 +87,31 @@ const PhotoInput: FC = ({ } }) - const handleFileInputChange = (event: ChangeEvent) => { - if (event.target.files) { - const file = event.target.files[0] - upsertPhoto(file) + 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) + } + event.target.value = '' } } + const handleFileDelete = (id: string) => { + removeAttachment(id) + } + // Check if there is already a photo - const hasPhoto = !!photo + const hasPhoto = !!photos.length // Button text based on whether there is a photo or not - const buttonText = hasPhoto ? 'Replace Photo' : 'Add Photo' + const buttonText = 'Add Photo' return ( <> @@ -87,27 +122,6 @@ const PhotoInput: FC = ({ and may be a

. Nested

s are not allowed, so we use a

*/} {children} -
- {/* {(cameraAvailable || cameraAvailable) && - - } */} - -
- {/* */} = ({ type="file" capture="environment" /> - {photo && ( + {( <> - -
- - Timestamp:{' '} - {metadata?.timestamp ? ( - - ) : ( - Missing - )} -
- Geolocation:{' '} - { - - {' '} - - } -
+ {photos.map((photo, index) => ( +
+ + + <> + +
+ + Timestamp:{' '} + {photo.data.metadata?.timestamp ? ( + + ) : ( + Missing + )} +
+ Geolocation:{' '} + { + + {' '} + + } + {(updateNotes || !disallowNotes) && + + } + { + + } +
+ {handleFileDelete && ( + + +

+ Are you sure you + want to delete + this photo? +

+
+ + +
+
+
+ )} + +
+
+
+ ))} )} + {photos.length <= maxPhotos && ( + + )} diff --git a/src/components/photo_input_wrapper.tsx b/src/components/photo_input_wrapper.tsx index e00c4d61..3b672db9 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' @@ -13,6 +13,17 @@ 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 * @@ -29,25 +40,31 @@ const PhotoInputWrapper: FC = ({ }) => { return ( - {({ attachments, upsertAttachment }) => { - const upsertPhoto = (img_file: Blob) => { + {({ + attachments, + upsertAttachment, + removeAttachment, + upsertMetaData, + metadata + }) => { + 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 ( {children} diff --git a/src/components/photo_wrapper.tsx b/src/components/photo_wrapper.tsx index 8a503a5e..0c996eb7 100644 --- a/src/components/photo_wrapper.tsx +++ b/src/components/photo_wrapper.tsx @@ -1,8 +1,10 @@ -import React, { FC } from 'react' +import React, { FC, useEffect, useState } from 'react' import { StoreContext } from './store' import Photo from './photo' import PhotoMetadata from '../types/photo_metadata.type' +import { filterAttachmentsByIdPrefix } from './photo_input_wrapper' +import { get } from 'lodash' interface PhotoWrapperProps { children: React.ReactNode @@ -31,19 +33,29 @@ const PhotoWrapper: FC = ({ }) => { return ( - {({ attachments, data }) => { + {({ attachments, data, metadata }) => { + const photos = filterAttachmentsByIdPrefix(attachments, id) return ( - +
+ {photos.map((photo, index) => { + return ( +
+ +
+ ) + })} +
) }}
diff --git a/src/components/store.tsx b/src/components/store.tsx index c2a0fe90..23b511c4 100644 --- a/src/components/store.tsx +++ b/src/components/store.tsx @@ -15,10 +15,13 @@ import type Attachment from '../types/attachment.type' import type { Objectish, NonEmptyArray } from '../types/misc_types.type' import type Metadata from '../types/metadata.type' import { putNewDoc } from '../utilities/database_utils' +import { satisfies } from 'semver' PouchDB.plugin(PouchDBUpsert) type UpsertAttachment = (blob: Blob, id: string) => void +type RemoveAttachment = (id: string) => void +type UpsertMetaData = (id: string, metadata: any) => void type UpsertData = (pathStr: string, data: any) => void @@ -33,8 +36,10 @@ type Attachments = Record< export const StoreContext = React.createContext({ attachments: {} satisfies Attachments, data: {} satisfies JSONValue, - metadata: {} satisfies Metadata | undefined, + metadata: {} satisfies MetaData, upsertAttachment: ((blob: Blob, id: any) => {}) as UpsertAttachment, + removeAttachment: ((id: string) => {}) as RemoveAttachment, + upsertMetaData: ((id: string, metadata: any) => {}) as UpsertMetaData, upsertData: ((pathStr: string, data: any) => {}) as UpsertData, upsertDoc: ((pathStr: string, data: any) => {}) as UpsertDoc, }) @@ -256,6 +261,7 @@ export const StoreProvider: FC = ({ } else { result.metadata_.last_modified_at = new Date() } + setMetaData(result.metadata_) return result }) .then(function (res) { @@ -340,6 +346,44 @@ export const StoreProvider: FC = ({ } } + 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) + } + } + + const upsertMetaData = (path: string, metadata: any) => { + path = 'metadata_.attachments.' + path + console.log(metadata, path, doc) + upsertDoc(path, metadata) + } + return ( = ({ data, metadata, upsertAttachment, + removeAttachment, + upsertMetaData, upsertData, upsertDoc, }} diff --git a/src/components/text_input.tsx b/src/components/text_input.tsx index 9a16ef76..dc613f9d 100644 --- a/src/components/text_input.tsx +++ b/src/components/text_input.tsx @@ -10,6 +10,7 @@ interface TextInputProps { min: number max: number regexp: RegExp + disabled?: boolean } /** @@ -32,6 +33,7 @@ const TextInput: FC = ({ min, max, regexp, + disabled, }) => { const [error, setError] = useState('') @@ -59,6 +61,8 @@ const TextInput: FC = ({ placeholder="A placeholder" value={value || ''} isInvalid={Boolean(error)} + disabled={disabled} + draggable={!disabled} /> {error && ( diff --git a/src/types/photo_metadata.type.ts b/src/types/photo_metadata.type.ts index 4b5c0555..c8ef572c 100644 --- a/src/types/photo_metadata.type.ts +++ b/src/types/photo_metadata.type.ts @@ -6,6 +6,7 @@ interface PhotoMetadata { } // TODO: replace string with more precise type timestamp: string + notes?: string | null } export default PhotoMetadata