mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +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>
|
<AppSidebarProvider>
|
||||||
<CommandMenu />
|
<CommandMenu />
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset className="min-w-0">
|
||||||
<TopBar className="sm:hidden">
|
<TopBar className="sm:hidden">
|
||||||
<TopBarLeft>
|
<TopBarLeft>
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
} from "@/app/components/page-layout";
|
} from "@/app/components/page-layout";
|
||||||
import { CopyLinkButton } from "@/components/copy-link-button";
|
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
EmptyStateDescription,
|
EmptyStateDescription,
|
||||||
|
@ -22,22 +21,16 @@ import {
|
||||||
EmptyStateTitle,
|
EmptyStateTitle,
|
||||||
} from "@/components/empty-state";
|
} from "@/components/empty-state";
|
||||||
import { Pagination } from "@/components/pagination";
|
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 { 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 { getTranslation } from "@/i18n/server";
|
||||||
import { requireUser } from "@/next-auth";
|
import { requireUser } from "@/next-auth";
|
||||||
|
|
||||||
|
import { SearchInput } from "../../../components/search-input";
|
||||||
import { PollsTabbedView } from "./polls-tabbed-view";
|
import { PollsTabbedView } from "./polls-tabbed-view";
|
||||||
import { SearchInput } from "./search-input";
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
const pageSchema = z
|
const pageSchema = z
|
||||||
.string()
|
.string()
|
||||||
|
@ -133,6 +126,7 @@ export default async function Page({
|
||||||
}: {
|
}: {
|
||||||
searchParams: { [key: string]: string | string[] | undefined };
|
searchParams: { [key: string]: string | string[] | undefined };
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = await getTranslation();
|
||||||
const { userId } = await requireUser();
|
const { userId } = await requireUser();
|
||||||
|
|
||||||
const parsedStatus = statusSchema.parse(searchParams.status);
|
const parsedStatus = statusSchema.parse(searchParams.status);
|
||||||
|
@ -176,41 +170,26 @@ export default async function Page({
|
||||||
<PageContent className="space-y-4">
|
<PageContent className="space-y-4">
|
||||||
<PollsTabbedView>
|
<PollsTabbedView>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SearchInput />
|
<SearchInput
|
||||||
|
placeholder={t("searchPollsPlaceholder", {
|
||||||
|
defaultValue: "Search polls by title...",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
{polls.length === 0 ? (
|
{polls.length === 0 ? (
|
||||||
<PollsEmptyState />
|
<PollsEmptyState />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<StackedList className="overflow-hidden">
|
<PollList>
|
||||||
{polls.map((poll) => (
|
{polls.map((poll) => (
|
||||||
<StackedListItem
|
<PollListItem
|
||||||
className="relative hover:bg-gray-50"
|
|
||||||
key={poll.id}
|
key={poll.id}
|
||||||
>
|
title={poll.title}
|
||||||
<div className="flex items-center gap-4">
|
status={poll.status}
|
||||||
<StackedListItemContent className="relative flex min-w-0 flex-1 items-center gap-2">
|
participants={poll.participants}
|
||||||
<PollStatusIcon status={poll.status} />
|
inviteLink={shortUrl(`/invite/${poll.id}`)}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</StackedList>
|
</PollList>
|
||||||
{totalPages > 1 ? (
|
{totalPages > 1 ? (
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={parsedPage}
|
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 { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "@/i18n/client";
|
export function SearchInput({ placeholder }: { placeholder: string }) {
|
||||||
|
|
||||||
export function SearchInput() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -66,9 +63,7 @@ export function SearchInput() {
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
autoFocus={searchParams.get("q") !== null}
|
autoFocus={searchParams.get("q") !== null}
|
||||||
placeholder={t("searchPollsPlaceholder", {
|
placeholder={placeholder}
|
||||||
defaultValue: "Search polls by title...",
|
|
||||||
})}
|
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
|
@ -47,7 +47,9 @@ export const ParticipantAvatarBar = ({
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{participant.name}</TooltipContent>
|
<TooltipPortal>
|
||||||
|
<TooltipContent>{participant.name}</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{hiddenCount > 0 ? (
|
{hiddenCount > 0 ? (
|
||||||
|
|
|
@ -2,8 +2,7 @@ import type { PollStatus } from "@rallly/database";
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
|
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
|
import { PollStatusIcon } from "@/features/poll/components/poll-status-icon";
|
||||||
import { PollStatusIcon } from "./poll-status-icon";
|
|
||||||
|
|
||||||
export const PollStatusLabel = ({
|
export const PollStatusLabel = ({
|
||||||
status,
|
status,
|
||||||
|
|
|
@ -8,9 +8,9 @@ export function StackedList({
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("divide-y rounded-lg border", className)}>
|
<ul className={cn("divide-y overflow-hidden rounded-lg border", className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,15 +21,14 @@ export function StackedListItem({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return <div className={cn("p-1", className)}>{children}</div>;
|
return (
|
||||||
}
|
<li
|
||||||
|
className={cn(
|
||||||
export function StackedListItemContent({
|
"flex items-center gap-x-6 p-4 hover:bg-gray-50",
|
||||||
children,
|
className,
|
||||||
className,
|
)}
|
||||||
}: {
|
>
|
||||||
children: React.ReactNode;
|
{children}
|
||||||
className?: string;
|
</li>
|
||||||
}) {
|
);
|
||||||
return <div className={cn("p-3", className)}>{children}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
email: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
image: true,
|
image: true,
|
||||||
|
@ -91,6 +92,7 @@ export async function getPolls({
|
||||||
participants: poll.participants.map((participant) => ({
|
participants: poll.participants.map((participant) => ({
|
||||||
id: participant.id,
|
id: participant.id,
|
||||||
name: participant.name,
|
name: participant.name,
|
||||||
|
email: participant.email ?? undefined,
|
||||||
image: participant.user?.image ?? undefined,
|
image: participant.user?.image ?? undefined,
|
||||||
})),
|
})),
|
||||||
event: poll.event ?? 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