Skip to content

Commit

Permalink
Start dream edit front-end implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
bombies committed Oct 26, 2023
1 parent 531fcb9 commit e560535
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {Divider} from "@nextui-org/divider";
import {Button} from "@nextui-org/react";
import ConfirmationModal from "@/app/(site)/components/ConfirmationModal";
import TrashIcon from "@/app/(site)/components/icons/TrashIcon";
import DreamView from "@/app/(site)/(internal)/dashboard/components/dreams/card/DreamView";

type Props = {
dream: Dream,
Expand All @@ -19,25 +20,11 @@ type Props = {
onDelete?: () => void,
}

const FetchFullDream = (dream: Dream, modalOpen: boolean) => {
return useSWR(modalOpen && `/api/me/dreams/${dream.id}?tags=true&characters=true`, fetcher<DreamWithRelations | null>, {refreshInterval: 0})
}


const DreamModal: FC<Props> = ({dream, isOpen, onClose, onDelete}) => {
const {data: fullDream, error: fullDreamError} = FetchFullDream(dream, isOpen ?? false)
const [deleteModalOpen, setDeleteModalOpen] = useState(false)

const tagChips = useMemo(() => fullDream?.tags?.map(tag => (
<Chip key={tag.id} color="primary" variant="flat" size="sm">
{tag.tag}
</Chip>
)), [fullDream?.tags])

useEffect(() => {
if (fullDreamError)
console.error(fullDreamError)
}, [fullDreamError])

return (
<Fragment>
<ConfirmationModal
Expand All @@ -51,28 +38,13 @@ const DreamModal: FC<Props> = ({dream, isOpen, onClose, onDelete}) => {
</ConfirmationModal>
<Modal
size="2xl"
header={
<Fragment>
{(tagChips || fullDreamError) && (
<div className="flex flex-wrap gap-2 mb-3">
{tagChips ?? (fullDreamError &&
<Chip color="danger" variant="flat" size="sm">
Error Loading Tags
</Chip>
)}
</div>
)}
<h1 className="text-4xl phone:text-2xl">{dream.title}</h1>
<h3 className="text-subtext text-sm font-semibold italic">{dream.comments}</h3>
<h3 className="text-subtext text-xs italic">~{calcEstimatedReadingTime(dream.description)} min.
read</h3>
</Fragment>
}
isOpen={isOpen}
onClose={onClose}
>
<article
className="text-[#EAE0FF] phone:text-sm whitespace-pre-wrap rounded-3xl border border-primary/40 bg-[#0C0015]/50 p-6">{dream.description}</article>
<DreamView
dream={dream}
fetchDream={isOpen}
/>
<Divider className="my-6"/>
<div className="flex justify-end">
<Button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client"

import {FC, Fragment, useEffect, useMemo, useState} from "react";
import {Dream} from "@prisma/client";
import {DreamWithRelations, PatchDreamDto} from "@/app/api/me/dreams/dreams.dto";
import {calcEstimatedReadingTime, fetcher} from "@/utils/client/client-utils";
import useSWR from "swr";
import {Chip} from "@nextui-org/chip";
import EditableInput from "@/app/(site)/components/EditableInput";
import {Button} from "@nextui-org/button";

type Props = {
dream: Dream,
fetchDream?: boolean,
onEdit?: (dto: PatchDreamDto) => void
}

const FetchFullDream = (dream: Dream, doFetch: boolean) => {
return useSWR(doFetch && `/api/me/dreams/${dream.id}?tags=true&characters=true`, fetcher<DreamWithRelations | null>, {refreshInterval: 0})
}

const DreamView: FC<Props> = ({dream, fetchDream, onEdit}) => {
const {data: fullDream, error: fullDreamError} = FetchFullDream(dream, fetchDream ?? true)
const [editMode, setEditMode] = useState(false)

const tagChips = useMemo(() => fullDream?.tags?.map(tag => (
<Chip key={tag.id} color="primary" variant="flat" size="sm">
{tag.tag}
</Chip>
)), [fullDream?.tags])

useEffect(() => {
if (fullDreamError)
console.error(fullDreamError)
}, [fullDreamError])

return (
<Fragment>
<Button onPress={() => setEditMode(prev => !prev)}>
Toggle Edit Mode
</Button>
{(tagChips || fullDreamError) && (
<div className="flex flex-wrap gap-2 mb-3">
{tagChips ?? (fullDreamError &&
<Chip color="danger" variant="flat" size="sm">
Error Loading Tags
</Chip>
)}
</div>
)}
<EditableInput
isEditable={editMode}
label="Edit Dream Title"
value={dream.title}
onEdit={(value) => {
console.log(value)
}}
>
<h1 className="text-4xl phone:text-2xl font-bold">{dream.title}</h1>
</EditableInput>
<h3 className="text-subtext text-sm font-semibold italic">{dream.comments}</h3>
<h3 className="text-subtext text-xs italic">~{calcEstimatedReadingTime(dream.description)} min.
read</h3>
<article
className="text-[#EAE0FF] phone:text-sm whitespace-pre-wrap rounded-3xl border border-primary/40 bg-[#0C0015]/50 p-6 mt-6">{dream.description}</article>
</Fragment>
)
}

export default DreamView
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,39 @@ const useDreams = (): DreamsState => {
})
}, [dreams, mutateDreams])

const editOptimisticDream = useCallback<OptimisticWorker<Dream>>(async (work, editedOptimisticDream) => {
if (!dreams)
return

const mutate = mutateDreams as KeyedMutator<Dream[]>

const doUpdate = (editedDream: Dream): Dream[] => {
const newArr = dreams.filter(dream => dream.id !== editedDream.id)
newArr.push(editedDream)
return newArr
}

const doWork = async (): Promise<Dream[]> => {
const updatedDream = await work()
if (!updatedDream)
return dreams
return doUpdate(updatedDream)
}

await mutate(doWork, {
optimisticData: doUpdate(editedOptimisticDream),
rollbackOnError: true,
})
}, [dreams, mutateDreams])

return {
data: dreams ?? [],
loading: dreamsLoading,
mutateData: dreams ? (mutateDreams as KeyedMutator<Dream[]>) : undefined,
optimisticData: {
addOptimisticData: addOptimisticDream,
removeOptimisticData: removeOptimisticDream
removeOptimisticData: removeOptimisticDream,
editOptimisticData: editOptimisticDream
}
}
}
Expand Down
110 changes: 110 additions & 0 deletions src/app/(site)/components/EditableInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client"

