Show participated polls on polls page + UI refresh (#1089)

This commit is contained in:
Luke Vella 2024-05-12 13:20:00 +08:00 committed by GitHub
parent bd9e9fe95b
commit f8a217ae75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
125 changed files with 3007 additions and 2363 deletions

View file

@ -1,19 +1,36 @@
"use client";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import { Textarea } from "@rallly/ui/textarea";
import dayjs from "dayjs";
import { MessageCircleIcon, MoreHorizontalIcon, TrashIcon } from "lucide-react";
import {
MessageSquareOffIcon,
MoreHorizontalIcon,
TrashIcon,
} from "lucide-react";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useParticipants } from "@/components/participants-provider";
import { Trans } from "@/components/trans";
import { usePermissions } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { useRole } from "@/contexts/role";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
@ -22,7 +39,6 @@ import { requiredString } from "../../utils/form-validation";
import NameInput from "../name-input";
import TruncatedLinkify from "../poll/truncated-linkify";
import UserAvatar from "../poll/user-avatar";
import { usePoll } from "../poll-context";
import { useUser } from "../user-provider";
interface CommentForm {
@ -30,9 +46,107 @@ interface CommentForm {
content: string;
}
const Discussion: React.FunctionComponent = () => {
function NewCommentForm({
onSubmit,
onCancel,
}: {
onSubmit?: () => void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
const { poll } = usePoll();
const poll = usePoll();
const { user } = useUser();
const { participants } = useParticipants();
const authorName = React.useMemo(() => {
if (user.isGuest) {
const participant = participants.find((p) => p.userId === user.id);
return participant?.name ?? "";
} else {
return user.name;
}
}, [user, participants]);
const pollId = poll.id;
const posthog = usePostHog();
const queryClient = trpc.useUtils();
const addComment = trpc.polls.comments.add.useMutation({
onSuccess: () => {
queryClient.polls.comments.invalidate();
posthog?.capture("created comment");
},
});
const session = useUser();
const { register, reset, control, handleSubmit, formState } =
useForm<CommentForm>({
defaultValues: {
authorName,
content: "",
},
});
return (
<form
className="w-full space-y-2.5"
onSubmit={handleSubmit(async ({ authorName, content }) => {
await addComment.mutateAsync({ authorName, content, pollId });
reset({ authorName, content: "" });
onSubmit?.();
})}
>
<div>
<Textarea
id="comment"
className="w-full"
autoFocus={true}
placeholder={t("commentPlaceholder")}
{...register("content", { validate: requiredString })}
/>
</div>
<div
className={cn("mb-2", {
hidden: !user.isGuest,
})}
>
<Controller
name="authorName"
key={session.user?.id}
control={control}
rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput error={!!formState.errors.authorName} {...field} />
)}
/>
</div>
<div className="flex gap-2.5">
<Button
type="submit"
variant="primary"
loading={formState.isSubmitting}
>
<Trans defaults="Add Comment" i18nKey="addComment" />
</Button>
<Button
onClick={() => {
reset();
onCancel?.();
}}
>
{t("cancel")}
</Button>
</div>
</form>
);
}
function DiscussionInner() {
const { t } = useTranslation();
const poll = usePoll();
const pollId = poll.id;
@ -46,13 +160,6 @@ const Discussion: React.FunctionComponent = () => {
const queryClient = trpc.useUtils();
const addComment = trpc.polls.comments.add.useMutation({
onSuccess: () => {
queryClient.polls.comments.invalidate();
posthog?.capture("created comment");
},
});
const deleteComment = trpc.polls.comments.delete.useMutation({
onMutate: ({ commentId }) => {
queryClient.polls.comments.list.setData(
@ -69,14 +176,6 @@ const Discussion: React.FunctionComponent = () => {
const session = useUser();
const { register, reset, control, handleSubmit, formState } =
useForm<CommentForm>({
defaultValues: {
authorName: "",
content: "",
},
});
const [isWriting, setIsWriting] = React.useState(false);
const role = useRole();
const { isUser } = usePermissions();
@ -86,124 +185,109 @@ const Discussion: React.FunctionComponent = () => {
}
return (
<div className="divide-y">
<div className="flex items-center gap-2 bg-gray-50 px-4 py-3 font-semibold">
<MessageCircleIcon className="size-5" /> {t("comments")} (
{comments.length})
</div>
<Card>
<CardHeader>
<CardTitle>
{t("comments")}
<Badge>{comments.length}</Badge>
</CardTitle>
</CardHeader>
{comments.length ? (
<div className="space-y-4 p-4">
{comments.map((comment) => {
const canDelete =
role === "admin" || (comment.userId && isUser(comment.userId));
<CardContent className="border-b">
<div className="space-y-4">
{comments.map((comment) => {
const canDelete =
role === "admin" || (comment.userId && isUser(comment.userId));
return (
<div className="" key={comment.id}>
<div data-testid="comment">
<div className="mb-1 flex items-center space-x-2">
<UserAvatar
name={comment.authorName}
showName={true}
isYou={session.ownsObject(comment)}
/>
<div className="flex items-center gap-2 text-sm ">
<div className="text-gray-500">
{dayjs(comment.createdAt).fromNow()}
return (
<div className="" key={comment.id}>
<div data-testid="comment">
<div className="mb-1 flex items-center space-x-2">
<UserAvatar
name={comment.authorName}
showName={true}
isYou={session.ownsObject(comment)}
/>
<div className="flex items-center gap-2 text-sm ">
<div className="text-gray-500">
{dayjs(comment.createdAt).fromNow()}
</div>
{canDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button className="hover:text-foreground text-gray-500">
<MoreHorizontalIcon className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
className="text-destructive"
onSelect={() => {
deleteComment.mutate({
commentId: comment.id,
});
}}
>
<TrashIcon className="size-4" />
<Trans i18nKey="delete" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{canDelete && (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<button className="hover:text-foreground text-gray-500">
<MoreHorizontalIcon className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
deleteComment.mutate({
commentId: comment.id,
});
}}
>
<TrashIcon className="mr-2 size-4" />
<Trans i18nKey="delete" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="ml-0.5 w-fit whitespace-pre-wrap pl-7 text-sm leading-relaxed">
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div>
</div>
<div className="ml-0.5 w-fit whitespace-pre-wrap pl-8 text-sm leading-relaxed">
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div>
</div>
</div>
);
})}
</div>
) : null}
<div className="p-3">
{isWriting ? (
<form
className="space-y-2.5"
onSubmit={handleSubmit(async ({ authorName, content }) => {
await addComment.mutateAsync({ authorName, content, pollId });
reset({ authorName, content: "" });
setIsWriting(false);
);
})}
>
<div>
<Textarea
id="comment"
className="w-full"
autoFocus={true}
placeholder={t("commentPlaceholder")}
{...register("content", { validate: requiredString })}
/>
</div>
<div className="mb-2">
<Controller
name="authorName"
key={session.user?.id}
control={control}
rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput error={!!formState.errors.authorName} {...field} />
)}
/>
</div>
<div className="flex justify-between gap-2">
<Button
onClick={() => {
reset();
setIsWriting(false);
}}
>
{t("cancel")}
</Button>
<Button
type="submit"
variant="primary"
loading={formState.isSubmitting}
>
<Trans defaults="Add Comment" i18nKey="addComment" />
</Button>
</div>
</form>
) : (
<button
className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-3 py-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
onClick={() => setIsWriting(true)}
>
<Trans
i18nKey="commentPlaceholder"
defaults="Leave a comment on this poll (visible to everyone)"
</div>
</CardContent>
) : null}
{!poll.event ? (
<CardFooter className="border-t-0">
{isWriting ? (
<NewCommentForm
onSubmit={() => {
setIsWriting(false);
}}
onCancel={() => {
setIsWriting(false);
}}
/>
</button>
)}
</div>
</div>
) : (
<button
className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-2 py-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
onClick={() => setIsWriting(true)}
>
<Trans
i18nKey="commentPlaceholder"
defaults="Leave a comment on this poll (visible to everyone)"
/>
</button>
)}
</CardFooter>
) : null}
</Card>
);
};
}
export default React.memo(Discussion);
export default function Discussion() {
const poll = usePoll();
if (poll.disableComments) {
return (
<p className="text-muted-foreground rounded-lg bg-gray-100 p-4 text-center text-sm">
<Icon>
<MessageSquareOffIcon className="mr-2 inline-block" />
</Icon>
<Trans
i18nKey="commentsDisabled"
defaults="Comments have been disabled"
/>
</p>
);
}
return <DiscussionInner />;
}