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;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
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 {
|
||||
@apply h-full font-sans text-base;
|
||||
|
|
|
@ -241,7 +241,6 @@
|
|||
"verificationCodeSentTo": "We sent a verification code to <b>{email}</b>",
|
||||
"home": "Home",
|
||||
"groupPoll": "Group Poll",
|
||||
"groupPollDescription": "Share your availability with a group of people and find the best time to meet.",
|
||||
"create": "Create",
|
||||
"upcoming": "Upcoming",
|
||||
"past": "Past",
|
||||
|
@ -250,7 +249,6 @@
|
|||
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||
"pastEventsEmptyStateTitle": "No Past Events",
|
||||
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||
"activePollCount": "{{activePollCount}} Live",
|
||||
"createPoll": "Create poll",
|
||||
"yearlyBillingDescription": "per year",
|
||||
"addToCalendar": "Add to Calendar",
|
||||
|
@ -281,5 +279,7 @@
|
|||
"savePercentage": "Save {percentage}%",
|
||||
"1month": "1 month",
|
||||
"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 { BarChart2Icon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export function AppCard({
|
||||
|
@ -25,36 +24,6 @@ export function AppCardContent({ children }: { children?: React.ReactNode }) {
|
|||
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({
|
||||
children,
|
||||
className,
|
||||
|
|
|
@ -1,75 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
AppCard,
|
||||
AppCardContent,
|
||||
AppCardDescription,
|
||||
AppCardFooter,
|
||||
AppCardIcon,
|
||||
AppCardName,
|
||||
GroupPollIcon,
|
||||
} from "@/app/[locale]/(admin)/app-card";
|
||||
import { GroupPollCard } from "@/components/group-poll-card";
|
||||
import { Subheading } from "@/components/heading";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { trpc } from "@/utils/trpc/client";
|
||||
|
||||
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) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid md:flex">
|
||||
<AppCard className="basis-96">
|
||||
<AppCardIcon>
|
||||
<GroupPollIcon size="lg" />
|
||||
</AppCardIcon>
|
||||
<AppCardContent>
|
||||
<div>
|
||||
<AppCardName>
|
||||
<Trans i18nKey="groupPoll" defaults="Group Poll" />
|
||||
</AppCardName>
|
||||
<AppCardDescription>
|
||||
<Trans
|
||||
i18nKey="groupPollDescription"
|
||||
defaults="Share your availability with a group of people and find the best time to meet."
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{data.map((poll) => {
|
||||
return (
|
||||
<GroupPollCard
|
||||
key={poll.id}
|
||||
pollId={poll.id}
|
||||
title={poll.title}
|
||||
status={poll.status}
|
||||
inviteLink={poll.inviteLink}
|
||||
responseCount={poll.responseCount}
|
||||
dateStart={poll.range.start}
|
||||
dateEnd={poll.range.end}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
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 { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
|
||||
import { ProBadge } from "@/app/[locale]/(admin)/pro-badge";
|
||||
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
|
||||
import { LogoLink } from "@/app/components/logo-link";
|
||||
import { UserDropdown } from "@/components/user-dropdown";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
|
@ -12,10 +17,10 @@ export default async function Layout({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
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
|
||||
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">
|
||||
|
@ -24,16 +29,33 @@ export default async function Layout({
|
|||
</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col pb-2 lg:min-w-0 lg:pl-72 lg:pr-2 lg:pt-2",
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<div className={cn("pb-16 lg:min-w-0 lg:pb-0 lg:pl-72")}>
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mb-6 flex justify-between gap-2">
|
||||
<div>
|
||||
<Button>
|
||||
<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}
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
PageContainer,
|
||||
PageContent,
|
||||
PageHeader,
|
||||
PageIcon,
|
||||
PageTitle,
|
||||
} from "@/app/components/page-layout";
|
||||
import { getTranslation } from "@/app/i18n";
|
||||
|
@ -18,14 +17,9 @@ export default async function Page({ params }: { params: Params }) {
|
|||
<div>
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<PageIcon>
|
||||
<HomeIcon />
|
||||
</PageIcon>
|
||||
<PageTitle>
|
||||
<Trans t={t} i18nKey="home" defaults="Home" />
|
||||
</PageTitle>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<Dashboard />
|
||||
|
|
|
@ -1,26 +1,18 @@
|
|||
"use client";
|
||||
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 { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CalendarPlusIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import useCopyToClipboard from "react-use/lib/useCopyToClipboard";
|
||||
import { z } from "zod";
|
||||
|
||||
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
|
||||
import {
|
||||
EmptyState,
|
||||
EmptyStateDescription,
|
||||
EmptyStateIcon,
|
||||
EmptyStateTitle,
|
||||
} from "@/app/components/empty-state";
|
||||
import { PollStatusBadge } from "@/components/poll-status";
|
||||
import { GroupPollCard } from "@/components/group-poll-card";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { VisibilityTrigger } from "@/components/visibility-trigger";
|
||||
|
@ -31,8 +23,7 @@ function PollCount({ count }: { count?: number }) {
|
|||
}
|
||||
|
||||
function FilteredPolls({ status }: { status: PollStatus }) {
|
||||
const { data, fetchNextPage, hasNextPage } =
|
||||
trpc.polls.infiniteList.useInfiniteQuery(
|
||||
const { data, fetchNextPage, hasNextPage } = trpc.polls.list.useInfiniteQuery(
|
||||
{
|
||||
status,
|
||||
limit: 30,
|
||||
|
@ -47,12 +38,41 @@ function FilteredPolls({ status }: { status: PollStatus }) {
|
|||
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 (
|
||||
<div className="space-y-6">
|
||||
<ol className="space-y-4">
|
||||
{data.pages.map((page, 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>
|
||||
))}
|
||||
</ol>
|
||||
|
@ -128,122 +148,3 @@ export function UserPolls() {
|
|||
</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>
|
||||
</PageHeader>
|
||||
<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 />
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
|
|
|
@ -92,16 +92,6 @@ export function Sidebar() {
|
|||
</li>
|
||||
</ul>
|
||||
</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">
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
<IfFreeUser>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { Trans } from "react-i18next/TransWithoutContext";
|
||||
|
||||
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
|
||||
import { BackButton } from "@/app/[locale]/(admin)/menu/menu-button";
|
||||
import { Params } from "@/app/[locale]/types";
|
||||
import { getTranslation } from "@/app/i18n";
|
||||
import { CreatePoll } from "@/components/create-poll";
|
||||
import { GroupPollIcon } from "@/components/group-poll-icon";
|
||||
import { UserDropdown } from "@/components/user-dropdown";
|
||||
|
||||
export default async function Page({ params }: { params: Params }) {
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cn } from "@rallly/ui";
|
||||
|
||||
import { Heading } from "@/components/heading";
|
||||
|
||||
export function PageContainer({
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<{ className?: string }>) {
|
||||
return <div className={cn(className)}>{children}</div>;
|
||||
return <div className={cn("space-y-6", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function PageIcon({
|
||||
|
@ -30,16 +32,7 @@ export function PageTitle({
|
|||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(
|
||||
"inline-flex items-center truncate text-xl font-bold tracking-tight text-gray-700",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
return <Heading className={className}>{children}</Heading>;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
|
@ -50,7 +43,7 @@ export function PageHeader({
|
|||
className?: string;
|
||||
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 }) {
|
||||
|
|
|
@ -37,7 +37,7 @@ export function EventCard() {
|
|||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-gray-50">
|
||||
<Card>
|
||||
<RandomGradientBar seed={poll.id} />
|
||||
<CardContent className="space-y-4 sm:space-y-6">
|
||||
<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 React from "react";
|
||||
|
||||
import { GroupPollIcon } from "@/app/[locale]/(admin)/app-card";
|
||||
import Loader from "@/app/[locale]/poll/[urlId]/skeleton";
|
||||
import { LogoutButton } from "@/app/components/logout-button";
|
||||
import { GroupPollIcon } from "@/components/group-poll-icon";
|
||||
import { InviteDialog } from "@/components/invite-dialog";
|
||||
import { LoginLink } from "@/components/login-link";
|
||||
import {
|
||||
|
@ -49,7 +49,7 @@ const Layout = ({ children }: React.PropsWithChildren) => {
|
|||
const pollLink = `/poll/${poll.id}`;
|
||||
const pathname = usePathname();
|
||||
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="flex min-w-0 items-center gap-x-2.5">
|
||||
{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 (
|
||||
<span
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
@ -27,11 +27,11 @@ export const PollStatusLabel = ({
|
|||
return (
|
||||
<span
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
</span>
|
||||
|
@ -40,7 +40,7 @@ export const PollStatusLabel = ({
|
|||
return (
|
||||
<span
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useTranslation } from "@/app/i18n/client";
|
|||
import { I18nNamespaces } from "../../declarations/i18next";
|
||||
|
||||
export const Trans = (props: {
|
||||
i18nKey: keyof I18nNamespaces["app"];
|
||||
i18nKey?: keyof I18nNamespaces["app"];
|
||||
defaults?: string;
|
||||
values?: Record<string, string | number | boolean | undefined>;
|
||||
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;
|
||||
}
|
||||
body {
|
||||
@apply text-foreground bg-gray-100;
|
||||
@apply text-foreground bg-gray-50;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { shortUrl } from "@/utils/absolute-url";
|
||||
|
||||
import { possiblyPublicProcedure, router } from "../trpc";
|
||||
|
||||
|
@ -14,4 +17,48 @@ export const dashboard = router({
|
|||
|
||||
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>,
|
||||
);
|
||||
}),
|
||||
list: possiblyPublicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
infiniteList: possiblyPublicProcedure
|
||||
.input(
|
||||
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
|
||||
create: possiblyPublicProcedure
|
||||
.use(rateLimitMiddleware)
|
||||
|
|
|
@ -195,6 +195,14 @@ export const useDayjs = () => {
|
|||
return useRequiredContext(DayjsContext);
|
||||
};
|
||||
|
||||
export const useLocalizeTime = () => {
|
||||
const { timeZone } = useDayjs();
|
||||
return React.useCallback(
|
||||
(date: Date) => dayjs(date).tz(timeZone),
|
||||
[timeZone],
|
||||
);
|
||||
};
|
||||
|
||||
export const DayjsProvider: React.FunctionComponent<{
|
||||
children?: React.ReactNode;
|
||||
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