import {FC, PropsWithChildren, useCallback, useEffect, useState} from "react";
import {InputProps} from "@nextui-org/react";
import {AnimatePresence, motion} from "framer-motion";
import Input from "@/app/(site)/components/Input";
import {Button} from "@nextui-org/button";
import {SubmitHandler, useForm} from "react-hook-form";
import CheckIcon from "@/app/(site)/components/icons/CheckIcon";
import CloseIcon from "@/app/(site)/components/icons/CloseIcon";

type FormProps = {
value: string
}

type Props = {
isEditable?: boolean,
value?: string,
onEdit?: (value: string) => void
} & Pick<InputProps, "classNames" | "placeholder" | "label"> & PropsWithChildren

const EditableInput: FC<Props> = ({isEditable, value, children, onEdit, ...inputProps}) => {
const {register, handleSubmit} = useForm<FormProps>()
const [currentValue, setCurrentValue] = useState(value ?? "")
const [editToggled, setEditToggled] = useState(false)

useEffect(() => {
if (isEditable)
setCurrentValue(value ?? "")
else setEditToggled(false)
}, [isEditable, value])

const onSubmit: SubmitHandler<FormProps> = useCallback((data) => {
if (data.value === value)
return setEditToggled(false)

if (onEdit)
onEdit(data.value)
setEditToggled(false)
}, [onEdit, value])

return (
<AnimatePresence>
{isEditable ? (editToggled ?
<motion.form
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 1}}
onSubmit={handleSubmit(onSubmit)}
>
<Input
isRequired
{...inputProps}
register={register}
id={"value"}
value={currentValue}
onValueChange={setCurrentValue}
endContent={
<div className="flex gap-2">
<Button
isIconOnly
color="success"
variant="light"
type="submit"
>
<CheckIcon/>
</Button>
<Button
isIconOnly
color="danger"
variant="light"
onPress={() => {
setEditToggled(false)
setCurrentValue(value ?? "")
}}
>
<CloseIcon/>
</Button>
</div>
}
/>

</motion.form>
:
<motion.div
className="cursor-pointer"
onClick={() => setEditToggled(true)}
initial={{
color: "#EAE0FF"
}}
whileHover={{
color: "#9E23FF"
}}
>
{children}
</motion.div>
) : (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 1}}
>
{children}
</motion.div>
)}
</AnimatePresence>
)
}

export default EditableInput
5 changes: 3 additions & 2 deletions src/app/(site)/components/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@ const Providers: FC<Props> = ({children, session}) => {
return (
<SWRConfig value={{
refreshInterval: 60 * 1000,
revalidateOnFocus: false
revalidateOnFocus: false,
provider: () => new Map()
}}>
<NextUIProvider>
<ThemeProvider attribute="class" defaultTheme="dark">
<SessionProvider session={session}>
<AppProgressBar
height="4px"
color="#9E23FF"
options={{ showSpinner: true }}
options={{showSpinner: true}}
shallowRouting
/>
<Toaster
Expand Down
12 changes: 12 additions & 0 deletions src/utils/client/client-data-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
import {KeyedMutator} from "swr";
import {Context, createContext, useContext} from "react";

/**
* `T - State Data Type`, `O - Optimistic Data Type`
* The reason it's setup this way is due to the state data type and the optimistic data
* type not being exactly the same.
* For example, the state may be `T` while optimistic data may be `T | undefined`.
*
* Example
* ```
* type MyContextState = DataContextState<MyState[], MyState>
* ```
*/
export type DataContextState<T, O> = {
loading: boolean,
data: T,
mutateData?: KeyedMutator<T>,
optimisticData: {
addOptimisticData: OptimisticWorker<O>,
removeOptimisticData: OptimisticWorker<O>,
editOptimisticData?: OptimisticWorker<O>
}
}

Expand Down

0 comments on commit e560535

Please sign in to comment.