mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 09:46:39 +02:00
♻️ Refactor poll feature (#1671)
This commit is contained in:
parent
1ad5f7019a
commit
44faca3ccf
13 changed files with 85 additions and 164 deletions
|
@ -21,7 +21,7 @@ export default async function Layout({
|
|||
<AppSidebarProvider>
|
||||
<CommandMenu />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SidebarInset className="min-w-0">
|
||||
<TopBar className="sm:hidden">
|
||||
<TopBarLeft>
|
||||
<SidebarTrigger />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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}
|
|
@ -47,7 +47,9 @@ export const ParticipantAvatarBar = ({
|
|||
/>
|
||||
</li>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{participant.name}</TooltipContent>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{participant.name}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
41
apps/web/src/features/poll/components/poll-list.tsx
Normal file
41
apps/web/src/features/poll/components/poll-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
5
apps/web/src/features/poll/schema.ts
Normal file
5
apps/web/src/features/poll/schema.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const pollStatusSchema = z.enum(["live", "paused", "finalized"]);
|
||||
|
||||
export type PollStatus = z.infer<typeof pollStatusSchema>;
|
Loading…
Add table
Reference in a new issue