♻️ Refactor poll feature (#1671)

This commit is contained in:
Luke Vella 2025-04-16 11:38:42 +01:00 committed by GitHub
parent 1ad5f7019a
commit 44faca3ccf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 85 additions and 164 deletions

View file

@ -21,7 +21,7 @@ export default async function Layout({
<AppSidebarProvider>
<CommandMenu />
<AppSidebar />
<SidebarInset>
<SidebarInset className="min-w-0">
<TopBar className="sm:hidden">
<TopBarLeft>
<SidebarTrigger />

View file

@ -13,7 +13,6 @@ import {
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { CopyLinkButton } from "@/components/copy-link-button";
import {
EmptyState,
EmptyStateDescription,
@ -22,22 +21,16 @@ import {
EmptyStateTitle,
} from "@/components/empty-state";
import { Pagination } from "@/components/pagination";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { PollStatusIcon } from "@/components/poll-status-icon";
import {
StackedList,
StackedListItem,
StackedListItemContent,
} from "@/components/stacked-list";
import { Trans } from "@/components/trans";
import { getPolls } from "@/data/get-polls";
import { getPolls } from "@/features/poll/api/get-polls";
import { PollList, PollListItem } from "@/features/poll/components/poll-list";
import { getTranslation } from "@/i18n/server";
import { requireUser } from "@/next-auth";
import { SearchInput } from "../../../components/search-input";
import { PollsTabbedView } from "./polls-tabbed-view";
import { SearchInput } from "./search-input";
const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_PAGE_SIZE = 10;
const pageSchema = z
.string()
@ -133,6 +126,7 @@ export default async function Page({
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const { t } = await getTranslation();
const { userId } = await requireUser();
const parsedStatus = statusSchema.parse(searchParams.status);
@ -176,41 +170,26 @@ export default async function Page({
<PageContent className="space-y-4">
<PollsTabbedView>
<div className="space-y-4">
<SearchInput />
<SearchInput
placeholder={t("searchPollsPlaceholder", {
defaultValue: "Search polls by title...",
})}
/>
{polls.length === 0 ? (
<PollsEmptyState />
) : (
<>
<StackedList className="overflow-hidden">
<PollList>
{polls.map((poll) => (
<StackedListItem
className="relative hover:bg-gray-50"
<PollListItem
key={poll.id}
>
<div className="flex items-center gap-4">
<StackedListItemContent className="relative flex min-w-0 flex-1 items-center gap-2">
<PollStatusIcon status={poll.status} />
<Link
className="focus:ring-ring truncate text-sm font-medium hover:underline focus-visible:ring-2"
href={`/poll/${poll.id}`}
>
<span className="absolute inset-0" />
{poll.title}
</Link>
</StackedListItemContent>
<StackedListItemContent className="z-10 hidden items-center justify-end gap-4 sm:flex">
<ParticipantAvatarBar
participants={poll.participants}
max={5}
/>
<CopyLinkButton
href={shortUrl(`/invite/${poll.id}`)}
/>
</StackedListItemContent>
</div>
</StackedListItem>
title={poll.title}
status={poll.status}
participants={poll.participants}
inviteLink={shortUrl(`/invite/${poll.id}`)}
/>
))}
</StackedList>
</PollList>
{totalPages > 1 ? (
<Pagination
currentPage={parsedPage}

View file

@ -1,82 +0,0 @@
import type { PollStatus } from "@rallly/database";
import { Icon } from "@rallly/ui/icon";
import { UsersIcon } from "lucide-react";
import Link from "next/link";
import { ScheduledEventDisplay } from "@/components/poll/scheduled-event-display";
import { PollStatusIcon } from "@/components/poll-status-icon";
import { RelativeDate } from "@/components/relative-date";
import { Trans } from "@/components/trans";
type Poll = {
id: string;
title: string;
status: PollStatus;
createdAt: Date;
updatedAt: Date;
participants: {
id: string;
name: string;
image?: string;
}[];
dateOptions: {
first?: Date;
last?: Date;
count: number;
duration: number | number[];
};
event?: {
start: Date;
duration: number;
};
};
type PollCardProps = {
poll: Poll;
};
export const PollCard = ({ poll }: PollCardProps) => {
return (
<Link
href={`/poll/${poll.id}`}
className="group flex h-full flex-col rounded-lg border border-gray-200 bg-white p-4 hover:bg-gray-50"
>
<div className="mb-2 flex items-center justify-between">
<PollStatusIcon status={poll.status} />
<div className="text-muted-foreground text-xs">
<RelativeDate date={poll.updatedAt} />
</div>
</div>
<h3 className="mb-4 line-clamp-2 text-base font-medium text-gray-900 group-hover:underline">
{poll.title}
</h3>
<div className="mt-auto space-y-2">
<ScheduledEventDisplay
event={poll.event}
dateOptions={poll.dateOptions}
/>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Icon>
<UsersIcon />
</Icon>
<span>
{poll.participants.length}{" "}
<Trans i18nKey="participants" defaults="participants" />
</span>
</div>
</div>
</Link>
);
};
export const RecentlyUpdatedPollsGrid = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{children}
</div>
);
};

View file

@ -7,10 +7,7 @@ import { SearchIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { useTranslation } from "@/i18n/client";
export function SearchInput() {
const { t } = useTranslation();
export function SearchInput({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
@ -66,9 +63,7 @@ export function SearchInput() {
ref={inputRef}
type="search"
autoFocus={searchParams.get("q") !== null}
placeholder={t("searchPollsPlaceholder", {
defaultValue: "Search polls by title...",
})}
placeholder={placeholder}
className="pl-8"
value={inputValue}
onChange={handleChange}

View file

@ -47,7 +47,9 @@ export const ParticipantAvatarBar = ({
/>
</li>
</TooltipTrigger>
<TooltipContent>{participant.name}</TooltipContent>
<TooltipPortal>
<TooltipContent>{participant.name}</TooltipContent>
</TooltipPortal>
</Tooltip>
))}
{hiddenCount > 0 ? (

View file

@ -2,8 +2,7 @@ import type { PollStatus } from "@rallly/database";
import { cn } from "@rallly/ui";
import { Trans } from "@/components/trans";
import { PollStatusIcon } from "./poll-status-icon";
import { PollStatusIcon } from "@/features/poll/components/poll-status-icon";
export const PollStatusLabel = ({
status,

View file

@ -8,9 +8,9 @@ export function StackedList({
className?: string;
}) {
return (
<div className={cn("divide-y rounded-lg border", className)}>
<ul className={cn("divide-y overflow-hidden rounded-lg border", className)}>
{children}
</div>
</ul>
);
}
@ -21,15 +21,14 @@ export function StackedListItem({
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("p-1", className)}>{children}</div>;
}
export function StackedListItemContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("p-3", className)}>{children}</div>;
return (
<li
className={cn(
"flex items-center gap-x-6 p-4 hover:bg-gray-50",
className,
)}
>
{children}
</li>
);
}

View file

@ -1,19 +0,0 @@
import { prisma } from "@rallly/database";
export async function getPollCountByStatus(userId: string) {
const res = await prisma.poll.groupBy({
by: ["status"],
where: { userId, deleted: false },
_count: true,
});
const counts = res.reduce(
(acc, item) => {
acc[item.status] = item._count;
return acc;
},
{ live: 0, paused: 0, finalized: 0 },
);
return counts;
}

View file

@ -61,6 +61,7 @@ export async function getPolls({
select: {
id: true,
name: true,
email: true,
user: {
select: {
image: true,
@ -91,6 +92,7 @@ export async function getPolls({
participants: poll.participants.map((participant) => ({
id: participant.id,
name: participant.name,
email: participant.email ?? undefined,
image: participant.user?.image ?? undefined,
})),
event: poll.event ?? undefined,

View file

@ -0,0 +1,41 @@
import Link from "next/link";
import { CopyLinkButton } from "@/components/copy-link-button";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { StackedList, StackedListItem } from "@/components/stacked-list";
import { PollStatusIcon } from "@/features/poll/components/poll-status-icon";
import type { PollStatus } from "../schema";
export const PollList = StackedList;
export function PollListItem({
title,
status,
participants,
inviteLink,
}: {
title: string;
status: PollStatus;
participants: { id: string; name: string; image?: string }[];
inviteLink: string;
}) {
return (
<StackedListItem>
<div className="relative -m-4 flex min-w-0 flex-1 items-center gap-2 p-4">
<PollStatusIcon status={status} showTooltip={false} />
<Link
className="focus:ring-ring min-w-0 text-sm font-medium hover:underline focus-visible:ring-2"
href={inviteLink}
>
<span className="absolute inset-0" />
<span className="block truncate">{title}</span>
</Link>
</div>
<div className="hidden items-center justify-end gap-4 sm:flex">
<ParticipantAvatarBar participants={participants} max={5} />
<CopyLinkButton href={inviteLink} />
</div>
</StackedListItem>
);
}

View file

@ -0,0 +1,5 @@
import { z } from "zod";
export const pollStatusSchema = z.enum(["live", "paused", "finalized"]);
export type PollStatus = z.infer<typeof pollStatusSchema>;