mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-11 23:21:51 +02:00
uncommit
This commit is contained in:
parent
8ca4b2acf8
commit
211e261c71
28 changed files with 519 additions and 324 deletions
|
@ -3,11 +3,8 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
@apply text-foreground h-full overflow-y-auto bg-gray-100 font-sans;
|
@apply text-foreground h-full overflow-y-auto bg-gray-50 font-sans;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply h-full font-sans text-base;
|
@apply h-full font-sans text-base;
|
||||||
|
|
|
@ -241,7 +241,6 @@
|
||||||
"verificationCodeSentTo": "We sent a verification code to <b>{email}</b>",
|
"verificationCodeSentTo": "We sent a verification code to <b>{email}</b>",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"groupPoll": "Group Poll",
|
"groupPoll": "Group Poll",
|
||||||
"groupPollDescription": "Share your availability with a group of people and find the best time to meet.",
|
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"upcoming": "Upcoming",
|
"upcoming": "Upcoming",
|
||||||
"past": "Past",
|
"past": "Past",
|
||||||
|
@ -250,7 +249,6 @@
|
||||||
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||||
"pastEventsEmptyStateTitle": "No Past Events",
|
"pastEventsEmptyStateTitle": "No Past Events",
|
||||||
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||||
"activePollCount": "{{activePollCount}} Live",
|
|
||||||
"createPoll": "Create poll",
|
"createPoll": "Create poll",
|
||||||
"yearlyBillingDescription": "per year",
|
"yearlyBillingDescription": "per year",
|
||||||
"addToCalendar": "Add to Calendar",
|
"addToCalendar": "Add to Calendar",
|
||||||
|
@ -281,5 +279,7 @@
|
||||||
"savePercentage": "Save {percentage}%",
|
"savePercentage": "Save {percentage}%",
|
||||||
"1month": "1 month",
|
"1month": "1 month",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"cancelAnytime": "Cancel anytime from your <a>billing page</a>."
|
"cancelAnytime": "Cancel anytime from your <a>billing page</a>.",
|
||||||
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
|
"viewAll": "View All"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
import { BarChart2Icon } from "lucide-react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export function AppCard({
|
export function AppCard({
|
||||||
|
@ -25,36 +24,6 @@ export function AppCardContent({ children }: { children?: React.ReactNode }) {
|
||||||
return <div className="">{children}</div>;
|
return <div className="">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupPollIcon({
|
|
||||||
size = "md",
|
|
||||||
}: {
|
|
||||||
size?: "xs" | "sm" | "md" | "lg";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="img"
|
|
||||||
aria-label="Group Poll Icon"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center bg-gradient-to-br from-purple-500 to-violet-500 text-purple-100",
|
|
||||||
{
|
|
||||||
"size-6 rounded": size === "xs",
|
|
||||||
"size-8 rounded-md": size === "sm",
|
|
||||||
"size-9 rounded-md": size === "md",
|
|
||||||
"size-10 rounded-lg": size === "lg",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BarChart2Icon
|
|
||||||
className={cn({
|
|
||||||
"size-4": size === "sm" || size === "xs",
|
|
||||||
"size-5": size === "md",
|
|
||||||
"size-6": size === "lg",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppCardIcon({
|
export function AppCardIcon({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
|
|
@ -1,75 +1,64 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@rallly/ui/button";
|
import { Button } from "@rallly/ui/button";
|
||||||
import { Icon } from "@rallly/ui/icon";
|
|
||||||
import { PlusIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import {
|
import { GroupPollCard } from "@/components/group-poll-card";
|
||||||
AppCard,
|
import { Subheading } from "@/components/heading";
|
||||||
AppCardContent,
|
|
||||||
AppCardDescription,
|
|
||||||
AppCardFooter,
|
|
||||||
AppCardIcon,
|
|
||||||
AppCardName,
|
|
||||||
GroupPollIcon,
|
|
||||||
} from "@/app/[locale]/(admin)/app-card";
|
|
||||||
import { Spinner } from "@/components/spinner";
|
import { Spinner } from "@/components/spinner";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { trpc } from "@/utils/trpc/client";
|
import { trpc } from "@/utils/trpc/client";
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { data } = trpc.dashboard.info.useQuery();
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/new">Create a Poll</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Subheading>
|
||||||
|
<Trans i18nKey="pending" defaults="Pending" />
|
||||||
|
</Subheading>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/polls">
|
||||||
|
<Trans i18nKey="viewAll" defaults="View All" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<PendingPolls />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingPolls() {
|
||||||
|
const { data } = trpc.dashboard.getPending.useQuery(undefined, {
|
||||||
|
suspense: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="grid gap-2 md:grid-cols-3">
|
||||||
<div className="grid md:flex">
|
{data.map((poll) => {
|
||||||
<AppCard className="basis-96">
|
return (
|
||||||
<AppCardIcon>
|
<GroupPollCard
|
||||||
<GroupPollIcon size="lg" />
|
key={poll.id}
|
||||||
</AppCardIcon>
|
pollId={poll.id}
|
||||||
<AppCardContent>
|
title={poll.title}
|
||||||
<div>
|
status={poll.status}
|
||||||
<AppCardName>
|
inviteLink={poll.inviteLink}
|
||||||
<Trans i18nKey="groupPoll" defaults="Group Poll" />
|
responseCount={poll.responseCount}
|
||||||
</AppCardName>
|
dateStart={poll.range.start}
|
||||||
<AppCardDescription>
|
dateEnd={poll.range.end}
|
||||||
<Trans
|
/>
|
||||||
i18nKey="groupPollDescription"
|
);
|
||||||
defaults="Share your availability with a group of people and find the best time to meet."
|
})}
|
||||||
/>
|
|
||||||
</AppCardDescription>
|
|
||||||
</div>
|
|
||||||
</AppCardContent>
|
|
||||||
<AppCardFooter className="flex items-center justify-between gap-4">
|
|
||||||
<div className="inline-flex items-center gap-1 text-sm">
|
|
||||||
<Link
|
|
||||||
className="text-primary font-medium hover:underline"
|
|
||||||
href="/polls?status=live"
|
|
||||||
>
|
|
||||||
<Trans
|
|
||||||
i18nKey="activePollCount"
|
|
||||||
defaults="{{activePollCount}} Live"
|
|
||||||
values={{
|
|
||||||
activePollCount: data.activePollCount,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/new">
|
|
||||||
<Icon>
|
|
||||||
<PlusIcon />
|
|
||||||
</Icon>
|
|
||||||
<Trans i18nKey="create" defaults="Create" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</AppCardFooter>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import { PlusIcon, SearchIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
|
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
|
||||||
import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
|
import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
|
||||||
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
|
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
|
||||||
import { LogoLink } from "@/app/components/logo-link";
|
import { LogoLink } from "@/app/components/logo-link";
|
||||||
|
import { UserDropdown } from "@/components/user-dropdown";
|
||||||
|
|
||||||
export default async function Layout({
|
export default async function Layout({
|
||||||
children,
|
children,
|
||||||
|
@ -12,10 +17,10 @@ export default async function Layout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col pb-16 md:pb-0">
|
<div className="flex h-screen flex-col bg-gray-50 pb-16 md:pb-0">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 py-5 md:flex",
|
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-6 overflow-y-auto p-6 lg:flex",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
|
@ -24,16 +29,33 @@ export default async function Layout({
|
||||||
</div>
|
</div>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={cn("pb-16 lg:min-w-0 lg:pb-0 lg:pl-72")}>
|
||||||
className={cn(
|
<div className="mx-auto max-w-7xl p-6">
|
||||||
"flex flex-1 flex-col pb-2 lg:min-w-0 lg:pl-72 lg:pr-2 lg:pt-2",
|
<div className="mb-6 flex justify-between gap-2">
|
||||||
)}
|
<div>
|
||||||
>
|
<Button>
|
||||||
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-sm lg:ring-1 lg:ring-gray-950/5 dark:lg:bg-gray-900 dark:lg:ring-white/10">
|
<Icon>
|
||||||
|
<SearchIcon />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link href="/new">
|
||||||
|
<Icon>
|
||||||
|
<PlusIcon />
|
||||||
|
</Icon>
|
||||||
|
Create
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<UserDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden">
|
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md lg:hidden">
|
||||||
<MobileNavigation />
|
<MobileNavigation />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,7 +7,6 @@ import {
|
||||||
PageContainer,
|
PageContainer,
|
||||||
PageContent,
|
PageContent,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageIcon,
|
|
||||||
PageTitle,
|
PageTitle,
|
||||||
} from "@/app/components/page-layout";
|
} from "@/app/components/page-layout";
|
||||||
import { getTranslation } from "@/app/i18n";
|
import { getTranslation } from "@/app/i18n";
|
||||||
|
@ -18,14 +17,9 @@ export default async function Page({ params }: { params: Params }) {
|
||||||
<div>
|
<div>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<div className="flex items-center gap-x-3">
|
<PageTitle>
|
||||||
<PageIcon>
|
<Trans t={t} i18nKey="home" defaults="Home" />
|
||||||
<HomeIcon />
|
</PageTitle>
|
||||||
</PageIcon>
|
|
||||||
<PageTitle>
|
|
||||||
<Trans t={t} i18nKey="home" defaults="Home" />
|
|
||||||
</PageTitle>
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
|
|
|
@ -1,26 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { PollStatus } from "@rallly/database";
|
import { PollStatus } from "@rallly/database";
|
||||||
import { cn } from "@rallly/ui";
|
|
||||||
import { Badge } from "@rallly/ui/badge";
|
|
||||||
import { Button } from "@rallly/ui/button";
|
|
||||||
import { Icon } from "@rallly/ui/icon";
|
|
||||||
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
|
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
|
||||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||||
import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react";
|
import { CalendarPlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import React from "react";
|
|
||||||
import useCopyToClipboard from "react-use/lib/useCopyToClipboard";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
|
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
EmptyStateDescription,
|
EmptyStateDescription,
|
||||||
EmptyStateIcon,
|
EmptyStateIcon,
|
||||||
EmptyStateTitle,
|
EmptyStateTitle,
|
||||||
} from "@/app/components/empty-state";
|
} from "@/app/components/empty-state";
|
||||||
import { PollStatusBadge } from "@/components/poll-status";
|
import { GroupPollCard } from "@/components/group-poll-card";
|
||||||
import { Spinner } from "@/components/spinner";
|
import { Spinner } from "@/components/spinner";
|
||||||
import { Trans } from "@/components/trans";
|
import { Trans } from "@/components/trans";
|
||||||
import { VisibilityTrigger } from "@/components/visibility-trigger";
|
import { VisibilityTrigger } from "@/components/visibility-trigger";
|
||||||
|
@ -31,28 +23,56 @@ function PollCount({ count }: { count?: number }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilteredPolls({ status }: { status: PollStatus }) {
|
function FilteredPolls({ status }: { status: PollStatus }) {
|
||||||
const { data, fetchNextPage, hasNextPage } =
|
const { data, fetchNextPage, hasNextPage } = trpc.polls.list.useInfiniteQuery(
|
||||||
trpc.polls.infiniteList.useInfiniteQuery(
|
{
|
||||||
{
|
status,
|
||||||
status,
|
limit: 30,
|
||||||
limit: 30,
|
},
|
||||||
},
|
{
|
||||||
{
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
keepPreviousData: true,
|
||||||
keepPreviousData: true,
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data?.pages[0]?.polls.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState className="h-96">
|
||||||
|
<EmptyStateIcon>
|
||||||
|
<CalendarPlusIcon />
|
||||||
|
</EmptyStateIcon>
|
||||||
|
<EmptyStateTitle>
|
||||||
|
<Trans i18nKey="noPolls" />
|
||||||
|
</EmptyStateTitle>
|
||||||
|
<EmptyStateDescription>
|
||||||
|
<Trans i18nKey="noPollsDescription" />
|
||||||
|
</EmptyStateDescription>
|
||||||
|
</EmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ol className="space-y-4">
|
<ol className="space-y-4">
|
||||||
{data.pages.map((page, i) => (
|
{data.pages.map((page, i) => (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
<PollsListView data={page.polls} />
|
<div className="grid gap-3 sm:gap-4 md:grid-cols-3">
|
||||||
|
{page.polls.map((poll) => (
|
||||||
|
<GroupPollCard
|
||||||
|
key={poll.id}
|
||||||
|
title={poll.title}
|
||||||
|
pollId={poll.id}
|
||||||
|
status={poll.status}
|
||||||
|
inviteLink={`${window.location.origin}/invite/${poll.id}`}
|
||||||
|
responseCount={poll.participants.length}
|
||||||
|
dateStart={poll.createdAt}
|
||||||
|
dateEnd={poll.createdAt}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
@ -128,122 +148,3 @@ export function UserPolls() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyLinkButton({ pollId }: { pollId: string }) {
|
|
||||||
const [, copy] = useCopyToClipboard();
|
|
||||||
const [didCopy, setDidCopy] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={didCopy}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
copy(`${window.location.origin}/invite/${pollId}`);
|
|
||||||
setDidCopy(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setDidCopy(false);
|
|
||||||
}, 1000);
|
|
||||||
}}
|
|
||||||
className="relative z-20 w-full"
|
|
||||||
>
|
|
||||||
{didCopy ? (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
|
|
||||||
<Trans i18nKey="copied" defaults="Copied" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LinkIcon className="size-4" />
|
|
||||||
<Trans i18nKey="copyLink" defaults="Copy Link" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParticipantCount({ count }: { count: number }) {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center gap-x-1 text-sm font-medium">
|
|
||||||
<Icon>
|
|
||||||
<UserIcon />
|
|
||||||
</Icon>
|
|
||||||
<span>{count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PollsListView({
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
data: {
|
|
||||||
id: string;
|
|
||||||
status: PollStatus;
|
|
||||||
title: string;
|
|
||||||
createdAt: Date;
|
|
||||||
userId: string;
|
|
||||||
participants: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
}) {
|
|
||||||
const table = useReactTable({
|
|
||||||
columns: [],
|
|
||||||
data,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
if (data?.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState className="h-96">
|
|
||||||
<EmptyStateIcon>
|
|
||||||
<CalendarPlusIcon />
|
|
||||||
</EmptyStateIcon>
|
|
||||||
<EmptyStateTitle>
|
|
||||||
<Trans i18nKey="noPolls" />
|
|
||||||
</EmptyStateTitle>
|
|
||||||
<EmptyStateDescription>
|
|
||||||
<Trans i18nKey="noPollsDescription" />
|
|
||||||
</EmptyStateDescription>
|
|
||||||
</EmptyState>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 sm:gap-4 md:grid-cols-2">
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"group relative space-y-4 overflow-hidden rounded-lg border bg-white p-4 focus-within:bg-gray-50",
|
|
||||||
)}
|
|
||||||
key={row.id}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<GroupPollIcon size="xs" />
|
|
||||||
<h2 className="truncate text-base font-medium group-hover:underline">
|
|
||||||
<Link
|
|
||||||
href={`/poll/${row.original.id}`}
|
|
||||||
className="absolute inset-0 z-10"
|
|
||||||
/>
|
|
||||||
{row.original.title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge size="lg">
|
|
||||||
<PollStatusBadge status={row.original.status} />
|
|
||||||
</Badge>
|
|
||||||
<Badge size="lg">
|
|
||||||
<ParticipantCount count={row.original.participants.length} />
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end justify-between">
|
|
||||||
<CopyLinkButton pollId={row.original.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ export default async function ProfileLayout({
|
||||||
<PageTitle>{t("settings")}</PageTitle>
|
<PageTitle>{t("settings")}</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent className="space-y-3 sm:space-y-4">
|
<PageContent className="space-y-3 sm:space-y-4">
|
||||||
<div className="scrollbar-none -mx-3 overflow-auto bg-gray-100 px-3 sm:mx-0 sm:px-0">
|
<div className="scrollbar-none -mx-3 overflow-auto px-3 sm:mx-0 sm:px-0">
|
||||||
<SettingsMenu />
|
<SettingsMenu />
|
||||||
</div>
|
</div>
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
|
|
|
@ -92,16 +92,6 @@ export function Sidebar() {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li className="-mx-2 space-y-1">
|
|
||||||
<Button variant="primary" className="w-full rounded-full" asChild>
|
|
||||||
<Link href="/new">
|
|
||||||
<Icon>
|
|
||||||
<PlusIcon />
|
|
||||||
</Icon>
|
|
||||||
<Trans i18nKey="create" defaults="create" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
<li className="mt-auto">
|
<li className="mt-auto">
|
||||||
<ul role="list" className="-mx-2 space-y-1">
|
<ul role="list" className="-mx-2 space-y-1">
|
||||||
<IfFreeUser>
|
<IfFreeUser>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { Trans } from "react-i18next/TransWithoutContext";
|
import { Trans } from "react-i18next/TransWithoutContext";
|
||||||
|
|
||||||
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
|
|
||||||
import { BackButton } from "@/app/[locale]/(admin)/menu/menu-button";
|
import { BackButton } from "@/app/[locale]/(admin)/menu/menu-button";
|
||||||
import { Params } from "@/app/[locale]/types";
|
import { Params } from "@/app/[locale]/types";
|
||||||
import { getTranslation } from "@/app/i18n";
|
import { getTranslation } from "@/app/i18n";
|
||||||
import { CreatePoll } from "@/components/create-poll";
|
import { CreatePoll } from "@/components/create-poll";
|
||||||
|
import { GroupPollIcon } from "@/components/group-poll-icon";
|
||||||
import { UserDropdown } from "@/components/user-dropdown";
|
import { UserDropdown } from "@/components/user-dropdown";
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Params }) {
|
export default async function Page({ params }: { params: Params }) {
|
||||||
|
|
|
@ -2,11 +2,13 @@
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cn } from "@rallly/ui";
|
import { cn } from "@rallly/ui";
|
||||||
|
|
||||||
|
import { Heading } from "@/components/heading";
|
||||||
|
|
||||||
export function PageContainer({
|
export function PageContainer({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: React.PropsWithChildren<{ className?: string }>) {
|
}: React.PropsWithChildren<{ className?: string }>) {
|
||||||
return <div className={cn(className)}>{children}</div>;
|
return <div className={cn("space-y-6", className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageIcon({
|
export function PageIcon({
|
||||||
|
@ -30,16 +32,7 @@ export function PageTitle({
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <Heading className={className}>{children}</Heading>;
|
||||||
<h1
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center truncate text-xl font-bold tracking-tight text-gray-700",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</h1>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
|
@ -50,7 +43,7 @@ export function PageHeader({
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: "default" | "ghost";
|
variant?: "default" | "ghost";
|
||||||
}) {
|
}) {
|
||||||
return <div className={cn("mb-6", className)}>{children}</div>;
|
return <div className={cn(className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageSection({ children }: { children?: React.ReactNode }) {
|
export function PageSection({ children }: { children?: React.ReactNode }) {
|
||||||
|
|
|
@ -37,7 +37,7 @@ export function EventCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="bg-gray-50">
|
<Card>
|
||||||
<RandomGradientBar seed={poll.id} />
|
<RandomGradientBar seed={poll.id} />
|
||||||
<CardContent className="space-y-4 sm:space-y-6">
|
<CardContent className="space-y-4 sm:space-y-6">
|
||||||
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">
|
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">
|
||||||
|
|
13
apps/web/src/components/grid-card.tsx
Normal file
13
apps/web/src/components/grid-card.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export function GridCardFooter({ children }: React.PropsWithChildren) {
|
||||||
|
return <div className="relative z-10 mt-6 flex gap-1">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridCardHeader({ children }: React.PropsWithChildren) {
|
||||||
|
return <div className="mb-4 flex items-center gap-2">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GridCard = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-lg border bg-white p-4">{children}</div>
|
||||||
|
);
|
||||||
|
};
|
111
apps/web/src/components/group-poll-card.tsx
Normal file
111
apps/web/src/components/group-poll-card.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PollStatus } from "@rallly/database";
|
||||||
|
import { Button } from "@rallly/ui/button";
|
||||||
|
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||||
|
import { Icon } from "@rallly/ui/icon";
|
||||||
|
import {
|
||||||
|
BarChart2Icon,
|
||||||
|
CalendarIcon,
|
||||||
|
Link2Icon,
|
||||||
|
User2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useCopyToClipboard } from "react-use";
|
||||||
|
|
||||||
|
import {
|
||||||
|
GridCard,
|
||||||
|
GridCardFooter,
|
||||||
|
GridCardHeader,
|
||||||
|
} from "@/components/grid-card";
|
||||||
|
import { GroupPollIcon } from "@/components/group-poll-icon";
|
||||||
|
import { Pill, PillList } from "@/components/pill";
|
||||||
|
import { PollStatusLabel } from "@/components/poll-status";
|
||||||
|
import { Trans } from "@/components/trans";
|
||||||
|
import { useLocalizeTime } from "@/utils/dayjs";
|
||||||
|
import { getRange } from "@/utils/get-range";
|
||||||
|
|
||||||
|
export function GroupPollCard({
|
||||||
|
status,
|
||||||
|
pollId,
|
||||||
|
title,
|
||||||
|
inviteLink,
|
||||||
|
responseCount,
|
||||||
|
dateStart,
|
||||||
|
dateEnd,
|
||||||
|
}: {
|
||||||
|
pollId: string;
|
||||||
|
title: string;
|
||||||
|
inviteLink: string;
|
||||||
|
responseCount: number;
|
||||||
|
dateStart: Date;
|
||||||
|
dateEnd: Date;
|
||||||
|
status: PollStatus;
|
||||||
|
}) {
|
||||||
|
const localizeTime = useLocalizeTime();
|
||||||
|
|
||||||
|
const { t } = useTranslation("app");
|
||||||
|
const [, copy] = useCopyToClipboard();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridCard key={pollId}>
|
||||||
|
<GridCardHeader>
|
||||||
|
<div>
|
||||||
|
<GroupPollIcon size="xs" />
|
||||||
|
</div>
|
||||||
|
<h3 className="truncate font-medium">
|
||||||
|
<Link className="focus:underline" href={`/poll/${pollId}`}>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
</GridCardHeader>
|
||||||
|
<PillList>
|
||||||
|
<Pill>
|
||||||
|
<PollStatusLabel status={status} />
|
||||||
|
</Pill>
|
||||||
|
<Pill>
|
||||||
|
<Icon>
|
||||||
|
<CalendarIcon />
|
||||||
|
</Icon>
|
||||||
|
{getRange(
|
||||||
|
localizeTime(dateStart).toDate(),
|
||||||
|
localizeTime(dateEnd).toDate(),
|
||||||
|
)}
|
||||||
|
</Pill>
|
||||||
|
<Pill>
|
||||||
|
<Icon>
|
||||||
|
<User2Icon />
|
||||||
|
</Icon>
|
||||||
|
<Trans defaults="{count, number}" values={{ count: responseCount }} />
|
||||||
|
</Pill>
|
||||||
|
</PillList>
|
||||||
|
<GridCardFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
copy(inviteLink);
|
||||||
|
toast({
|
||||||
|
title: t("copiedToClipboard", {
|
||||||
|
ns: "app",
|
||||||
|
defaultValue: "Copied to clipboard",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<Link2Icon />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link href={`/poll/${pollId}`}>
|
||||||
|
<Icon>
|
||||||
|
<BarChart2Icon />
|
||||||
|
</Icon>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</GridCardFooter>
|
||||||
|
</GridCard>
|
||||||
|
);
|
||||||
|
}
|
32
apps/web/src/components/group-poll-icon.tsx
Normal file
32
apps/web/src/components/group-poll-icon.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { cn } from "@rallly/ui";
|
||||||
|
import { BarChart2Icon } from "lucide-react";
|
||||||
|
|
||||||
|
export function GroupPollIcon({
|
||||||
|
size = "md",
|
||||||
|
}: {
|
||||||
|
size?: "xs" | "sm" | "md" | "lg";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label="Group Poll Icon"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center bg-gradient-to-br from-purple-500 to-violet-500 text-purple-100",
|
||||||
|
{
|
||||||
|
"size-6 rounded": size === "xs",
|
||||||
|
"size-8 rounded-md": size === "sm",
|
||||||
|
"size-9 rounded-md": size === "md",
|
||||||
|
"size-10 rounded-lg": size === "lg",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BarChart2Icon
|
||||||
|
className={cn({
|
||||||
|
"size-4": size === "sm" || size === "xs",
|
||||||
|
"size-5": size === "md",
|
||||||
|
"size-6": size === "lg",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
37
apps/web/src/components/heading.tsx
Normal file
37
apps/web/src/components/heading.tsx
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { cn } from "@rallly/ui";
|
||||||
|
|
||||||
|
import { heading } from "@/fonts/heading";
|
||||||
|
|
||||||
|
export function Heading({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: React.PropsWithChildren<{ className?: string }>) {
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
"text-xl font-semibold text-gray-900",
|
||||||
|
heading.className,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Subheading({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: React.PropsWithChildren<{ className?: string }>) {
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
className={cn(
|
||||||
|
"text-base font-semibold text-gray-900",
|
||||||
|
heading.className,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
|
@ -13,9 +13,9 @@ import Link from "next/link";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
|
|
||||||
import Loader from "@/app/[locale]/poll/[urlId]/skeleton";
|
import Loader from "@/app/[locale]/poll/[urlId]/skeleton";
|
||||||
import { LogoutButton } from "@/app/components/logout-button";
|
import { LogoutButton } from "@/app/components/logout-button";
|
||||||
|
import { GroupPollIcon } from "@/components/group-poll-icon";
|
||||||
import { InviteDialog } from "@/components/invite-dialog";
|
import { InviteDialog } from "@/components/invite-dialog";
|
||||||
import { LoginLink } from "@/components/login-link";
|
import { LoginLink } from "@/components/login-link";
|
||||||
import {
|
import {
|
||||||
|
@ -49,7 +49,7 @@ const Layout = ({ children }: React.PropsWithChildren) => {
|
||||||
const pollLink = `/poll/${poll.id}`;
|
const pollLink = `/poll/${poll.id}`;
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-100">
|
<div>
|
||||||
<div className="sticky top-0 z-30 flex flex-col justify-between gap-x-4 gap-y-2.5 border-b bg-gray-100 p-3 sm:flex-row lg:items-center lg:px-5">
|
<div className="sticky top-0 z-30 flex flex-col justify-between gap-x-4 gap-y-2.5 border-b bg-gray-100 p-3 sm:flex-row lg:items-center lg:px-5">
|
||||||
<div className="flex min-w-0 items-center gap-x-2.5">
|
<div className="flex min-w-0 items-center gap-x-2.5">
|
||||||
{pathname === pollLink ? (
|
{pathname === pollLink ? (
|
||||||
|
|
11
apps/web/src/components/pill.tsx
Normal file
11
apps/web/src/components/pill.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export function PillList({ children }: React.PropsWithChildren) {
|
||||||
|
return <ul className="flex flex-wrap gap-1">{children}</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pill({ children }: React.PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground inline-flex items-center gap-2 rounded-md border px-1.5 py-0.5 text-sm">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
|
@ -15,7 +15,7 @@ export const PollStatusLabel = ({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-x-1.5 text-sm font-medium text-pink-600",
|
"inline-flex items-center gap-x-1.5 text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -27,11 +27,11 @@ export const PollStatusLabel = ({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-x-1.5 rounded-full text-sm font-medium text-gray-500",
|
"inline-flex items-center gap-x-1.5 rounded-full text-sm text-gray-500",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="size-1.5 rounded-full bg-gray-600" />
|
<span className="size-1.5 rounded-full bg-gray-500" />
|
||||||
|
|
||||||
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
|
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
|
||||||
</span>
|
</span>
|
||||||
|
@ -40,7 +40,7 @@ export const PollStatusLabel = ({
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-x-1.5 rounded-full text-sm font-medium text-green-600",
|
"inline-flex items-center gap-x-1.5 rounded-full text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { useTranslation } from "@/app/i18n/client";
|
||||||
import { I18nNamespaces } from "../../declarations/i18next";
|
import { I18nNamespaces } from "../../declarations/i18next";
|
||||||
|
|
||||||
export const Trans = (props: {
|
export const Trans = (props: {
|
||||||
i18nKey: keyof I18nNamespaces["app"];
|
i18nKey?: keyof I18nNamespaces["app"];
|
||||||
defaults?: string;
|
defaults?: string;
|
||||||
values?: Record<string, string | number | boolean | undefined>;
|
values?: Record<string, string | number | boolean | undefined>;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
|
6
apps/web/src/fonts/heading.ts
Normal file
6
apps/web/src/fonts/heading.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { Archivo } from "next/font/google";
|
||||||
|
|
||||||
|
export const heading = Archivo({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
6
apps/web/src/fonts/sans.ts
Normal file
6
apps/web/src/fonts/sans.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
export const sans = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
|
@ -7,7 +7,7 @@
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply text-foreground bg-gray-100;
|
@apply text-foreground bg-gray-50;
|
||||||
font-feature-settings:
|
font-feature-settings:
|
||||||
"rlig" 1,
|
"rlig" 1,
|
||||||
"calt" 1;
|
"calt" 1;
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import { prisma } from "@rallly/database";
|
import { prisma } from "@rallly/database";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { shortUrl } from "@/utils/absolute-url";
|
||||||
|
|
||||||
import { possiblyPublicProcedure, router } from "../trpc";
|
import { possiblyPublicProcedure, router } from "../trpc";
|
||||||
|
|
||||||
|
@ -14,4 +17,48 @@ export const dashboard = router({
|
||||||
|
|
||||||
return { activePollCount };
|
return { activePollCount };
|
||||||
}),
|
}),
|
||||||
|
getPending: possiblyPublicProcedure.query(async ({ ctx }) => {
|
||||||
|
const polls = await prisma.poll.findMany({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
status: "live",
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
status: true,
|
||||||
|
options: {
|
||||||
|
select: {
|
||||||
|
startTime: true,
|
||||||
|
duration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
participants: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return polls.map((poll) => {
|
||||||
|
return {
|
||||||
|
id: poll.id,
|
||||||
|
title: poll.title,
|
||||||
|
createdAt: poll.createdAt,
|
||||||
|
range: {
|
||||||
|
start: poll.options[0].startTime,
|
||||||
|
end: dayjs(poll.options[poll.options.length - 1].startTime)
|
||||||
|
.add(poll.options[poll.options.length - 1].duration, "minute")
|
||||||
|
.toDate(),
|
||||||
|
},
|
||||||
|
status: poll.status,
|
||||||
|
responseCount: poll._count.participants,
|
||||||
|
inviteLink: shortUrl(`/invite/${poll.id}`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -60,43 +60,10 @@ export const polls = router({
|
||||||
{} as Record<PollStatus, number>,
|
{} as Record<PollStatus, number>,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
list: possiblyPublicProcedure
|
|
||||||
.input(
|
/**
|
||||||
z.object({
|
* @deprecated
|
||||||
status: z.enum(["all", "live", "paused", "finalized"]),
|
*/
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
return await prisma.poll.findMany({
|
|
||||||
where: {
|
|
||||||
userId: ctx.user.id,
|
|
||||||
status: input.status === "all" ? undefined : input.status,
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{
|
|
||||||
createdAt: "desc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "asc",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
location: true,
|
|
||||||
timeZone: true,
|
|
||||||
createdAt: true,
|
|
||||||
status: true,
|
|
||||||
userId: true,
|
|
||||||
participants: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
infiniteList: possiblyPublicProcedure
|
infiniteList: possiblyPublicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
@ -150,6 +117,74 @@ export const polls = router({
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
list: possiblyPublicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
status: z.enum(["all", "live", "paused", "finalized"]),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
limit: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { cursor, limit, status } = input;
|
||||||
|
const polls = await prisma.poll.findMany({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
status: status === "all" ? undefined : status,
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "asc",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cursor: cursor ? { id: cursor } : undefined,
|
||||||
|
take: limit + 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
location: true,
|
||||||
|
timeZone: true,
|
||||||
|
createdAt: true,
|
||||||
|
status: true,
|
||||||
|
userId: true,
|
||||||
|
options: {
|
||||||
|
select: {
|
||||||
|
startTime: true,
|
||||||
|
duration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextCursor: typeof cursor | undefined = undefined;
|
||||||
|
if (polls.length > input.limit) {
|
||||||
|
const nextItem = polls.pop();
|
||||||
|
nextCursor = nextItem!.id;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
polls: polls.map((poll) => ({
|
||||||
|
...poll,
|
||||||
|
participantCount: poll.participants.length,
|
||||||
|
dateRange: {
|
||||||
|
start: poll.options[0].startTime,
|
||||||
|
end: dayjs(poll.options[poll.options.length - 1].startTime)
|
||||||
|
.add(poll.options[poll.options.length - 1].duration, "minute")
|
||||||
|
.toDate(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
nextCursor,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
// START LEGACY ROUTES
|
// START LEGACY ROUTES
|
||||||
create: possiblyPublicProcedure
|
create: possiblyPublicProcedure
|
||||||
.use(rateLimitMiddleware)
|
.use(rateLimitMiddleware)
|
||||||
|
|
|
@ -195,6 +195,14 @@ export const useDayjs = () => {
|
||||||
return useRequiredContext(DayjsContext);
|
return useRequiredContext(DayjsContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useLocalizeTime = () => {
|
||||||
|
const { timeZone } = useDayjs();
|
||||||
|
return React.useCallback(
|
||||||
|
(date: Date) => dayjs(date).tz(timeZone),
|
||||||
|
[timeZone],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const DayjsProvider: React.FunctionComponent<{
|
export const DayjsProvider: React.FunctionComponent<{
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
config?: {
|
config?: {
|
||||||
|
|
17
apps/web/src/utils/get-range.spec.ts
Normal file
17
apps/web/src/utils/get-range.spec.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { getRange } from "./get-range";
|
||||||
|
|
||||||
|
describe("getRange", () => {
|
||||||
|
it("should return the start date if the start and end date are the same", () => {
|
||||||
|
const start = new Date("2021-01-01");
|
||||||
|
const end = new Date("2021-01-01");
|
||||||
|
expect(getRange(start, end)).toBe("01 Jan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the start and end date if the start and end date are different", () => {
|
||||||
|
const start = new Date("2021-01-01");
|
||||||
|
const end = new Date("2021-01-02");
|
||||||
|
expect(getRange(start, end)).toBe("01 Jan - 02 Jan");
|
||||||
|
});
|
||||||
|
});
|
17
apps/web/src/utils/get-range.ts
Normal file
17
apps/web/src/utils/get-range.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a range of dates in a human readable format
|
||||||
|
* If the start and end date are the same, return the start date
|
||||||
|
* @param start The start date
|
||||||
|
* @param end The end date
|
||||||
|
* @returns A human readable range of dates
|
||||||
|
*/
|
||||||
|
export function getRange(start: Date, end: Date) {
|
||||||
|
const startDay = dayjs(start).format("DD MMM");
|
||||||
|
const endDay = dayjs(end).format("DD MMM");
|
||||||
|
if (startDay === endDay) {
|
||||||
|
return startDay;
|
||||||
|
}
|
||||||
|
return `${startDay} - ${endDay}`;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue