Begin integration of dotnet backend with biergarten nextjs code

This commit is contained in:
Aaron Po
2026-01-26 18:52:16 -05:00
parent 3d8b17320a
commit 7dc7ef4b1a
345 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import { FC, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { z } from 'zod';
import CommentContentBody from './CommentContentBody';
import EditCommentBody from './EditCommentBody';
import UserAvatar from '../Account/UserAvatar';
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
interface CommentCardProps {
comment: z.infer<typeof CommentQueryResult>;
mutate: ReturnType<typeof useBeerPostComments>['mutate'];
ref?: ReturnType<typeof useInView>['ref'];
handleDeleteCommentRequest: HandleDeleteCommentRequest;
handleEditCommentRequest: HandleEditCommentRequest;
}
const CommentCardBody: FC<CommentCardProps> = ({
comment,
mutate,
ref,
handleDeleteCommentRequest,
handleEditCommentRequest,
}) => {
const [inEditMode, setInEditMode] = useState(false);
return (
<div ref={ref} className="flex items-start">
<div className="mx-3 w-[20%] justify-center sm:w-[12%]">
<div className="h-20 pt-4">
<UserAvatar user={comment.postedBy} />
</div>
</div>
<div className="h-full w-[88%]">
{!inEditMode ? (
<CommentContentBody comment={comment} setInEditMode={setInEditMode} />
) : (
<EditCommentBody
comment={comment}
mutate={mutate}
setInEditMode={setInEditMode}
handleDeleteCommentRequest={handleDeleteCommentRequest}
handleEditCommentRequest={handleEditCommentRequest}
/>
)}
</div>
</div>
);
};
export default CommentCardBody;

View File

@@ -0,0 +1,55 @@
import UserContext from '@/contexts/UserContext';
import { Dispatch, SetStateAction, FC, useContext } from 'react';
import { FaEllipsisH } from 'react-icons/fa';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import { z } from 'zod';
interface CommentCardDropdownProps {
comment: z.infer<typeof CommentQueryResult>;
setInEditMode: Dispatch<SetStateAction<boolean>>;
}
const CommentCardDropdown: FC<CommentCardDropdownProps> = ({
comment,
setInEditMode,
}) => {
const { user } = useContext(UserContext);
const isCommentOwner = user?.id === comment.postedBy.id;
return (
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-sm m-1">
<FaEllipsisH />
</label>
<ul
tabIndex={0}
className="menu dropdown-content rounded-box w-52 bg-base-100 p-2 shadow"
>
<li>
{isCommentOwner ? (
<button
type="button"
onClick={() => {
setInEditMode(true);
}}
>
Edit
</button>
) : (
<button
type="button"
onClick={() => {
// eslint-disable-next-line no-alert
alert('This feature is not yet implemented.');
}}
>
Report
</button>
)}
</li>
</ul>
</div>
);
};
export default CommentCardDropdown;

View File

@@ -0,0 +1,77 @@
import UserContext from '@/contexts/UserContext';
import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import { format } from 'date-fns';
import { Dispatch, FC, SetStateAction, useContext } from 'react';
import { Rating } from 'react-daisyui';
import Link from 'next/link';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import { z } from 'zod';
import { useInView } from 'react-intersection-observer';
import classNames from 'classnames';
import CommentCardDropdown from './CommentCardDropdown';
interface CommentContentBodyProps {
comment: z.infer<typeof CommentQueryResult>;
setInEditMode: Dispatch<SetStateAction<boolean>>;
}
const CommentContentBody: FC<CommentContentBodyProps> = ({ comment, setInEditMode }) => {
const { user } = useContext(UserContext);
const timeDistance = useTimeDistance(new Date(comment.createdAt));
const [ref, inView] = useInView({ triggerOnce: true });
return (
<div
className={classNames('space-y-1 py-4 pr-3 fade-in-10', {
'opacity-0': !inView,
'animate-fade': inView,
})}
ref={ref}
>
<div className="space-y-2">
<div className="flex flex-row justify-between">
<div>
<p className="font-semibold sm:text-2xl">
<Link href={`/users/${comment.postedBy.id}`} className="link-hover link">
{comment.postedBy.username}
</Link>
</p>
<span className="italic">
posted{' '}
<time
className="tooltip tooltip-bottom"
data-tip={format(new Date(comment.createdAt), 'MM/dd/yyyy')}
>
{timeDistance}
</time>{' '}
ago
</span>
</div>
{user && (
<CommentCardDropdown comment={comment} setInEditMode={setInEditMode} />
)}
</div>
<div className="space-y-1">
<Rating value={comment.rating}>
{Array.from({ length: 5 }).map((val, index) => (
<Rating.Item
name="rating-1"
className="mask mask-star cursor-default"
disabled
aria-disabled
key={index}
/>
))}
</Rating>
</div>
</div>
<div>
<p className="text-sm">{comment.content}</p>
</div>
</div>
);
};
export default CommentContentBody;

View File

@@ -0,0 +1,85 @@
import { FC } from 'react';
import { Rating } from 'react-daisyui';
import type {
FormState,
SubmitHandler,
UseFormHandleSubmit,
UseFormRegister,
UseFormSetValue,
UseFormWatch,
} from 'react-hook-form';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
import FormSegment from '../ui/forms/FormSegment';
import FormTextArea from '../ui/forms/FormTextArea';
import Button from '../ui/forms/Button';
interface Comment {
content: string;
rating: number;
}
interface CommentFormProps {
handleSubmit: UseFormHandleSubmit<Comment>;
onSubmit: SubmitHandler<Comment>;
watch: UseFormWatch<Comment>;
setValue: UseFormSetValue<Comment>;
formState: FormState<Comment>;
register: UseFormRegister<Comment>;
}
const CommentForm: FC<CommentFormProps> = ({
handleSubmit,
onSubmit,
watch,
setValue,
formState,
register,
}) => {
const { errors } = formState;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div>
<FormInfo>
<FormLabel htmlFor="content">Leave a comment</FormLabel>
<FormError>{errors.content?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
id="content"
formValidationSchema={register('content')}
placeholder="Comment"
rows={5}
error={!!errors.content?.message}
disabled={formState.isSubmitting}
/>
</FormSegment>
<FormInfo>
<FormLabel htmlFor="rating">Rating</FormLabel>
<FormError>{errors.rating?.message}</FormError>
</FormInfo>
<Rating
value={watch('rating')}
onChange={(value) => {
setValue('rating', value);
}}
>
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
<Rating.Item name="rating-1" className="mask mask-star" />
</Rating>
</div>
<div>
<Button type="submit" isSubmitting={formState.isSubmitting}>
Submit
</Button>
</div>
</form>
);
};
export default CommentForm;

View File

@@ -0,0 +1,19 @@
const CommentLoadingCardBody = () => {
return (
<div className="card-body h-52 fade-in-10">
<div className="flex animate-pulse space-x-4 slide-in-from-top">
<div className="flex-1 space-y-4 py-1">
<div className="h-4 w-3/4 rounded bg-base-100" />
<div className="space-y-2">
<div className="h-4 rounded bg-base-100" />
<div className="h-4 w-11/12 rounded bg-base-100" />
<div className="h-4 w-10/12 rounded bg-base-100" />
<div className="h-4 w-11/12 rounded bg-base-100" />
</div>
</div>
</div>
</div>
);
};
export default CommentLoadingCardBody;

View File

@@ -0,0 +1,22 @@
import { FC } from 'react';
import Spinner from '../ui/Spinner';
import CommentLoadingCardBody from './CommentLoadingCardBody';
interface CommentLoadingComponentProps {
length: number;
}
const CommentLoadingComponent: FC<CommentLoadingComponentProps> = ({ length }) => {
return (
<>
{Array.from({ length }).map((_, i) => (
<CommentLoadingCardBody key={i} />
))}
<div className="p-1">
<Spinner size="sm" />
</div>
</>
);
};
export default CommentLoadingComponent;

View File

@@ -0,0 +1,125 @@
import { FC, MutableRefObject } from 'react';
import { FaArrowUp } from 'react-icons/fa';
import { useInView } from 'react-intersection-observer';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import useBeerStyleComments from '@/hooks/data-fetching/beer-style-comments/useBeerStyleComments';
import NoCommentsCard from './NoCommentsCard';
import CommentLoadingComponent from './CommentLoadingComponent';
import CommentCardBody from './CommentCardBody';
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
type HookReturnType = ReturnType<
typeof useBeerPostComments | typeof useBreweryPostComments | typeof useBeerStyleComments
>;
interface CommentsComponentProps {
comments: HookReturnType['comments'];
isAtEnd: HookReturnType['isAtEnd'];
isLoadingMore: HookReturnType['isLoadingMore'];
mutate: HookReturnType['mutate'];
setSize: HookReturnType['setSize'];
size: HookReturnType['size'];
commentSectionRef: MutableRefObject<HTMLDivElement | null>;
handleDeleteCommentRequest: HandleDeleteCommentRequest;
handleEditCommentRequest: HandleEditCommentRequest;
pageSize: number;
}
const CommentsComponent: FC<CommentsComponentProps> = ({
comments,
commentSectionRef,
handleDeleteCommentRequest,
handleEditCommentRequest,
isAtEnd,
isLoadingMore,
mutate,
pageSize,
setSize,
size,
}) => {
const { ref: penultimateCommentRef } = useInView({
threshold: 0.1,
/**
* When the last comment comes into view, call setSize from the comment fetching hook
* to load more comments.
*/
onChange: (visible) => {
if (!visible || isAtEnd) return;
setSize(size + 1);
},
});
return (
<>
{!!comments.length && (
<div className="card h-full bg-base-300 pb-6">
{comments.map((comment, index) => {
const isLastComment = index === comments.length - 1;
/**
* Attach a ref to the last comment in the list. When it comes into view, the
* component will call setSize to load more comments.
*/
return (
<div
ref={isLastComment ? penultimateCommentRef : undefined}
key={comment.id}
>
<CommentCardBody
comment={comment}
mutate={mutate}
handleDeleteCommentRequest={handleDeleteCommentRequest}
handleEditCommentRequest={handleEditCommentRequest}
/>
</div>
);
})}
{
/**
* If there are more comments to load, show a loading component with a
* skeleton loader and a loading spinner.
*/
!!isLoadingMore && <CommentLoadingComponent length={pageSize} />
}
{
/**
* If the user has scrolled to the end of the comments, show a button that
* will scroll them back to the top of the comments section.
*/
!!isAtEnd && (
<div className="flex h-20 items-center justify-center text-center">
<div
className="tooltip tooltip-bottom"
data-tip="Scroll back to top of comments."
>
<button
type="button"
className="btn btn-ghost btn-sm"
aria-label="Scroll back to top of comments"
onClick={() => {
commentSectionRef.current?.scrollIntoView({
behavior: 'smooth',
});
}}
>
<FaArrowUp />
</button>
</div>
</div>
)
}
</div>
)}
{!comments.length && <NoCommentsCard />}
</>
);
};
export default CommentsComponent;

View File

@@ -0,0 +1,168 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useState, Dispatch, SetStateAction } from 'react';
import { Rating } from 'react-daisyui';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';
import useBeerPostComments from '@/hooks/data-fetching/beer-comments/useBeerPostComments';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
import useBreweryPostComments from '@/hooks/data-fetching/brewery-comments/useBreweryPostComments';
import toast from 'react-hot-toast';
import createErrorToast from '@/util/createErrorToast';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
import FormSegment from '../ui/forms/FormSegment';
import FormTextArea from '../ui/forms/FormTextArea';
import { HandleDeleteCommentRequest, HandleEditCommentRequest } from './types';
import { useInView } from 'react-intersection-observer';
import classNames from 'classnames';
interface EditCommentBodyProps {
comment: z.infer<typeof CommentQueryResult>;
setInEditMode: Dispatch<SetStateAction<boolean>>;
mutate: ReturnType<
typeof useBeerPostComments | typeof useBreweryPostComments
>['mutate'];
handleDeleteCommentRequest: HandleDeleteCommentRequest;
handleEditCommentRequest: HandleEditCommentRequest;
}
const EditCommentBody: FC<EditCommentBodyProps> = ({
comment,
setInEditMode,
mutate,
handleDeleteCommentRequest,
handleEditCommentRequest,
}) => {
const { register, handleSubmit, formState, setValue, watch } = useForm<
z.infer<typeof CreateCommentValidationSchema>
>({
defaultValues: { content: comment.content, rating: comment.rating },
resolver: zodResolver(CreateCommentValidationSchema),
});
const { errors, isSubmitting } = formState;
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {
const loadingToast = toast.loading('Deleting comment...');
setIsDeleting(true);
try {
await handleDeleteCommentRequest(comment.id);
await mutate();
toast.remove(loadingToast);
toast.success('Deleted comment.');
} catch (error) {
toast.remove(loadingToast);
createErrorToast(error);
}
};
const onEdit: SubmitHandler<z.infer<typeof CreateCommentValidationSchema>> = async (
data,
) => {
const loadingToast = toast.loading('Submitting comment edits...');
try {
setInEditMode(true);
await handleEditCommentRequest(comment.id, data);
await mutate();
toast.remove(loadingToast);
toast.success('Comment edits submitted successfully.');
setInEditMode(false);
} catch (error) {
toast.remove(loadingToast);
createErrorToast(error);
setInEditMode(false);
}
};
const disableForm = isSubmitting || isDeleting;
const [ref, inView] = useInView({ triggerOnce: true });
return (
<div
className={classNames('py-4 pr-3 animate-in fade-in-10', {
'opacity-0': !inView,
'animate-fade': inView,
})}
ref={ref}
>
<form onSubmit={handleSubmit(onEdit)} className="space-y-3">
<div>
<FormInfo>
<FormLabel htmlFor="content">Edit your comment</FormLabel>
<FormError>{errors.content?.message}</FormError>
</FormInfo>
<FormSegment>
<FormTextArea
id="content"
formValidationSchema={register('content')}
placeholder="Comment"
rows={2}
error={!!errors.content?.message}
disabled={disableForm}
/>
</FormSegment>
<div className="flex flex-row items-center justify-between">
<div>
<FormInfo>
<FormLabel htmlFor="rating">Change your rating</FormLabel>
<FormError>{errors.rating?.message}</FormError>
</FormInfo>
<Rating
value={watch('rating')}
onChange={(value) => {
setValue('rating', value);
}}
>
{Array.from({ length: 5 }).map((val, index) => (
<Rating.Item
name="rating-1"
className="mask mask-star cursor-default"
disabled={disableForm}
aria-disabled={disableForm}
key={index}
/>
))}
</Rating>
</div>
<div className="join">
<button
type="button"
className="btn join-item btn-xs lg:btn-sm"
disabled={disableForm}
onClick={() => {
setInEditMode(false);
}}
>
Cancel
</button>
<button
type="submit"
disabled={disableForm}
className="btn join-item btn-xs lg:btn-sm"
>
Save
</button>
<button
type="button"
className="btn join-item btn-xs lg:btn-sm"
onClick={onDelete}
disabled={disableForm}
>
Delete
</button>
</div>
</div>
</div>
</form>
</div>
);
};
export default EditCommentBody;

View File

@@ -0,0 +1,13 @@
const NoCommentsCard = () => {
return (
<div className="card bg-base-300">
<div className="card-body h-64">
<div className="flex h-full flex-col items-center justify-center">
<span className="text-lg font-bold">No comments yet.</span>
</div>
</div>
</div>
);
};
export default NoCommentsCard;

View File

@@ -0,0 +1,12 @@
import CreateCommentValidationSchema from '@/services/schema/CommentSchema/CreateCommentValidationSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
type APIResponse = z.infer<typeof APIResponseValidationSchema>;
export type HandleEditCommentRequest = (
id: string,
data: z.infer<typeof CreateCommentValidationSchema>,
) => Promise<APIResponse>;
export type HandleDeleteCommentRequest = (id: string) => Promise<APIResponse>;