diff --git a/web/components/BabyBook/PictureArray.tsx b/web/components/BabyBook/PictureArray.tsx index 15b01802..6a58671d 100644 --- a/web/components/BabyBook/PictureArray.tsx +++ b/web/components/BabyBook/PictureArray.tsx @@ -1,11 +1,15 @@ import ArrowUpIcon from "@components/Icons/LeftChevronIcon copy"; +import { UseMapWrapper } from "@lib/hooks/useMap"; import { monthIndexToString } from "@lib/utils/date"; import Image from "next/image"; import { useRouter } from "next/router"; import { BabyBookYear, BabyImage } from "pages/admin/book/[babyId]"; import React, { useEffect, useRef, useState } from "react"; -const PictureArray = ({ babyBook, select }: Props) => { +const selectedForDownloadKey = (i: number, j: number, k: number) => + `${i}${j}${k}`; + +const PictureArray = ({ babyBook, select, selectedForDownload }: Props) => { const router = useRouter(); const wrapper = useRef(null); const [showToTop, setShowToTop] = useState(false); @@ -14,6 +18,7 @@ const PictureArray = ({ babyBook, select }: Props) => { year.months.map((_) => React.createRef()) ) ); + useEffect(() => { const onHashChange = ( url = window.location.pathname + window.location.hash @@ -53,6 +58,7 @@ const PictureArray = ({ babyBook, select }: Props) => { if (scrollTop > 0) setShowToTop(true); else setShowToTop(false); }; + const toTop = () => { if (!wrapper.current) return; wrapper.current.scrollTop = 0; @@ -69,19 +75,63 @@ const PictureArray = ({ babyBook, select }: Props) => {

{year.year}

{year.months.map((month, j) => { + const monthSelected = month.images.every((_, k) => + selectedForDownload.has(selectedForDownloadKey(i, j, k)) + ); + return (
-

- {monthIndexToString(month.month)} {year.year} +

{ + selectedForDownload.batch((map) => { + month.images.forEach((img, k) => { + const key = selectedForDownloadKey(i, j, k); + if (monthSelected) { + map.delete(key); + } else { + map.set(key, img); + } + }); + }); + }} + > + + + {monthIndexToString(month.month)} {year.year} +

- {month.images.map((image, k) => ( - select(i, j, k)} - key={k} - /> - ))} + {month.images.map((image, k) => { + const imageSelected = selectedForDownload.has( + selectedForDownloadKey(i, j, k) + ); + + return ( + select(i, j, k)} + selected={imageSelected} + onCheckboxClick={() => { + if (imageSelected) { + selectedForDownload.delete( + selectedForDownloadKey(i, j, k) + ); + } else { + selectedForDownload.set( + selectedForDownloadKey(i, j, k), + image + ); + } + }} + key={k} + /> + ); + })}
); @@ -104,21 +154,36 @@ const PictureArray = ({ babyBook, select }: Props) => { const BabyBookImage = ({ image, onClick, + onCheckboxClick, + selected, }: { image: BabyImage; onClick: () => void; + selected: boolean; + onCheckboxClick: () => void; }) => { + const borderClasses = `border hover:border-mbb-pink ${selected ? "!border-mbb-pink" : ""}`; + return (
{image.caption && ( -

+

{image.caption}

)} + { + e.stopPropagation(); + onCheckboxClick(); + }} + />
); }; @@ -126,6 +191,7 @@ const BabyBookImage = ({ interface Props { babyBook: BabyBookYear[]; select: (arg0: number, arg1: number, arg2: number) => void; + selectedForDownload: UseMapWrapper; } export default PictureArray; diff --git a/web/components/BabyBook/PictureModal.tsx b/web/components/BabyBook/PictureModal.tsx index 53b5a5cb..942e42e5 100644 --- a/web/components/BabyBook/PictureModal.tsx +++ b/web/components/BabyBook/PictureModal.tsx @@ -1,3 +1,4 @@ +import Button from "@components/atoms/Button"; import DownloadIcon from "@components/Icons/DownloadIcon"; import LeftChevronIcon from "@components/Icons/LeftChevronIcon"; import RightChevronIcon from "@components/Icons/RightChevronIcon"; @@ -37,13 +38,13 @@ const PictureModal = ({ ).toDate(); return (
-
+
deselect()} > -

Download

+

Back to album

@@ -90,13 +91,12 @@ const PictureModal = ({

{image.caption === "" ? "No Caption" : image.caption}

- + text="Download" + icon={} + width="auto" + />
diff --git a/web/components/BabyBook/Sidebar.tsx b/web/components/BabyBook/Sidebar.tsx index f3eede36..d7b34da7 100644 --- a/web/components/BabyBook/Sidebar.tsx +++ b/web/components/BabyBook/Sidebar.tsx @@ -60,7 +60,7 @@ const SideBar = ({ babyBook }: Props) => { return (
-
+
{babyBook.map((year) => ( +

setIsExpanded(!isExpanded)} - className={`font-semibold text-lg border-r px-4 cursor-pointer ${ + className={`font-semibold text-lg border-r px-4 cursor-pointer text-end ${ year === currentYear ? "text-black" : "" }`} > {year}

@@ -122,9 +122,9 @@ const YearSection = ({

pushHash(`${year}.${month.month}`)} - className={`p-1 px-8 w-full border-r cursor-pointer ${ + className={`p-1 px-8 border-r cursor-pointer ${ month.month === currentMonth - ? "bg-alt border-r-[3px] border-highlight text-black" + ? "bg-mbb-pink/10 border-r-[3px] border-mbb-pink text-black" : "" }`} > diff --git a/web/components/BabyBook/Topbar.tsx b/web/components/BabyBook/Topbar.tsx index 073982a3..efc381f0 100644 --- a/web/components/BabyBook/Topbar.tsx +++ b/web/components/BabyBook/Topbar.tsx @@ -2,21 +2,23 @@ import DownloadIcon from "@components/Icons/DownloadIcon"; import ImageIcon from "@components/Icons/ImageIcon"; import LinkIcon from "@components/Icons/LinkIcon"; import PersonIcon from "@components/Icons/PersonIcon"; -import { GetServerSideProps } from "next"; -import Image from "next/image"; -import { stringify } from "querystring"; import { useState } from "react"; -import admin_portal_gradient from "../../public/admin_portal_gradient.png"; -import left_heart from "../../public/left_heart.png"; -import right_heart from "../../public/right_heart.png"; +import Button from "@components/atoms/Button"; +import Image from "next/image"; -const TopBar = ({ number, motherName, name, content, iv }: Props) => { +const TopBar = ({ + number, + motherName, + name, + content, + iv, + isPictureSelected, +}: Props) => { const [copiedConfirmation, setCopiedConfirmation] = useState(false); - const downloadAlbum = async () => { + const downloadAlbum = () => { const a = document.createElement("a"); a.href = `/api/download-album?content=${content}&iv=${iv}`; - console.log(a.href); document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -35,22 +37,17 @@ const TopBar = ({ number, motherName, name, content, iv }: Props) => { return (

-
-
- -
+
+ Motherhood Beyond Bars Logo
- - - - - - -

{name}'s album

+

+ {name}'s album +

{number} photos

@@ -60,24 +57,22 @@ const TopBar = ({ number, motherName, name, content, iv }: Props) => {

Mother - {motherName}

-
- - -
+ {!isPictureSelected && ( +
+
+ )}
); }; @@ -88,6 +83,7 @@ interface Props { name: string; content: string; iv: string; + isPictureSelected: boolean; } export default TopBar; diff --git a/web/components/Icons/DownloadIcon.tsx b/web/components/Icons/DownloadIcon.tsx index b03fb981..291b3bc7 100644 --- a/web/components/Icons/DownloadIcon.tsx +++ b/web/components/Icons/DownloadIcon.tsx @@ -4,14 +4,13 @@ const DownloadIcon = () => { width="10" height="14" viewBox="0 0 10 14" - fill="none" + className="fill-current" xmlns="http://www.w3.org/2000/svg" > ); diff --git a/web/components/Icons/LinkIcon.tsx b/web/components/Icons/LinkIcon.tsx index faea1bbf..5c5099a0 100644 --- a/web/components/Icons/LinkIcon.tsx +++ b/web/components/Icons/LinkIcon.tsx @@ -4,14 +4,13 @@ const LinkIcon = () => { width="14" height="14" viewBox="0 0 14 14" - fill="none" + className="fill-current" xmlns="http://www.w3.org/2000/svg" > ); diff --git a/web/components/Icons/LockIcon.tsx b/web/components/Icons/LockIcon.tsx new file mode 100644 index 00000000..3f1b7661 --- /dev/null +++ b/web/components/Icons/LockIcon.tsx @@ -0,0 +1,36 @@ +export default function LockIcon() { + return ( + + + + + + + ); +} diff --git a/web/components/Icons/PlusIcon.tsx b/web/components/Icons/PlusIcon.tsx new file mode 100644 index 00000000..39e78105 --- /dev/null +++ b/web/components/Icons/PlusIcon.tsx @@ -0,0 +1,18 @@ +export default function PlusIcon() { + return ( + + + + ); +} diff --git a/web/components/Icons/SmileIcon.tsx b/web/components/Icons/SmileIcon.tsx new file mode 100644 index 00000000..236d41f8 --- /dev/null +++ b/web/components/Icons/SmileIcon.tsx @@ -0,0 +1,40 @@ +export default function SmileIcon() { + return ( + + + + + + + ); +} diff --git a/web/components/Icons/XIcon.tsx b/web/components/Icons/XIcon.tsx new file mode 100644 index 00000000..0273462c --- /dev/null +++ b/web/components/Icons/XIcon.tsx @@ -0,0 +1,26 @@ +import { MouseEventHandler } from "react"; + +export default function XIcon({ + onClick, +}: { + onClick?: MouseEventHandler; +}) { + return ( + + + + ); +} diff --git a/web/components/atoms/Button.tsx b/web/components/atoms/Button.tsx index 6fa84b83..0beb1cbf 100644 --- a/web/components/atoms/Button.tsx +++ b/web/components/atoms/Button.tsx @@ -5,12 +5,14 @@ interface Props { submit?: boolean; icon?: React.ReactNode; disabled?: boolean; + width?: string | number; } export default function Button({ text, type = "primary", onClick, + width, disabled = false, submit = false, icon = undefined, @@ -29,24 +31,24 @@ export default function Button({ } return ( -
- -
+ ); } diff --git a/web/db/actions/caregiver/Photo.ts b/web/db/actions/caregiver/Photo.ts index 92530d8b..6b2e0346 100644 --- a/web/db/actions/caregiver/Photo.ts +++ b/web/db/actions/caregiver/Photo.ts @@ -3,7 +3,15 @@ import { db, storage } from "../../firebase"; // import firebase storage import { ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; import { doc, setDoc, Timestamp } from "firebase/firestore"; -export async function uploadPhoto(e: React.ChangeEvent) { +interface returnType { + success: boolean; + data?: { downloadURL: object }; + error?: string; +} + +export function uploadPhoto( + e: React.ChangeEvent +): returnType { const files = e.target.files; if (!files || files.length === 0) { return { success: false, error: "File attempted to be uploaded was empty" }; @@ -19,10 +27,6 @@ export async function uploadPhoto(e: React.ChangeEvent) { uploadTask.on( "state_changed", - (snapshot) => { - const progress = - (snapshot.bytesTransferred / snapshot.totalBytes) * 100; - }, (error) => { return { success: false, error: `Upload failed: ${error}` }; }, @@ -48,16 +52,26 @@ export async function uploadPhoto(e: React.ChangeEvent) { `${caregiverID}_${Date.now()}` ); - await setDoc(docRef, { - imageUrl: imageURL, - caption: caption, - date: Timestamp.now(), - caregiverId: caregiverID, - }); + // TODO fix logic os that caption is added after + try { + await setDoc(docRef, { + imageUrl: imageURL, + caption: caption, + date: Timestamp.now(), + caregiverId: caregiverID, + }); + } catch (error) { + return { success: false, error: `Upload failed: ${error}` }; + } return { success: true, data: { downloadURL: downloadURL } }; } ); + + return { + success: false, + error: `Upload failed: Something has gone wrong, please try again`, + }; } catch (error) { return { success: false, error: `Upload failed: ${error}` }; } diff --git a/web/lib/hooks/useMap.ts b/web/lib/hooks/useMap.ts new file mode 100644 index 00000000..2590b51c --- /dev/null +++ b/web/lib/hooks/useMap.ts @@ -0,0 +1,49 @@ +import { Dispatch, SetStateAction, useRef, useState } from "react"; + +export type UseMapWrapper = { + set: (key: K, value: V) => void; + get: (key: K) => V | undefined; + delete: (key: K) => void; + has: (key: K) => boolean; + batch: (op: (map: Map) => void) => void; + clear: () => void; + entries: () => IterableIterator<[K, V]>; + size: number; +}; + +export const useMap = () => { + const mapRef = useRef(new Map()); + + const [wrapper, setWrapper] = useState>(createWrapper()); + + function createWrapper(): UseMapWrapper { + return { + set: (key, value) => { + mapRef.current.set(key, value); + setWrapper(createWrapper()); + }, + delete: (key) => { + if (mapRef.current.has(key)) { + setWrapper(createWrapper()); + } + mapRef.current.delete(key); + }, + get: (key) => mapRef.current.get(key), + has: (key) => mapRef.current.has(key), + entries: () => mapRef.current.entries(), + batch: (op) => { + op(mapRef.current); + setWrapper(createWrapper()); + }, + clear: () => { + if (mapRef.current.size > 0) setWrapper(createWrapper()); + mapRef.current = new Map(); + }, + get size() { + return mapRef.current.size; + }, + }; + } + + return wrapper; +}; diff --git a/web/lib/utils/date.ts b/web/lib/utils/date.ts index eac75215..ca7238ee 100644 --- a/web/lib/utils/date.ts +++ b/web/lib/utils/date.ts @@ -16,20 +16,22 @@ export function formatDate(date: string) { export function monthIndexToString(index: number | string) { const number = parseInt(index as string); - if (!isNaN(number) && (number < 0 || number > 11)) return "Not a Month"; - const INDEX_TO_MONTH: { [key: string]: string } = { - "0": "January", - "1": "February", - "2": "March", - "3": "April", - "4": "May", - "5": "June", - "6": "July", - "7": "August", - "8": "September", - "9": "October", - "10": "November", - "11": "December", - }; - return INDEX_TO_MONTH[index]; + if (isNaN(number) || number < 0 || number > 11) return "Not a Month"; + + const INDEX_TO_MONTH = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ] as const; + + return INDEX_TO_MONTH[number]; } diff --git a/web/pages/admin/book/[babyId].tsx b/web/pages/admin/book/[babyId].tsx index f07da826..e19e0762 100644 --- a/web/pages/admin/book/[babyId].tsx +++ b/web/pages/admin/book/[babyId].tsx @@ -17,6 +17,10 @@ import { GetServerSideProps } from "next"; import { Baby } from "@lib/types/baby"; import { useState } from "react"; import { decrypt } from "@lib/utils/encryption"; +import { useMap } from "@lib/hooks/useMap"; +import Button from "@components/atoms/Button"; +import DownloadIcon from "@components/Icons/DownloadIcon"; +import XIcon from "@components/Icons/XIcon"; export default function BabyBook({ babyBook, @@ -28,11 +32,15 @@ export default function BabyBook({ const [isPictureSelected, setIsPictureSelected] = useState(false); const [selectedImage, setSelectedImage] = useState(); const [currIndexes, setCurrIndexes] = useState({ i: -1, j: -1, k: -1 }); + const selectedForDownload = useMap(); + const selectImage = (i: number, j: number, k: number) => { setIsPictureSelected(true); setSelectedImage(babyBook[i].months[j].images[k]); setCurrIndexes({ i, j, k }); + selectedForDownload.clear(); }; + const selectImageOffset = ( i: number, j: number, @@ -59,23 +67,31 @@ export default function BabyBook({ if (i < 0 || i === babyBook.length) return; } } + selectImage(i, j, k); }; + const deselectImage = () => { setIsPictureSelected(false); }; + return ( -
+
- + {isPictureSelected && ( )} + {selectedForDownload.size > 0 && ( +
+
+ { + selectedForDownload.clear(); + }} + /> +

+ {selectedForDownload.size} file + {selectedForDownload.size > 1 ? "s" : ""} selected +

+
+
+ )}
); diff --git a/web/pages/caregiver/book/[babyId].tsx b/web/pages/caregiver/book/[babyId].tsx new file mode 100644 index 00000000..12425138 --- /dev/null +++ b/web/pages/caregiver/book/[babyId].tsx @@ -0,0 +1,217 @@ +import { + collection, + doc, + DocumentReference, + getDoc, + getDocs, + orderBy, + query as doQuery, + Timestamp, +} from "firebase/firestore"; + +import { GetServerSideProps } from "next"; +import Image from "next/image"; + +import { db } from "db/firebase"; +import { uploadPhoto } from "db/actions/caregiver/Photo"; + +import { Baby } from "@lib/types/baby"; +import { decrypt } from "@lib/utils/encryption"; +import { monthIndexToString } from "@lib/utils/date"; + +import SmileIcon from "@components/Icons/SmileIcon"; +import PlusIcon from "@components/Icons/PlusIcon"; + +export default function BabyBook({ + babyBook, + totImages, + baby, + content, + iv, +}: Props) { + // TODO add TopBar when done + + return ( +
+
+

+ {baby.firstName} {baby.lastName} + {baby.lastName[baby.lastName.length - 1] === "s" ? "'" : "'s"} Album +

+

Birthday: {baby.birthday}

+
+ {totImages === 0 ? ( + <> +
+ +
+

+ No Photos Yet +

+

+ Get started by adding a photo of {baby.firstName} here! +

+ + ) : ( + babyBook.flatMap(({ year, months }) => + months.map(({ month, images }) => ( +
+

+ {monthIndexToString(month)} {year} +

+
+ {images.map(({ imageUrl, date }) => ( + <> +
{ + // TODO: view single image + }} + > + {`Baby +
+ + ))} +
+
+ )) + ) + )} + +
+ ); +} + +interface Props { + babyBook: BabyBookYear[]; + totImages: number; + baby: { + firstName: string; + lastName: string; + mother: string; + birthday: string; + }; + content: string; + iv: string; +} + +export interface BabyBookYear { + year: number; + months: BabyBookMonth[]; +} + +export interface BabyBookMonth { + month: number; + images: BabyImage[]; +} + +export interface BabyImage { + caption: string; + date: { + seconds: number; + nanoseconds: number; + }; + imageUrl: string; + caregiverId: string; +} + +interface RawBabyImage { + caption: string; + date: Timestamp; + imageURL: string; + caregiverID: DocumentReference; +} + +export const getServerSideProps: GetServerSideProps< + Props, + { babyId?: string } +> = async ({ params, query }) => { + const props: Props = { + babyBook: [], + totImages: 0, + baby: { firstName: "", lastName: "", mother: "", birthday: "" }, + content: "", + iv: "", + }; + + if (!params || !params.babyId || !query.iv) return { props }; + + props.content = params?.babyId as string; + props.iv = query.iv as string; + const babyId = decrypt({ iv: query.iv as string, content: params.babyId }); + + const babyRef = doc(db, "babies", babyId); + const baby = await getDoc(babyRef); + const babyData = baby.data() as Baby; + + props.baby = { + firstName: babyData.firstName, + lastName: babyData.lastName, + mother: babyData.motherName, + birthday: babyData.dob.toString(), + }; + const babyBookRef = doQuery( + collection(db, `babies/${babyId}/book`), + orderBy("date", "desc") + ); + const babyBookDocs = await getDocs(babyBookRef); + babyBookDocs.docs.forEach((book) => { + props.totImages = props.totImages + 1; + const raw = book.data() as RawBabyImage; + const date = raw.date.toDate(); + + const currYear = date.getFullYear(); + if ( + props.babyBook.length < 1 || + props.babyBook[props.babyBook.length - 1].year !== currYear + ) + props.babyBook.push({ year: currYear, months: [] }); + const year = props.babyBook[props.babyBook.length - 1]; + + const currMonth = date.getMonth(); + if ( + year.months.length < 1 || + year.months[year.months.length - 1].month !== currMonth + ) + year.months.push({ month: currMonth, images: [] }); + year.months[year.months.length - 1].images.push({ + caption: raw.caption || "", + imageUrl: raw.imageURL, + caregiverId: raw.caregiverID?.id || "", + date: { + seconds: raw.date.seconds, + nanoseconds: raw.date.nanoseconds, + }, + }); + }); + + return { + props, + }; +}; diff --git a/web/pages/caregiver/book/index.tsx b/web/pages/caregiver/book/index.tsx new file mode 100644 index 00000000..809cc2d0 --- /dev/null +++ b/web/pages/caregiver/book/index.tsx @@ -0,0 +1,103 @@ +import Button from "@components/atoms/Button"; +import LockIcon from "@components/Icons/LockIcon"; +import { GetServerSideProps } from "next"; + +interface Props { + babies: any[]; + books: { name: string; birthday: string; bookLink: string }[]; +} + +// TODO add topbar and merge designs + +export default function BabyBookHome({ babies, books }: Props) { + if (babies.length === 0) { + return ( +
+
+ +
+

+ Restricted Access +

+

+ Looks like your account is not assigned to a child yet! +
+
+ Once the child is in your care,{" "} + + contact us + {" "} + to set up account features for you and the child. +

+
+ ); + } + + if (books.length === 0) { + return ( +
+

+ Start a Baby Book +

+

+ The Baby Book is a place where you can document the baby's journey + by uploading images and descriptions. Motherhood Behind Bars will then + deliver the images to the mothers, so they can stay updated on their + baby's growth. +

+
+ ); + } + + return ( +
+

+ Current Baby Books +

+
+ {books.map(({ name, birthday, bookLink }) => ( + +

{name}

+

+ Birthday: {birthday} +

+
+ ))} +
+
+ ); +} + +export const getServerSideProps: GetServerSideProps = async ({ + query, +}) => { + // TODO: implement actual book fetching here + return { + props: { + babies: [""], + books: [ + { + name: "John", + birthday: "11/13/2204", + bookLink: + "/caregiver/book/bd89888ca382e490a04183167a810518f2aa9f39?iv=af3abe9304c706f681857b64fc4e6127", + }, + { + name: "Joe", + birthday: "11/13/2104", + bookLink: "/link/link2", + }, + { + name: "Joslyn", + birthday: "11/13/2004", + bookLink: "/link/link3", + }, + ], + }, + }; +}; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 94f20d62..929fa19c 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -18,6 +18,8 @@ module.exports = { "radial-gradient(147.14% 98.02% at 24.4% 30.17%, #EDB1CB 0%, #B14378 100%)", "onboarding-background2": "radial-gradient(86.27% 87.14% at 24.4% 30.17%, #EDB1CB 0.01%, #B14378 79.82%)", + "admin-baby-book-background": + "radial-gradient(114.39% 277.05% at 79.56% 231.48%, #EDB1CB 0%, #B14378 100%)", }, fontFamily: { sans: ["Open Sans", ...defaultTheme.fontFamily.sans],