Show participated polls on polls page + UI refresh (#1089)

This commit is contained in:
Luke Vella 2024-05-12 13:20:00 +08:00 committed by GitHub
parent bd9e9fe95b
commit f8a217ae75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
125 changed files with 3007 additions and 2363 deletions

View file

@ -85,9 +85,9 @@ export const MarketingHero = ({
<Link
locale="en"
href="/blog/rallly-3-0-self-hosting"
className="hover:ring-primary relative inline-flex items-center gap-x-3 rounded-full border bg-gray-100 py-1 pl-1 pr-4 text-sm leading-6 text-gray-600 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-gray-300 focus:ring-offset-1"
className="hover:ring-primary relative inline-flex items-center gap-x-3 rounded-full border bg-gray-100 py-1 pl-1 pr-4 text-sm leading-6 text-gray-600 hover:bg-gray-50 focus:ring-2 focus:ring-gray-300 focus:ring-offset-1"
>
<Badge className="bg-green-500">
<Badge variant="green">
<Trans i18nKey="home:new" defaults="New" />
</Badge>
<span className="flex items-center gap-x-1">

View file

@ -1,5 +1,6 @@
import { DiscordIcon } from "@rallly/icons";
import languages from "@rallly/languages";
import { Button } from "@rallly/ui/button";
import {
Select,
SelectContent,
@ -36,8 +37,10 @@ export const LanguageSelect = () => {
});
}}
>
<SelectTrigger>
<SelectTrigger asChild>
<Button className="w-full">
<SelectValue />
</Button>
</SelectTrigger>
<SelectContent>
{Object.entries(languages).map(([code, name]) => (

View file

@ -94,12 +94,10 @@
"errors_notFoundDescription": "We couldn't find the page you're looking for.",
"errors_goToHome": "Go to home",
"optionCount": "{count, plural, one {# option} other {# options}}",
"participantCount": "{count, plural, one {# participant} other {# participants}}",
"addComment": "Add Comment",
"profile": "Profile",
"polls": "Polls",
"showMore": "Show more…",
"timeZoneSelect__defaultValue": "Select time zone…",
"timeZoneSelect__noOption": "No option found",
"timeZoneSelect__inputPlaceholder": "Search…",
"poweredByRallly": "Powered by <a>{name}</a>",
@ -115,13 +113,11 @@
"billing": "Billing",
"guestPollAlertDescription": "<0>Create an account</0> or <1>login</1> to claim this poll.",
"guestPollAlertTitle": "Your administrator rights can be lost if you clear your cookies",
"attendeeCount": "{count, plural, one {# attendee} other {# attendees}}",
"notificationsValue": "Notifications: <b>{value}</b>",
"notificationsOn": "On",
"notificationsOff": "Off",
"pollStatusOpen": "Live",
"pollStatusPaused": "Paused",
"pollStatusClosed": "Finalized",
"reopenPoll": "Reopen Poll",
"resumePoll": "Resume",
"pausePoll": "Pause",
@ -131,7 +127,6 @@
"permissionDenied": "Unauthorized",
"permissionDeniedDescription": "If you are the poll creator, please login to access your poll",
"loginDifferent": "Switch user",
"timeShownIn": "Times shown in {timeZone}",
"editDetailsDescription": "Change the details of your event.",
"finalizeDescription": "Select a final date for your event.",
"notificationsGuestTooltip": "Create an account or login to turn on notifications",
@ -220,11 +215,7 @@
"integrations": "Integrations",
"contacts": "Contacts",
"unlockFeatures": "Unlock all Pro features.",
"pollStatusAll": "All",
"pollStatusLive": "Live",
"pollStatusFinalized": "Finalized",
"pending": "Pending",
"xMore": "{count} more",
"share": "Share",
"pageXOfY": "Page {currentPage} of {pageCount}",
"noParticipants": "No participants",
@ -241,5 +232,19 @@
"accountNotLinkedDescription": "A user with this email already exists. Please log in using the original method.",
"or": "Or",
"autoTimeZone": "Automatic Time Zone Conversion",
"autoTimeZoneHelp": "Enable this setting to automatically adjust event times to each participant's local time zone."
"autoTimeZoneHelp": "Enable this setting to automatically adjust event times to each participant's local time zone.",
"commentsDisabled": "Comments have been disabled",
"allParticipants": "All Participants",
"host": "Host",
"created": "Created",
"pollStatus": "Status",
"pollsListAll": "All",
"pollsListMine": "Mine",
"pollsListOther": "Other",
"noParticipantsDescription": "Click <b>Share</b> to invite participants",
"back": "Back",
"timeShownIn": "Times shown in {timeZone}",
"pollStatusPausedDescription": "Votes cannot be submitted or edited at this time",
"eventHostTitle": "Manage Access",
"eventHostDescription": "You are the creator of this poll"
}

View file

@ -1,46 +1,21 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import { MobileNavigation } from "@/app/[locale]/(admin)/mobile-navigation";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { LogoLink } from "@/app/components/logo-link";
import { CurrentUserAvatar } from "@/components/user";
function MobileNavigation() {
return (
<div className="flex items-center justify-between border-b bg-gray-100 px-4 py-3 shadow-sm lg:hidden">
<LogoLink />
<div className="flex justify-end gap-x-2.5">
<Link
href="/settings/profile"
className="inline-flex h-9 w-7 items-center"
>
<CurrentUserAvatar size="sm" />
</Link>
<Button asChild variant="ghost">
<Link href="/menu">
<MenuIcon className="text-muted-foreground size-4" />
</Link>
</Button>
</div>
</div>
);
}
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
function SidebarLayout() {
return (
<div className="bg-gray-50">
<div className="bg-gray-100">
<MobileNavigation />
<div
className={cn(
"inset-y-0 z-50 hidden shrink-0 flex-col gap-y-5 overflow-y-auto border-r bg-gray-100 px-5 py-4 lg:fixed lg:flex lg:w-72 lg:px-6 lg:py-4",
"inset-y-0 z-50 hidden shrink-0 flex-col gap-y-5 overflow-y-auto px-5 py-4 lg:fixed lg:flex lg:w-72 lg:px-6 lg:py-4",
)}
>
<div>
@ -48,12 +23,9 @@ export default async function Layout({
</div>
<Sidebar />
</div>
<div className={cn("min-h-screen grow bg-gray-50 lg:pl-72")}>
<div className={cn("min-h-screen grow space-y-4 lg:ml-72")}>
{children}
</div>
</div>
);
}
return <SidebarLayout />;
}

View file

@ -0,0 +1,37 @@
"use client";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { MenuIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export function BackButton() {
const router = useRouter();
return (
<Button
variant="ghost"
onClick={() => {
router.back();
}}
>
<XIcon className="text-muted-foreground size-4" />
</Button>
);
}
export function MobileMenuButton({ open }: { open?: boolean }) {
if (open) {
return <BackButton />;
}
return (
<Button asChild variant="ghost">
<Link prefetch={true} href="/menu">
<Icon>
<MenuIcon />
</Icon>
</Link>
</Button>
);
}

View file

@ -0,0 +1,9 @@
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
export default function Page() {
return (
<div className="p-3">
<Sidebar />
</div>
);
}

View file

@ -0,0 +1,27 @@
"use client";
import { Button } from "@rallly/ui/button";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MobileMenuButton } from "@/app/[locale]/(admin)/menu/menu-button";
import { CurrentUserAvatar } from "@/components/user";
export function MobileNavigation() {
const pathname = usePathname();
const isOpen = pathname === "/menu";
return (
<div className="sticky top-0 z-20 flex h-12 items-center justify-between border-b bg-gray-100 px-2 lg:hidden lg:px-4">
<MobileMenuButton open={isOpen} />
<div className="flex justify-end gap-x-2.5">
<Button asChild variant="ghost">
<Link href="/settings/profile">
<CurrentUserAvatar size="xs" />
</Link>
</Button>
</div>
</div>
);
}

View file

@ -1,31 +1,31 @@
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import { Params } from "@/app/[locale]/types";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
import { CreatePoll } from "@/components/create-poll";
export default async function Page({ params }: { params: { locale: string } }) {
export default async function Page({ params }: { params: Params }) {
const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex items-center justify-between gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="polls" />
</PageTitle>
<Button asChild>
<Link href="/polls">
<Trans t={t} i18nKey="cancel" defaults="Cancel" />
<Icon>
<ArrowLeftIcon />
</Icon>
<Trans i18nKey="back" t={t} defaults="Back" />
</Link>
</Button>
</div>
</PageHeader>
<PageContent>
<CreatePoll />

View file

@ -0,0 +1,139 @@
import { PollStatus } from "@rallly/database";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { createColumnHelper } from "@tanstack/react-table";
import dayjs from "dayjs";
import Link from "next/link";
import React from "react";
import { useTranslation } from "react-i18next";
import { PollStatusBadge } from "@/components/poll-status";
import { Trans } from "@/components/trans";
import { UserAvatar } from "@/components/user";
import { useUser } from "@/components/user-provider";
import { useDayjs } from "@/utils/dayjs";
export type PollData = {
id: string;
status: PollStatus;
title: string;
createdAt: Date;
participants: { name: string }[];
timeZone: string | null;
userId: string;
user: {
name: string;
id: string;
} | null;
event: {
start: Date;
duration: number;
} | null;
};
const columnHelper = createColumnHelper<PollData>();
export const usePollColumns = () => {
const { t } = useTranslation();
const { adjustTimeZone } = useDayjs();
const { user } = useUser();
return React.useMemo(
() => [
columnHelper.accessor("title", {
id: "title",
header: t("title"),
size: 1000,
cell: ({ row }) => {
return (
<Link
href={`/invite/${row.original.id}`}
className="group absolute inset-0 flex items-center gap-x-2.5 px-4"
>
<span className="min-w-0 truncate whitespace-nowrap text-sm font-medium group-hover:underline">
{row.original.title}
</span>
</Link>
);
},
}),
columnHelper.accessor("user", {
header: () => (
<div className="text-center">
{t("host", { defaultValue: "Host" })}
</div>
),
size: 75,
cell: ({ getValue }) => {
const isYou = getValue()?.id === user.id;
return (
<div className="text-center">
<Tooltip>
<TooltipTrigger>
<UserAvatar size="xs" name={getValue()?.name} />
</TooltipTrigger>
<TooltipContent>
{isYou ? t("you") : getValue()?.name ?? t("guest")}
</TooltipContent>
</Tooltip>
</div>
);
},
}),
columnHelper.accessor("createdAt", {
header: () => <Trans i18nKey="created" defaults="Created" />,
cell: ({ row }) => {
const { createdAt } = row.original;
return (
<p className="text-muted-foreground whitespace-nowrap text-sm">
<time dateTime={createdAt.toDateString()}>
{dayjs(createdAt).fromNow()}
</time>
</p>
);
},
}),
columnHelper.accessor("status", {
header: t("pollStatus", { defaultValue: "Status" }),
cell: ({ row }) => {
return (
<div className="text-muted-foreground flex text-sm">
{row.original.event ? (
<Tooltip>
<TooltipTrigger>
<PollStatusBadge status={row.original.status} />
</TooltipTrigger>
<TooltipContent>
{adjustTimeZone(
row.original.event.start,
!row.original.timeZone,
).format("LLLL")}
</TooltipContent>
</Tooltip>
) : (
<PollStatusBadge status={row.original.status} />
)}
</div>
);
},
}),
columnHelper.accessor("participants", {
header: () => null,
cell: ({ row }) => {
if (row.original.userId !== user.id) {
return null;
}
return (
<Link
className="text-link text-sm"
href={`/poll/${row.original.id}`}
>
<Trans i18nKey="manage" />
</Link>
);
},
}),
],
[adjustTimeZone, t, user.id],
);
};

View file

@ -0,0 +1,3 @@
export default function Loader() {
return null;
}

View file

@ -0,0 +1,23 @@
import { PollsList } from "@/app/[locale]/(admin)/polls/[[...list]]/polls-list";
import { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n";
interface PageParams extends Params {
list?: string;
}
export default async function Page({ params }: { params: PageParams }) {
const list = params.list ? params.list[0] : "all";
return <PollsList list={list} />;
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
return {
title: t("polls"),
};
}

View file

@ -0,0 +1,23 @@
"use client";
import {
ResponsiveMenu,
ResponsiveMenuItem,
} from "@/app/components/responsive-menu";
import { Trans } from "@/components/trans";
export function PollFolders() {
return (
<ResponsiveMenu>
<ResponsiveMenuItem href="/polls">
<Trans i18nKey="pollsListAll" defaults="All" />
</ResponsiveMenuItem>
<ResponsiveMenuItem href="/polls/mine">
<Trans i18nKey="pollsListMine" defaults="Mine" />
</ResponsiveMenuItem>
<ResponsiveMenuItem href="/polls/other">
<Trans i18nKey="pollsListOther" defaults="Other" />
</ResponsiveMenuItem>
</ResponsiveMenu>
);
}

View file

@ -0,0 +1,138 @@
"use client";
import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card";
import { Flex } from "@rallly/ui/flex";
import { Icon } from "@rallly/ui/icon";
import { PaginationState } from "@tanstack/react-table";
import { BarChart2Icon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { useTranslation } from "react-i18next";
import {
EmptyState,
EmptyStateDescription,
EmptyStateFooter,
EmptyStateIcon,
EmptyStateTitle,
} from "@/app/components/empty-state";
import { Spinner } from "@/components/spinner";
import { Table } from "@/components/table";
import { Trans } from "@/components/trans";
import { trpc } from "@/utils/trpc/client";
import { PollData, usePollColumns } from "./columns";
function PollsEmptyState() {
const { t } = useTranslation();
return (
<EmptyState className="h-96 rounded-lg border-2 border-dashed">
<EmptyStateIcon>
<BarChart2Icon />
</EmptyStateIcon>
<EmptyStateTitle>
{t("noPolls", { defaultValue: "No Polls" })}
</EmptyStateTitle>
<EmptyStateDescription>{t("noPollsDescription")}</EmptyStateDescription>
<EmptyStateFooter>
<Button variant="primary" asChild>
<Link href="/new">
<Icon>
<PlusIcon />
</Icon>
<Trans i18nKey="newPoll" />
</Link>
</Button>
</EmptyStateFooter>
</EmptyState>
);
}
export function PollsList({ list }: { list?: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const pagination = React.useMemo<PaginationState>(
() => ({
pageIndex: (Number(searchParams?.get("page")) || 1) - 1,
pageSize: Number(searchParams?.get("pageSize")) || 10,
}),
[searchParams],
);
// const sorting = React.useMemo<SortingState>(() => {
// const id = searchParams?.get("sort");
// const desc = searchParams?.get("desc");
// if (!id) {
// return [{ id: "createdAt", desc: true }];
// }
// return [{ id, desc: desc === "desc" }];
// }, [searchParams]);
const { data, isFetching } = trpc.polls.paginatedList.useQuery(
{ list, pagination },
{
staleTime: Infinity,
cacheTime: Infinity,
keepPreviousData: true,
},
);
const columns = usePollColumns();
if (!data) {
// return a table using <Skeleton /> components
return (
<Flex className="h-screen" align="center" justify="center">
<Spinner className="text-muted-foreground" />
</Flex>
);
}
return (
<div className="space-y-4">
{data.total ? (
<Card>
<Table
className={isFetching ? "opacity-50" : undefined}
layout="auto"
paginationState={pagination}
enableTableHeader={true}
data={data.rows as PollData[]}
pageCount={Math.ceil(data.total / pagination.pageSize)}
// sortingState={sorting}
// onSortingChange={(updater) => {
// const newSorting =
// typeof updater === "function" ? updater(sorting) : updater;
// const current = new URLSearchParams(searchParams ?? undefined);
// const sortColumn = newSorting[0];
// if (sortColumn === undefined) {
// current.delete("sort");
// current.delete("desc");
// } else {
// current.set("sort", sortColumn.id);
// current.set("desc", sortColumn.desc ? "desc" : "asc");
// }
// // current.set("pageSize", String(newPagination.pageSize));
// router.replace(`${pathname}?${current.toString()}`);
// }}
onPaginationChange={(updater) => {
const newPagination =
typeof updater === "function" ? updater(pagination) : updater;
const current = new URLSearchParams(searchParams ?? undefined);
current.set("page", String(newPagination.pageIndex + 1));
// current.set("pageSize", String(newPagination.pageSize));
router.replace(`${pathname}?${current.toString()}`);
}}
columns={columns}
/>
</Card>
) : (
<PollsEmptyState />
)}
</div>
);
}

View file

@ -1,8 +1,10 @@
import { Button } from "@rallly/ui/button";
import { PenBoxIcon } from "lucide-react";
import { Icon } from "@rallly/ui/icon";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { Trans } from "react-i18next/TransWithoutContext";
import { PollFolders } from "@/app/[locale]/(admin)/polls/[[...list]]/polls-folders";
import { Params } from "@/app/[locale]/types";
import {
PageContainer,
PageContent,
@ -11,43 +13,36 @@ import {
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
interface PageParams extends Params {
list?: string;
}
export default async function Layout({
params,
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
children?: React.ReactNode;
params: PageParams;
}) {
const { t } = await getTranslation(params.locale);
return (
<PageContainer>
<PageHeader>
<div className="flex items-center justify-between gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="polls" />
</PageTitle>
<Button asChild>
<div className="flex items-center gap-x-2.5">
<PageTitle>{t("polls")}</PageTitle>
<Button size="sm" asChild>
<Link href="/new">
<PenBoxIcon className="text-muted-foreground size-4" />
<span className="hidden sm:inline">
<Trans t={t} i18nKey="newPoll" />
</span>
<Icon>
<PlusIcon />
</Icon>
</Link>
</Button>
</div>
</PageHeader>
<PageContent>{children}</PageContent>
<PageContent className="space-y-3 lg:space-y-4">
<PollFolders />
{children}
</PageContent>
</PageContainer>
);
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
return {
title: t("polls"),
};
}

View file

@ -1,31 +0,0 @@
import { Skeleton } from "@/components/skeleton";
function Row() {
return (
<div className="flex items-center gap-x-4 py-4 first:pt-0">
<div className="grow">
<Skeleton className="mb-2 h-5 w-48" />
<Skeleton className="h-4 w-24" />
</div>
<div className="pr-8">
<Skeleton className="h-4 w-24" />
</div>
<div className="pr-8">
<Skeleton className="h-4 w-24" />
</div>
<div className="pr-8">
<Skeleton className="h-4 w-12" />
</div>
</div>
);
}
export default function Loader() {
return (
<div className="divide-y divide-gray-100">
<Row />
<Row />
<Row />
<Row />
</div>
);
}

View file

@ -1,18 +0,0 @@
import { getTranslation } from "@/app/i18n";
import { PollsList } from "./polls-list";
export default async function Page() {
return <PollsList />;
}
export async function generateMetadata({
params,
}: {
params: { locale: string };
}) {
const { t } = await getTranslation(params.locale);
return {
title: t("polls"),
};
}

View file

@ -1,55 +0,0 @@
"use client";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Trans } from "@/components/trans";
function PollFolder({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
const searchParams = useSearchParams();
const query = searchParams?.has("status")
? `?${searchParams?.toString()}`
: "";
const currentUrl = pathname + query;
const isActive = href === currentUrl;
return (
<Button
asChild
className={cn(
isActive
? "bg-gray-100"
: "text-muted-foreground shadow-sm hover:bg-gray-100 active:bg-gray-200",
)}
>
<Link href={href}>{children}</Link>
</Button>
);
}
export function PollFolders() {
return (
<div className="flex flex-wrap gap-3">
<PollFolder href="/polls">
<Trans i18nKey="pollStatusAll" defaults="All" />
</PollFolder>
<PollFolder href="/polls?status=live">
<Trans i18nKey="pollStatusLive" defaults="Live" />
</PollFolder>
<PollFolder href="/polls?status=paused">
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
</PollFolder>
<PollFolder href="/polls?status=finalized">
<Trans i18nKey="pollStatusFinalized" defaults="Finalized" />
</PollFolder>
</div>
);
}

View file

@ -1,216 +0,0 @@
"use client";
import { PollStatus } from "@rallly/database";
import { Button } from "@rallly/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { createColumnHelper, PaginationState } from "@tanstack/react-table";
import dayjs from "dayjs";
import { ArrowRightIcon, InboxIcon, PlusIcon, UsersIcon } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { PollStatusBadge } from "@/components/poll-status";
import { Table } from "@/components/table";
import { Trans } from "@/components/trans";
import { useDayjs } from "@/utils/dayjs";
import { trpc } from "@/utils/trpc/client";
import Loader from "./loading";
const EmptyState = () => {
return (
<div className="py-24">
<div className="mx-auto w-full max-w-md rounded-md border-2 border-dashed border-gray-300 p-8 text-center">
<div className="mb-4">
<InboxIcon className="inline-block size-10 text-gray-400" />
</div>
<h3 className="font-semibold">
<Trans i18nKey="noPolls" defaults="No polls" />
</h3>
<p className="text-muted-foreground">
<Trans
i18nKey="noPollsDescription"
defaults="Get started by creating a new poll."
/>
</p>
<div className="mt-6">
<Button variant="primary" asChild={true}>
<Link href="/new">
<PlusIcon className="size-5" />
<Trans defaults="New Poll" i18nKey="newPoll" />
</Link>
</Button>
</div>
</div>
</div>
);
};
type Column = {
id: string;
status: PollStatus;
title: string;
createdAt: Date;
participants: { name: string }[];
timeZone: string | null;
event: {
start: Date;
duration: number;
} | null;
};
const columnHelper = createColumnHelper<Column>();
export function PollsList() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const pagination = React.useMemo<PaginationState>(
() => ({
pageIndex: (Number(searchParams?.get("page")) || 1) - 1,
pageSize: Number(searchParams?.get("pageSize")) || 10,
}),
[searchParams],
);
const { data } = trpc.polls.paginatedList.useQuery({ pagination });
const { adjustTimeZone } = useDayjs();
const columns = React.useMemo(
() => [
columnHelper.display({
id: "title",
header: () => null,
size: 5000,
cell: ({ row }) => {
return (
<Link className="group block" href={`/poll/${row.original.id}`}>
<div className="mb-1 flex min-w-0 items-center gap-x-2">
<h3 className="truncate font-semibold text-gray-600 group-hover:text-gray-900">
{row.original.title}
</h3>
<ArrowRightIcon className="size-4 opacity-0 transition-all group-hover:opacity-100 group-focus:translate-x-2" />
</div>
{row.original.event ? (
<p className="text-muted-foreground text-sm">
{row.original.event.duration === 0
? adjustTimeZone(
row.original.event.start,
!row.original.timeZone,
).format("LL")
: `${adjustTimeZone(
row.original.event.start,
!row.original.timeZone,
).format("LL LT")} - ${adjustTimeZone(
dayjs(row.original.event.start).add(
row.original.event.duration,
"minutes",
),
!row.original.timeZone,
).format("LT")}`}
</p>
) : (
<p className="text-sm text-gray-400">
<Trans i18nKey="pending" defaults="Pending" />
</p>
)}
</Link>
);
},
}),
columnHelper.accessor("status", {
header: () => null,
size: 200,
cell: ({ row }) => {
return (
<div>
<PollStatusBadge status={row.getValue("status")} />
</div>
);
},
}),
columnHelper.accessor("createdAt", {
header: () => null,
size: 1000,
cell: ({ row }) => {
const { createdAt } = row.original;
return (
<p className="text-muted-foreground whitespace-nowrap text-sm">
<time dateTime={createdAt.toDateString()}>
<Trans
i18nKey="createdTime"
values={{ relativeTime: dayjs(createdAt).fromNow() }}
/>
</time>
</p>
);
},
}),
columnHelper.accessor("participants", {
header: () => null,
cell: ({ row }) => {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger className="text-muted-foreground flex items-center gap-x-2">
<UsersIcon className="size-4" />
<span className="text-sm">
{row.original.participants.length}
</span>
</TooltipTrigger>
<TooltipContent>
{row.original.participants.length > 0 ? (
<>
{row.original.participants
.slice(0, 10)
.map((participant, i) => (
<p key={i}>{participant.name}</p>
))}
{row.original.participants.length > 10 ? (
<p>
<Trans
i18nKey="xMore"
defaults="{count} more"
values={{
count: row.original.participants.length - 5,
}}
/>
</p>
) : null}
</>
) : (
<Trans i18nKey="noParticipants" defaults="No participants" />
)}
</TooltipContent>
</Tooltip>
);
},
}),
],
[adjustTimeZone],
);
if (!data) {
// return a table using <Skeleton /> components
return <Loader />;
}
if (data.total === 0) return <EmptyState />;
return (
<Table
layout="auto"
paginationState={pagination}
data={data.rows as Column[]}
pageCount={Math.ceil(data.total / pagination.pageSize)}
onPaginationChange={(updater) => {
const newPagination =
typeof updater === "function" ? updater(pagination) : updater;
const current = new URLSearchParams(searchParams ?? undefined);
current.set("page", String(newPagination.pageIndex + 1));
// current.set("pageSize", String(newPagination.pageSize));
router.push(`${pathname}?${current.toString()}`);
}}
columns={columns}
/>
);
}

View file

@ -67,16 +67,21 @@ const SubscriptionStatus = () => {
return (
<div className="space-y-6">
{!data.active ? (
<div>
<Label className="mb-4">
<Trans i18nKey="upgrade" />
</Label>
<BillingPlans />
</div>
) : data.legacy ? (
<LegacyBilling />
) : (
<SettingsSection
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
description={
<Trans
i18nKey="billingStatusDescription"
defaults="Manage your subscription and billing details."
/>
}
>
<BillingPortal />
</SettingsSection>
)}
</div>
);
@ -242,17 +247,7 @@ export function BillingPage() {
<title>{t("billing")}</title>
</Head>
<SettingsContent>
<SettingsSection
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
description={
<Trans
i18nKey="billingStatusDescription"
defaults="Manage your subscription and billing details."
/>
}
>
<SubscriptionStatus />
</SettingsSection>
<hr />
<SettingsSection
title={<Trans i18nKey="support" defaults="Support" />}

View file

@ -1,8 +1,9 @@
import { BillingPage } from "@/app/[locale]/(admin)/settings/billing/billing-page";
import { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n";
export default function Page() {
import { BillingPage } from "./billing-page";
export default async function Page() {
return <BillingPage />;
}

View file

@ -1,6 +1,4 @@
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
import React from "react";
import { Trans } from "react-i18next/TransWithoutContext";
import {
PageContainer,
@ -9,9 +7,8 @@ import {
PageTitle,
} from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
import { isSelfHosted } from "@/utils/constants";
import { SettingsMenu } from "./menu-item";
import { SettingsMenu } from "./settings-menu";
export default async function ProfileLayout({
children,
@ -20,41 +17,16 @@ export default async function ProfileLayout({
params: { locale: string };
}>) {
const { t } = await getTranslation(params.locale);
const menuItems = [
{
title: t("profile"),
href: "/settings/profile",
icon: UserIcon,
},
{
title: t("preferences"),
href: "/settings/preferences",
icon: Settings2Icon,
},
];
if (!isSelfHosted) {
menuItems.push({
title: t("billing"),
href: "/settings/billing",
icon: CreditCardIcon,
});
}
return (
<PageContainer>
<PageHeader>
<div className="flex items-center justify-between gap-x-4">
<PageTitle>
<Trans t={t} i18nKey="settings" />
</PageTitle>
</div>
<PageTitle>{t("settings")}</PageTitle>
</PageHeader>
<PageContent className="space-y-6">
<PageContent className="space-y-3 lg:space-y-4">
<div>
<SettingsMenu />
</div>
<div className="max-w-4xl">{children}</div>
<div>{children}</div>
</PageContent>
</PageContainer>
);

View file

@ -1,102 +0,0 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@rallly/ui/select";
import clsx from "clsx";
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import { useTranslation } from "react-i18next";
import { isSelfHosted } from "@/utils/constants";
export function MenuItem(props: { href: string; children: React.ReactNode }) {
const pathname = usePathname();
return (
<Link
className={clsx(
"flex min-w-0 items-center gap-x-2 rounded-none px-3 py-2 text-sm font-medium",
pathname === props.href
? "bg-white"
: "text-gray-500 hover:bg-gray-100 focus:bg-gray-200",
)}
href={props.href}
>
{props.children}
</Link>
);
}
export function SettingsMenu() {
const { t } = useTranslation();
const pathname = usePathname();
const menuItems = React.useMemo(() => {
const items = [
{
title: t("profile"),
href: "/settings/profile",
icon: UserIcon,
},
{
title: t("preferences"),
href: "/settings/preferences",
icon: Settings2Icon,
},
];
if (!isSelfHosted) {
items.push({
title: t("billing"),
href: "/settings/billing",
icon: CreditCardIcon,
});
}
return items;
}, [t]);
const router = useRouter();
const value = React.useMemo(
() => menuItems.find((item) => item.href === pathname),
[menuItems, pathname],
);
return (
<>
<div className="mb-4 hidden divide-x overflow-hidden rounded-md border bg-gray-50 shadow-sm lg:inline-flex">
{menuItems.map((item, i) => (
<MenuItem key={i} href={item.href}>
<item.icon className="size-4" />
{item.title}
</MenuItem>
))}
</div>
<Select
value={value?.title}
onValueChange={(value) => {
const item = menuItems.find((item) => item.title === value);
if (item) {
router.push(item.href);
}
}}
>
<SelectTrigger className="lg:hidden">
<SelectValue />
</SelectTrigger>
<SelectContent>
{menuItems.map((item, i) => (
<SelectItem key={i} value={item.title}>
<div className="flex items-center gap-x-2.5">
<item.icon className="text-muted-foreground size-4" />
<span className="font-medium">{item.title}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</>
);
}

View file

@ -1,8 +1,9 @@
import { PreferencesPage } from "@/app/[locale]/(admin)/settings/preferences/preferences-page";
import { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n";
export default function Page() {
import { PreferencesPage } from "./preferences-page";
export default async function Page() {
return <PreferencesPage />;
}

View file

@ -1,8 +1,9 @@
import { ProfilePage } from "@/app/[locale]/(admin)/settings/profile/profile-page";
import { Params } from "@/app/[locale]/types";
import { getTranslation } from "@/app/i18n";
export default function Page() {
import { ProfilePage } from "./profile-page";
export default async function Page() {
return <ProfilePage />;
}

View file

@ -0,0 +1,38 @@
"use client";
import { Icon } from "@rallly/ui/icon";
import { CreditCardIcon, Settings2Icon, UserIcon } from "lucide-react";
import { Trans } from "react-i18next";
import {
ResponsiveMenu,
ResponsiveMenuItem,
} from "@/app/components/responsive-menu";
import { IfCloudHosted } from "@/contexts/environment";
export function SettingsMenu() {
return (
<ResponsiveMenu>
<ResponsiveMenuItem href="/settings/profile">
<Icon>
<UserIcon />
</Icon>
<Trans i18nKey="profile" />
</ResponsiveMenuItem>
<ResponsiveMenuItem href="/settings/preferences">
<Icon>
<Settings2Icon />
</Icon>
<Trans i18nKey="preferences" />
</ResponsiveMenuItem>
<IfCloudHosted>
<ResponsiveMenuItem href="/settings/billing">
<Icon>
<CreditCardIcon />
</Icon>
<Trans i18nKey="billing" />
</ResponsiveMenuItem>
</IfCloudHosted>
</ResponsiveMenu>
);
}

View file

@ -2,16 +2,20 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import {
ArrowUpRightIcon,
BarChart2Icon,
BlocksIcon,
BookMarkedIcon,
CalendarIcon,
ChevronRightIcon,
LifeBuoyIcon,
LogInIcon,
PlusIcon,
Settings2Icon,
SparklesIcon,
UsersIcon,
VoteIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@ -26,10 +30,12 @@ import { IconComponent } from "@/types";
function NavItem({
href,
children,
target,
icon: Icon,
current,
}: {
href: string;
target?: string;
icon: IconComponent;
children: React.ReactNode;
current?: boolean;
@ -37,11 +43,12 @@ function NavItem({
return (
<Link
href={href}
target={target}
className={cn(
current
? "bg-gray-200 text-gray-800"
: "text-gray-700 hover:bg-gray-200 active:bg-gray-300",
"group flex items-center gap-x-3 rounded-md px-3 py-2 text-sm font-semibold leading-6",
"group flex items-center gap-x-2.5 rounded-md px-3 py-2 text-sm font-semibold leading-6",
)}
>
<Icon
@ -63,23 +70,33 @@ export function Sidebar() {
<nav className="flex flex-1 flex-col ">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
<ul role="list" className="space-y-1 lg:-mx-2">
<li>
<NavItem
current={pathname?.startsWith("/poll")}
href="/polls"
icon={VoteIcon}
icon={BarChart2Icon}
>
<Trans i18nKey="polls" defaults="Polls" />
</NavItem>
</li>
</ul>
</li>
<li className="space-y-1 lg:-mx-2">
<Button className="w-full rounded-full" variant="primary" asChild>
<Link href="/new">
<Icon>
<PlusIcon />
</Icon>
<Trans i18nKey="newPoll" defaults="New Poll" />
</Link>
</Button>
</li>
<li>
<div className="text-xs font-semibold leading-6 text-gray-400">
<Trans i18nKey="comingSoon" defaults="Coming Soon" />
</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
<ul role="list" className="mt-2 space-y-1 lg:-mx-2">
<li className="pointer-events-none grid gap-1 opacity-50">
<NavItem href="/events" icon={CalendarIcon}>
<Trans i18nKey="events" defaults="Events" />
@ -97,7 +114,7 @@ export function Sidebar() {
</ul>
</li>
<li className="mt-auto">
<ul role="list" className="-mx-2 space-y-1">
<ul role="list" className="space-y-1 lg:-mx-2">
<IfFreeUser>
<li>
<Link
@ -127,6 +144,18 @@ export function Sidebar() {
</NavItem>
</li>
</IfGuest>
<li>
<NavItem
target="_blank"
href="https://support.rallly.co"
icon={LifeBuoyIcon}
>
<Trans i18nKey="support" />
<Icon>
<ArrowUpRightIcon />
</Icon>
</NavItem>
</li>
<li>
<NavItem href="/settings/preferences" icon={Settings2Icon}>
<Trans i18nKey="preferences" />
@ -134,7 +163,7 @@ export function Sidebar() {
</li>
</ul>
<hr className="my-2" />
<ul role="list" className="-mx-2 space-y-1">
<ul role="list" className="space-y-1 lg:-mx-2">
<li>
<Button
asChild

View file

@ -1,106 +1,69 @@
"use client";
import { Button } from "@rallly/ui/button";
import { ArrowUpLeftIcon } from "lucide-react";
import Head from "next/head";
import { Icon } from "@rallly/ui/icon";
import { ArrowUpRightIcon, UserCircle2Icon } from "lucide-react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import React from "react";
import { PageHeader } from "@/app/components/page-layout";
import { Poll } from "@/components/poll";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import Discussion from "@/components/discussion";
import { EventCard } from "@/components/event-card";
import { PollFooter } from "@/components/poll/poll-footer";
import { PollHeader } from "@/components/poll/poll-header";
import { ResponsiveResults } from "@/components/poll/responsive-results";
import { useTouchBeacon } from "@/components/poll/use-touch-beacon";
import { VotingForm } from "@/components/poll/voting-form";
import { ScheduledEvent } from "@/components/scheduled-event";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { useUser } from "@/components/user-provider";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
import Loader from "./loading";
const Prefetch = ({ children }: React.PropsWithChildren) => {
const searchParams = useSearchParams();
const token = searchParams?.get("token") as string;
const params = useParams<{ urlId: string }>();
const urlId = params?.urlId as string;
const { data: permission } = trpc.auth.getUserPermission.useQuery(
{ token },
{
enabled: !!token,
},
);
const { data: poll, error } = trpc.polls.get.useQuery(
{ urlId },
{
retry: false,
},
);
const { data: participants } = trpc.polls.participants.list.useQuery({
pollId: urlId,
});
if (error?.data?.code === "NOT_FOUND") {
return <div>Not found</div>;
}
if (!poll || !participants) {
return <Loader />;
}
return (
<PermissionsContext.Provider value={{ userId: permission?.userId ?? null }}>
<Head>
<title>{poll.title}</title>
</Head>
{children}
</PermissionsContext.Provider>
);
};
const GoToApp = () => {
const poll = usePoll();
const { user } = useUser();
if (poll.userId !== user.id) {
return null;
}
return (
<PageHeader variant="ghost">
<div className="flex justify-between">
<div>
<Button
variant="ghost"
asChild
className={poll.userId !== user.id ? "hidden" : ""}
<div className="flex gap-2.5 rounded-md border p-2.5 text-sm sm:items-center">
<Icon>
<UserCircle2Icon />
</Icon>
<div className="flex grow flex-col gap-x-2.5 sm:flex-row">
<h4 className="font-semibold">
<Trans i18nKey="eventHostTitle" defaults="Manage Access" />
</h4>
<p className="text-muted-foreground">
<Trans
i18nKey="eventHostDescription"
defaults="You are the creator of this poll"
/>
</p>
</div>
<Link
className="text-link inline-flex items-center gap-x-2.5 lg:px-2.5"
href={`/poll/${poll.id}`}
>
<Link href={`/poll/${poll.id}`}>
<ArrowUpLeftIcon className="text-muted-foreground size-4" />
<Trans i18nKey="manage" />
<Icon>
<ArrowUpRightIcon />
</Icon>
</Link>
</Button>
</div>
<div>
<UserDropdown />
</div>
</div>
</PageHeader>
);
};
export function InvitePage() {
useTouchBeacon();
return (
<Prefetch>
<LegacyPollContextProvider>
<VisibilityProvider>
<div className="mx-auto max-w-4xl space-y-3 p-3 lg:space-y-4 lg:px-4 lg:py-8">
<PollHeader />
<GoToApp />
<div className="p-3 lg:px-6 lg:py-5">
<div className="mx-auto max-w-4xl">
<div className="-mx-1">
<Poll />
<EventCard />
<ScheduledEvent />
<VotingForm>
<ResponsiveResults />
</VotingForm>
<Discussion />
<PollFooter />
</div>
</div>
</div>
</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { useParams, useSearchParams } from "next/navigation";
import React from "react";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { VisibilityProvider } from "@/components/visibility";
import { PermissionsContext } from "@/contexts/permissions";
import { trpc } from "@/utils/trpc/client";
import Loader from "./loading";
const Prefetch = ({ children }: React.PropsWithChildren) => {
const searchParams = useSearchParams();
const token = searchParams?.get("token") as string;
const params = useParams<{ urlId: string }>();
const urlId = params?.urlId as string;
const { data: permission } = trpc.auth.getUserPermission.useQuery(
{ token },
{
enabled: !!token,
},
);
const { data: poll, error } = trpc.polls.get.useQuery(
{ urlId },
{
retry: false,
},
);
const { data: participants } = trpc.polls.participants.list.useQuery({
pollId: urlId,
});
if (error?.data?.code === "NOT_FOUND") {
return <div>Not found</div>;
}
if (!poll || !participants) {
return <Loader />;
}
return (
<PermissionsContext.Provider value={{ userId: permission?.userId ?? null }}>
{children}
</PermissionsContext.Provider>
);
};
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Prefetch>
<LegacyPollContextProvider>
<VisibilityProvider>{children}</VisibilityProvider>
</LegacyPollContextProvider>
</Prefetch>
);
}

View file

@ -1,12 +1,3 @@
import { PageContainer, PageHeader } from "@/app/components/page-layout";
import { Skeleton } from "@/components/skeleton";
export default function Loading() {
return (
<PageContainer>
<PageHeader className="flex justify-end" variant="ghost">
<Skeleton className="h-9 w-32" />
</PageHeader>
</PageContainer>
);
return null;
}

View file

@ -3,16 +3,11 @@ import { Metadata } from "next";
import { notFound } from "next/navigation";
import { InvitePage } from "@/app/[locale]/invite/[urlId]/invite-page";
import { PageContainer } from "@/app/components/page-layout";
import { getTranslation } from "@/app/i18n";
import { absoluteUrl } from "@/utils/absolute-url";
export default async function Page() {
return (
<PageContainer>
<InvitePage />
</PageContainer>
);
return <InvitePage />;
}
export async function generateMetadata({

View file

@ -3,6 +3,7 @@ import "../../style.css";
import languages from "@rallly/languages";
import { Toaster } from "@rallly/ui/toaster";
import { Viewport } from "next";
import { Inter } from "next/font/google";
import React from "react";
@ -17,6 +18,13 @@ export async function generateStaticParams() {
return Object.keys(languages).map((locale) => ({ locale }));
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function Root({
children,
params: { locale },

View file

@ -1,19 +0,0 @@
"use client";
import { Button } from "@rallly/ui/button";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
export function BackButton() {
const router = useRouter();
return (
<Button
variant="ghost"
onClick={() => {
router.back();
}}
>
<XIcon className="text-muted-foreground size-4" />
</Button>
);
}

View file

@ -1,30 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
import { BackButton } from "@/app/[locale]/menu/back-button";
export default function Page() {
return (
<div className="bg-gray-100">
<div className="flex items-center justify-between px-4 py-3">
<Link
className="inline-block transition-transform active:translate-y-1"
href="/"
>
<Image
src="/logo-mark.svg"
alt="Rallly"
width={32}
height={32}
className="shrink-0"
/>
</Link>
<BackButton />
</div>
<div className="px-5 py-5">
<Sidebar />
</div>
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import Discussion from "@/components/discussion";
import { EventCard } from "@/components/event-card";
import { PollFooter } from "@/components/poll/poll-footer";
import { PollHeader } from "@/components/poll/poll-header";
import { ResponsiveResults } from "@/components/poll/responsive-results";
import { useTouchBeacon } from "@/components/poll/use-touch-beacon";
import { VotingForm } from "@/components/poll/voting-form";
import { ScheduledEvent } from "@/components/scheduled-event";
import { GuestPollAlert } from "./guest-poll-alert";
export function AdminPage() {
useTouchBeacon();
return (
<div className="space-y-3 lg:space-y-4">
<PollHeader />
<GuestPollAlert />
<EventCard />
<ScheduledEvent />
<VotingForm>
<ResponsiveResults />
</VotingForm>
<Discussion />
<PollFooter />
</div>
);
}

View file

@ -51,7 +51,6 @@ const Page: NextPageWithLayout = () => {
<PayWall>
<Form {...form}>
<form
className="mx-auto max-w-3xl"
onSubmit={form.handleSubmit((data) => {
//submit
duplicate.mutate(
@ -90,7 +89,7 @@ const Page: NextPageWithLayout = () => {
<FormLabel>
<Trans i18nKey="duplicateTitleLabel" defaults="Title" />
</FormLabel>
<Input {...field} />
<Input className="w-full" {...field} />
<FormDescription>
<Trans
i18nKey="duplicateTitleDescription"

View file

@ -46,7 +46,6 @@ const Page: NextPageWithLayout = () => {
return (
<Form {...form}>
<form
className="mx-auto max-w-3xl"
onSubmit={form.handleSubmit((data) => {
//submit
updatePollMutation(

View file

@ -86,7 +86,6 @@ const Page = () => {
return (
<Form {...form}>
<form
className="mx-auto max-w-3xl"
onSubmit={form.handleSubmit((data) => {
const encodedOptions = data.options.map(encodeDateOption);
const optionsToDelete = poll.options.filter((option) => {

View file

@ -39,7 +39,6 @@ const Page = () => {
return (
<Form {...form}>
<form
className="mx-auto max-w-3xl"
onSubmit={form.handleSubmit(async (data) => {
//submit
await update.mutateAsync(

View file

@ -38,7 +38,7 @@ const FinalizationForm = () => {
});
return (
<Card className="mx-auto max-w-3xl">
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="finalize" />

View file

@ -1,16 +1,5 @@
import { cn } from "@rallly/ui";
import { AdminPage } from "@/app/[locale]/poll/[urlId]/admin-page";
import { Poll } from "@/components/poll";
import { GuestPollAlert } from "./guest-poll-alert";
export default async function Page() {
return (
<div className={cn("mx-auto max-w-4xl space-y-4")}>
<div className="-mx-1 space-y-3 sm:space-y-6">
<GuestPollAlert />
<Poll />
</div>
</div>
);
export default function Page() {
return <AdminPage />;
}

View file

@ -0,0 +1,45 @@
import { cn } from "@rallly/ui";
import { Icon } from "@rallly/ui/icon";
export function EmptyState({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-center justify-center text-center",
className,
)}
>
{children}
</div>
);
}
export function EmptyStateIcon({ children }: { children: React.ReactNode }) {
return (
<div className="mb-4 inline-flex rounded-full border p-4">
<Icon size="lg">{children}</Icon>
</div>
);
}
export function EmptyStateTitle({ children }: { children: React.ReactNode }) {
return <p className="text-base font-semibold">{children}</p>;
}
export function EmptyStateDescription({
children,
}: {
children: React.ReactNode;
}) {
return <p className="text-muted-foreground mt-1 text-sm">{children}</p>;
}
export function EmptyStateFooter({ children }: { children: React.ReactNode }) {
return <div className="mt-4">{children}</div>;
}

View file

@ -1,11 +1,35 @@
"use client";
import { cn } from "@rallly/ui";
import { Icon } from "@rallly/ui/icon";
export function PageContainer({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) {
return <div className={cn("", className)}>{children}</div>;
return (
<div
className={cn(
"h-full max-w-4xl grow px-3 py-4 lg:px-4 lg:py-6",
className,
)}
>
{children}
</div>
);
}
export function PageIcon({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn(className)}>
<Icon size="lg">{children}</Icon>
</div>
);
}
export function PageTitle({
@ -16,7 +40,7 @@ export function PageTitle({
className?: string;
}) {
return (
<h2 className={cn("truncate font-semibold leading-9", className)}>
<h2 className={cn("truncate text-base font-semibold", className)}>
{children}
</h2>
);
@ -25,25 +49,12 @@ export function PageTitle({
export function PageHeader({
children,
className,
variant = "default",
}: {
children?: React.ReactNode;
className?: string;
variant?: "default" | "ghost";
}) {
return (
<div
className={cn(
"px-4 py-3 lg:px-6 lg:py-3",
{
"sticky top-0 z-20 border-b bg-gray-50": variant === "default",
},
className,
)}
>
{children}
</div>
);
return <div className={cn("mb-4 lg:mb-6", className)}>{children}</div>;
}
export function PageContent({
@ -53,5 +64,5 @@ export function PageContent({
children?: React.ReactNode;
className?: string;
}) {
return <div className={cn("p-4 lg:px-6 lg:py-5", className)}>{children}</div>;
return <div className={cn("lg:grow", className)}>{children}</div>;
}

View file

@ -0,0 +1,118 @@
"use client";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@rallly/ui/select";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import { useBreakpoint } from "@/utils/breakpoint";
const ResponsiveMenuContext = React.createContext<"desktop" | "mobile">(
"desktop",
);
function DesktopMenuItem({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<li>
<Link
className={cn(
"flex h-9 min-w-0 grow items-center gap-x-2.5 rounded-md border px-2.5 text-sm font-medium",
pathname === href
? "bg-gray-50"
: "text-gray-500 hover:bg-gray-100 active:bg-gray-200",
)}
href={href}
>
{children}
</Link>
</li>
);
}
function MobileMenuItem({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
return (
<SelectItem value={href}>
<div className="flex items-center gap-x-2">{children}</div>
</SelectItem>
);
}
export function ResponsiveMenuItem(props: {
href: string;
children: React.ReactNode;
}) {
const breakpoint = React.useContext(ResponsiveMenuContext);
switch (breakpoint) {
case "desktop":
return <DesktopMenuItem {...props} />;
case "mobile":
return <MobileMenuItem {...props} />;
}
}
function DesktopMenu({ children }: { children: React.ReactNode }) {
return (
<ResponsiveMenuContext.Provider value="desktop">
<ul className="inline-flex gap-2.5">{children}</ul>
</ResponsiveMenuContext.Provider>
);
}
function MobileMenu({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
if (!pathname) {
return null;
}
return (
<ResponsiveMenuContext.Provider value="mobile">
<Select
value={pathname}
onValueChange={(destination) => {
router.push(destination);
}}
>
<SelectTrigger asChild className="w-full">
<Button>
<SelectValue />
</Button>
</SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
</ResponsiveMenuContext.Provider>
);
}
export function ResponsiveMenu({ children }: { children?: React.ReactNode }) {
const breakpoint = useBreakpoint();
switch (breakpoint) {
case "desktop":
return <DesktopMenu>{children}</DesktopMenu>;
case "mobile":
return <MobileMenu>{children}</MobileMenu>;
}
}

View file

@ -21,9 +21,10 @@ export const BillingPlans = () => {
const [tab, setTab] = React.useState("yearly");
return (
<div className="space-y-4">
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="mb-4">
<div className="space-y-4">
<div>
<TabsList>
<TabsTrigger value="monthly">
<Trans i18nKey="billingPeriodMonthly" />
</TabsTrigger>
@ -31,6 +32,7 @@ export const BillingPlans = () => {
<Trans i18nKey="billingPeriodYearly" />
</TabsTrigger>
</TabsList>
</div>
<div className="grid gap-4 rounded-md md:grid-cols-2">
<BillingPlan>
<BillingPlanHeader>
@ -69,7 +71,7 @@ export const BillingPlans = () => {
</BillingPlanPerk>
</BillingPlanPerks>
</BillingPlan>
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-4 rounded-md border bg-white p-4 shadow-sm">
<div>
<BillingPlanTitle>
<Trans i18nKey="planPro" />
@ -126,8 +128,7 @@ export const BillingPlans = () => {
</BillingPlanPerks>
</div>
</div>
</Tabs>
<div className="rounded-md border border-cyan-200 bg-cyan-50 px-4 py-3 text-cyan-800">
<div className="rounded-lg border border-cyan-800/10 bg-cyan-50 px-4 py-3 text-cyan-800 shadow-sm">
<div className="mb-2">
<TrendingUpIcon className="text-indigo mr-2 mt-0.5 size-6 shrink-0" />
</div>
@ -147,5 +148,6 @@ export const BillingPlans = () => {
</p>
</div>
</div>
</Tabs>
);
};

View file

@ -78,10 +78,11 @@ export const TimesShownIn = () => {
return (
<ClockPreferences>
<button className="inline-flex items-center gap-x-2 text-sm hover:underline">
<button className="inline-flex items-center gap-x-2.5 text-sm hover:underline">
<GlobeIcon className="size-4" />
<Trans
i18nKey="timeShownIn"
defaults="Times shown in {timeZone}"
values={{ timeZone: timeZone.replaceAll("_", " ") }}
/>
</button>

View file

@ -98,14 +98,14 @@ export const CreatePoll: React.FunctionComponent = () => {
numberOfOptions: formData.options?.length,
optionsView: formData?.view,
});
queryClient.polls.list.invalidate();
queryClient.invalidate();
router.push(`/poll/${res.id}`);
},
},
);
})}
>
<div className="mx-auto max-w-4xl space-y-4">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>

View file

@ -10,14 +10,14 @@ export const DateIconInner = (props: {
return (
<div
className={clsx(
"inline-flex size-12 flex-col overflow-hidden rounded-md border bg-gray-50 text-center text-slate-800",
"inline-flex size-10 flex-col overflow-hidden rounded-md border bg-gray-50 text-center text-slate-800",
props.className,
)}
>
<div className="text-muted-foreground border-b border-gray-200 text-xs font-normal leading-4">
{props.dow}
</div>
<div className="flex grow items-center justify-center bg-white text-lg font-semibold leading-none tracking-tight">
<div className="flex grow items-center justify-center bg-white text-sm font-medium leading-none tracking-tight">
{props.day}
</div>
</div>

View file

@ -1,19 +1,36 @@
"use client";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import { Textarea } from "@rallly/ui/textarea";
import dayjs from "dayjs";
import { MessageCircleIcon, MoreHorizontalIcon, TrashIcon } from "lucide-react";
import {
MessageSquareOffIcon,
MoreHorizontalIcon,
TrashIcon,
} from "lucide-react";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useParticipants } from "@/components/participants-provider";
import { Trans } from "@/components/trans";
import { usePermissions } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { useRole } from "@/contexts/role";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
@ -22,7 +39,6 @@ import { requiredString } from "../../utils/form-validation";
import NameInput from "../name-input";
import TruncatedLinkify from "../poll/truncated-linkify";
import UserAvatar from "../poll/user-avatar";
import { usePoll } from "../poll-context";
import { useUser } from "../user-provider";
interface CommentForm {
@ -30,9 +46,107 @@ interface CommentForm {
content: string;
}
const Discussion: React.FunctionComponent = () => {
function NewCommentForm({
onSubmit,
onCancel,
}: {
onSubmit?: () => void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
const { poll } = usePoll();
const poll = usePoll();
const { user } = useUser();
const { participants } = useParticipants();
const authorName = React.useMemo(() => {
if (user.isGuest) {
const participant = participants.find((p) => p.userId === user.id);
return participant?.name ?? "";
} else {
return user.name;
}
}, [user, participants]);
const pollId = poll.id;
const posthog = usePostHog();
const queryClient = trpc.useUtils();
const addComment = trpc.polls.comments.add.useMutation({
onSuccess: () => {
queryClient.polls.comments.invalidate();
posthog?.capture("created comment");
},
});
const session = useUser();
const { register, reset, control, handleSubmit, formState } =
useForm<CommentForm>({
defaultValues: {
authorName,
content: "",
},
});
return (
<form
className="w-full space-y-2.5"
onSubmit={handleSubmit(async ({ authorName, content }) => {
await addComment.mutateAsync({ authorName, content, pollId });
reset({ authorName, content: "" });
onSubmit?.();
})}
>
<div>
<Textarea
id="comment"
className="w-full"
autoFocus={true}
placeholder={t("commentPlaceholder")}
{...register("content", { validate: requiredString })}
/>
</div>
<div
className={cn("mb-2", {
hidden: !user.isGuest,
})}
>
<Controller
name="authorName"
key={session.user?.id}
control={control}
rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput error={!!formState.errors.authorName} {...field} />
)}
/>
</div>
<div className="flex gap-2.5">
<Button
type="submit"
variant="primary"
loading={formState.isSubmitting}
>
<Trans defaults="Add Comment" i18nKey="addComment" />
</Button>
<Button
onClick={() => {
reset();
onCancel?.();
}}
>
{t("cancel")}
</Button>
</div>
</form>
);
}
function DiscussionInner() {
const { t } = useTranslation();
const poll = usePoll();
const pollId = poll.id;
@ -46,13 +160,6 @@ const Discussion: React.FunctionComponent = () => {
const queryClient = trpc.useUtils();
const addComment = trpc.polls.comments.add.useMutation({
onSuccess: () => {
queryClient.polls.comments.invalidate();
posthog?.capture("created comment");
},
});
const deleteComment = trpc.polls.comments.delete.useMutation({
onMutate: ({ commentId }) => {
queryClient.polls.comments.list.setData(
@ -69,14 +176,6 @@ const Discussion: React.FunctionComponent = () => {
const session = useUser();
const { register, reset, control, handleSubmit, formState } =
useForm<CommentForm>({
defaultValues: {
authorName: "",
content: "",
},
});
const [isWriting, setIsWriting] = React.useState(false);
const role = useRole();
const { isUser } = usePermissions();
@ -86,13 +185,16 @@ const Discussion: React.FunctionComponent = () => {
}
return (
<div className="divide-y">
<div className="flex items-center gap-2 bg-gray-50 px-4 py-3 font-semibold">
<MessageCircleIcon className="size-5" /> {t("comments")} (
{comments.length})
</div>
<Card>
<CardHeader>
<CardTitle>
{t("comments")}
<Badge>{comments.length}</Badge>
</CardTitle>
</CardHeader>
{comments.length ? (
<div className="space-y-4 p-4">
<CardContent className="border-b">
<div className="space-y-4">
{comments.map((comment) => {
const canDelete =
role === "admin" || (comment.userId && isUser(comment.userId));
@ -119,13 +221,14 @@ const Discussion: React.FunctionComponent = () => {
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
className="text-destructive"
onSelect={() => {
deleteComment.mutate({
commentId: comment.id,
});
}}
>
<TrashIcon className="mr-2 size-4" />
<TrashIcon className="size-4" />
<Trans i18nKey="delete" />
</DropdownMenuItem>
</DropdownMenuContent>
@ -133,7 +236,7 @@ const Discussion: React.FunctionComponent = () => {
)}
</div>
</div>
<div className="ml-0.5 w-fit whitespace-pre-wrap pl-8 text-sm leading-relaxed">
<div className="ml-0.5 w-fit whitespace-pre-wrap pl-7 text-sm leading-relaxed">
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div>
</div>
@ -141,58 +244,22 @@ const Discussion: React.FunctionComponent = () => {
);
})}
</div>
</CardContent>
) : null}
<div className="p-3">
{!poll.event ? (
<CardFooter className="border-t-0">
{isWriting ? (
<form
className="space-y-2.5"
onSubmit={handleSubmit(async ({ authorName, content }) => {
await addComment.mutateAsync({ authorName, content, pollId });
reset({ authorName, content: "" });
setIsWriting(false);
})}
>
<div>
<Textarea
id="comment"
className="w-full"
autoFocus={true}
placeholder={t("commentPlaceholder")}
{...register("content", { validate: requiredString })}
/>
</div>
<div className="mb-2">
<Controller
name="authorName"
key={session.user?.id}
control={control}
rules={{ validate: requiredString }}
render={({ field }) => (
<NameInput error={!!formState.errors.authorName} {...field} />
)}
/>
</div>
<div className="flex justify-between gap-2">
<Button
onClick={() => {
reset();
<NewCommentForm
onSubmit={() => {
setIsWriting(false);
}}
>
{t("cancel")}
</Button>
<Button
type="submit"
variant="primary"
loading={formState.isSubmitting}
>
<Trans defaults="Add Comment" i18nKey="addComment" />
</Button>
</div>
</form>
onCancel={() => {
setIsWriting(false);
}}
/>
) : (
<button
className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-3 py-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
className="border-input text-muted-foreground flex w-full rounded border bg-transparent px-2 py-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
onClick={() => setIsWriting(true)}
>
<Trans
@ -201,9 +268,26 @@ const Discussion: React.FunctionComponent = () => {
/>
</button>
)}
</div>
</div>
</CardFooter>
) : null}
</Card>
);
};
}
export default React.memo(Discussion);
export default function Discussion() {
const poll = usePoll();
if (poll.disableComments) {
return (
<p className="text-muted-foreground rounded-lg bg-gray-100 p-4 text-center text-sm">
<Icon>
<MessageSquareOffIcon className="mr-2 inline-block" />
</Icon>
<Trans
i18nKey="commentsDisabled"
defaults="Comments have been disabled"
/>
</p>
);
}
return <DiscussionInner />;
}

View file

@ -1,142 +1,87 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import { Card, CardContent, CardDescription, CardTitle } from "@rallly/ui/card";
import { Icon } from "@rallly/ui/icon";
import dayjs from "dayjs";
import { MapPinIcon, MousePointerClickIcon, TextIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import { DotIcon, MapPinIcon, PauseIcon } from "lucide-react";
import { Card } from "@/components/card";
import { DateIcon } from "@/components/date-icon";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { useParticipants } from "@/components/participants-provider";
import { useTranslation } from "@/app/i18n/client";
import TruncatedLinkify from "@/components/poll/truncated-linkify";
import { PollStatusBadge } from "@/components/poll-status";
import { RandomGradientBar } from "@/components/random-gradient-bar";
import { Trans } from "@/components/trans";
import { IfParticipantsVisible } from "@/components/visibility";
import { usePoll } from "@/contexts/poll";
import { generateGradient } from "@/utils/color-hash";
import { useDayjs } from "@/utils/dayjs";
import { preventWidows } from "@/utils/prevent-widows";
import PollSubheader from "./poll/poll-subheader";
import TruncatedLinkify from "./poll/truncated-linkify";
import VoteIcon from "./poll/vote-icon";
export const EventCard = () => {
const { t } = useTranslation();
export function EventCard() {
const poll = usePoll();
const { participants } = useParticipants();
const { adjustTimeZone } = useDayjs();
const attendees = participants.filter((participant) =>
participant.votes.some(
(vote) =>
vote.optionId === poll?.event?.optionId &&
(vote.type === "yes" || vote.type === "ifNeedBe"),
),
);
if (!poll) {
return null;
}
const { t } = useTranslation();
return (
<Card className="overflow-visible" fullWidthOnMobile={false}>
<div
className="-mx-px -mt-px h-2 rounded-t-md"
style={{ background: generateGradient(poll.id) }}
/>
<div className="bg-pattern grid gap-4 p-4 sm:flex sm:justify-between sm:px-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 sm:gap-6">
{poll.event ? (
<>
<Card className="bg-gray-50">
<RandomGradientBar seed={poll.id} />
<CardContent>
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">
<div>
<DateIcon
date={adjustTimeZone(poll.event.start, !poll.timeZone)}
/>
</div>
) : null}
<div>
<h1
className="mb-1 text-xl font-bold tracking-tight"
data-testid="poll-title"
>
{preventWidows(poll.title)}
</h1>
{poll.event ? (
<div className="text-muted-foreground text-sm">
{poll.event.duration === 0
? adjustTimeZone(poll.event.start, !poll.timeZone).format(
"LL",
)
: `${adjustTimeZone(
poll.event.start,
!poll.timeZone,
).format("LL LT")} - ${adjustTimeZone(
dayjs(poll.event.start).add(
poll.event.duration,
"minutes",
),
!poll.timeZone,
).format("LT")}`}
</div>
) : null}
{!poll.event ? (
<PollSubheader />
) : (
<div className="mt-4 space-y-2">
<div className="text-muted-foreground text-sm">
<CardTitle data-testid="poll-title" className="text-lg">
{poll.title}
</CardTitle>
<CardDescription>
<span className="flex items-center gap-1 text-sm text-gray-500">
<span>
<Trans
i18nKey="attendeeCount"
defaults="{count, plural, one {# attendee} other {# attendees}}"
values={{ count: attendees.length }}
i18nKey="createdBy"
values={{
name: poll.user?.name ?? t("guest"),
}}
components={{
b: <span />,
}}
/>
</span>
<Icon>
<DotIcon />
</Icon>
<span className="whitespace-nowrap">
<Trans
i18nKey="createdTime"
values={{ relativeTime: dayjs(poll.createdAt).fromNow() }}
/>
</span>
</span>
</CardDescription>
</div>
<IfParticipantsVisible>
<ParticipantAvatarBar participants={attendees} max={10} />
</IfParticipantsVisible>
</div>
)}
</div>
</div>
</div>
<div>
<PollStatusBadge status={poll.status} />
</div>
</div>
<div className="space-y-4 p-4 sm:px-6">
{poll.description ? (
<div className="flex gap-4">
<TextIcon className="text-muted-foreground size-4 shrink-0 translate-y-1" />
<div className="whitespace-pre-line">
<p className="mt-4 min-w-0 text-wrap text-sm leading-relaxed">
<TruncatedLinkify>{poll.description}</TruncatedLinkify>
</div>
</div>
</p>
) : null}
{poll.location ? (
<div className="flex gap-4">
<MapPinIcon className="text-muted-foreground size-4 translate-y-1" />
<ul className="text-muted-foreground mt-4 flex flex-col gap-x-4 gap-y-2.5 whitespace-nowrap text-sm">
<li className="flex items-center gap-x-2.5">
<Icon>
<MapPinIcon />
</Icon>
<TruncatedLinkify>{poll.location}</TruncatedLinkify>
</div>
</li>
</ul>
) : null}
<div className="flex gap-4">
<MousePointerClickIcon className="text-muted-foreground size-4 shrink-0 translate-y-0.5" />
<div>
<div className="flex gap-2.5">
<span className="inline-flex items-center space-x-1">
<VoteIcon type="yes" />
<span className="text-sm">{t("yes")}</span>
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="ifNeedBe" />
<span className="text-sm">{t("ifNeedBe")}</span>
</span>
<span className="inline-flex items-center space-x-1">
<VoteIcon type="no" />
<span className="text-sm">{t("no")}</span>
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{poll.status === "paused" ? (
<Alert icon={PauseIcon}>
<AlertTitle>
<Trans i18nKey="pollStatusPaused" />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey="pollStatusPausedDescription"
defaults="Votes cannot be submitted or edited at this time"
/>
</AlertDescription>
</Alert>
) : null}
</>
);
};
}

View file

@ -1,7 +1,6 @@
import { FormField, FormItem, FormLabel, FormMessage } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { Textarea } from "@rallly/ui/textarea";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import { useFormContext } from "react-hook-form";
@ -40,10 +39,9 @@ export const PollDetailsForm = () => {
<Input
{...field}
type="text"
error={!!errors.title}
id="title"
className={clsx("w-full", {
"input-error": errors.title,
})}
className="w-full"
placeholder={t("titlePlaceholder")}
/>
<FormMessage />

View file

@ -8,6 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import { Switch } from "@rallly/ui/switch";
import clsx from "clsx";
import dayjs from "dayjs";
@ -99,19 +100,19 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
<div>
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center justify-center space-x-4">
<Button
icon={ChevronLeftIcon}
title={t("previousMonth")}
onClick={datepicker.prev}
/>
<Button title={t("previousMonth")} onClick={datepicker.prev}>
<Icon>
<ChevronLeftIcon />
</Icon>
</Button>
<div className="grow text-center font-semibold tracking-tight">
{datepicker.label}
</div>
<Button
title={t("nextMonth")}
icon={ChevronRightIcon}
onClick={datepicker.next}
/>
<Button title={t("nextMonth")} onClick={datepicker.next}>
<Icon>
<ChevronRightIcon />
</Icon>
</Button>
</div>
<div className="grid grid-cols-7">
{datepicker.daysOfWeek.map((dayOfWeek) => {
@ -343,7 +344,6 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
})}
<div className="flex items-center space-x-3">
<Button
icon={PlusIcon}
onClick={() => {
const lastOption = expectTimeOption(
optionsForDay[optionsForDay.length - 1].option,
@ -373,6 +373,9 @@ const MonthCalendar: React.FunctionComponent<DateTimePickerProps> = ({
]);
}}
>
<Icon>
<PlusIcon />
</Icon>
{t("addTimeOption")}
</Button>
<DropdownMenu>

View file

@ -20,7 +20,7 @@ import React from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import { ProBadge } from "@/components/pro-badge";
import { ProFeatureBadge } from "@/components/pro-feature-badge";
import { usePlan } from "@/contexts/plan";
export type PollSettingsFormData = {
@ -46,7 +46,7 @@ const SettingTitle = ({
return (
<div className="flex min-w-0 items-center gap-x-2.5">
<div className="text-sm font-medium">{children}</div>
{pro ? <ProBadge /> : null}
{pro ? <ProFeatureBadge /> : null}
{hint ? (
<Tooltip>
<TooltipTrigger>

View file

@ -7,6 +7,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@rallly/ui/dialog";
import { Icon } from "@rallly/ui/icon";
import { ArrowUpRightIcon, Share2Icon } from "lucide-react";
import Link from "next/link";
import React from "react";
@ -56,14 +57,14 @@ export const InviteDialog = () => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild={true}>
<Button variant="primary" icon={Share2Icon}>
<Button variant="primary">
<Icon>
<Share2Icon />
</Icon>
<Trans i18nKey="share" defaults="Share" />
</Button>
</DialogTrigger>
<DialogContent
data-testid="invite-participant-dialog"
className="bg-gradient-to-b from-gray-100 via-white to-white sm:max-w-md"
>
<DialogContent data-testid="invite-participant-dialog">
<div className="flex">
<Share2Icon className="text-primary size-6" />
</div>

View file

@ -1,22 +1,12 @@
"use client";
import { Button } from "@rallly/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIconLabel,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import {
ArrowLeftIcon,
ArrowUpRight,
ChevronDownIcon,
ListIcon,
LogInIcon,
LogOutIcon,
PauseCircleIcon,
PlayCircleIcon,
RotateCcw,
ShieldCloseIcon,
} from "lucide-react";
import Link from "next/link";
@ -25,12 +15,6 @@ import React from "react";
import Loader from "@/app/[locale]/poll/[urlId]/skeleton";
import { LogoutButton } from "@/app/components/logout-button";
import {
PageContainer,
PageContent,
PageHeader,
PageTitle,
} from "@/app/components/page-layout";
import { InviteDialog } from "@/components/invite-dialog";
import { LoginLink } from "@/components/login-link";
import {
@ -43,117 +27,16 @@ import {
import ManagePoll from "@/components/poll/manage-poll";
import NotificationsToggle from "@/components/poll/notifications-toggle";
import { LegacyPollContextProvider } from "@/components/poll/poll-context-provider";
import { PollStatusLabel } from "@/components/poll-status";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { usePlan } from "@/contexts/plan";
import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
const StatusControl = () => {
const poll = usePoll();
const queryClient = trpc.useUtils();
const reopen = trpc.polls.reopen.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
event: null,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
const pause = trpc.polls.pause.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
closed: true,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
const resume = trpc.polls.resume.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
closed: false,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button>
<PollStatusLabel status={poll.status} />
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{poll.event ? (
<DropdownMenuItem
onClick={() => {
reopen.mutate({ pollId: poll.id });
}}
>
<DropdownMenuItemIconLabel icon={RotateCcw}>
<Trans i18nKey="reopenPoll" defaults="Reopen Poll" />
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
) : (
<>
{poll.closed ? (
<DropdownMenuItem
onClick={() => resume.mutate({ pollId: poll.id })}
>
<DropdownMenuItemIconLabel icon={PlayCircleIcon}>
<Trans i18nKey="resumePoll" defaults="Resume" />
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => pause.mutate({ pollId: poll.id })}
>
<DropdownMenuItemIconLabel icon={PauseCircleIcon}>
<Trans i18nKey="pausePoll" defaults="Pause" />
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
const AdminControls = () => {
return (
<div className="flex items-center gap-x-2">
<NotificationsToggle />
<StatusControl />
<ManagePoll />
<InviteDialog />
</div>
@ -166,37 +49,40 @@ const Layout = ({ children }: React.PropsWithChildren) => {
const pathname = usePathname();
return (
<PageContainer>
<PageHeader className="flex flex-col gap-x-4 gap-y-2.5 md:flex-row md:items-center">
<div className="flex min-w-0 items-center gap-x-4 md:basis-2/3">
<div className="flex gap-x-4 md:basis-1/2">
<div className="bg-gray-100">
<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-4">
{pathname === pollLink ? (
<Button asChild>
<Button variant="ghost" asChild>
<Link href="/polls">
<ListIcon className="size-4" />
<Icon>
<ListIcon />
</Icon>
</Link>
</Button>
) : (
<Button asChild>
<Button variant="ghost" asChild>
<Link href={pollLink}>
<ArrowLeftIcon className="size-4" />
<Icon>
<ArrowLeftIcon />
</Icon>
</Link>
</Button>
)}
<PageTitle>{poll.title}</PageTitle>
<h1 className="truncate text-sm font-medium">{poll.title}</h1>
</div>
</div>
<div className="flex basis-1/3 md:justify-end">
<div>
<AdminControls />
</div>
</PageHeader>
<PageContent>{children}</PageContent>
</PageContainer>
</div>
<div className="mx-auto max-w-4xl space-y-3 p-3 lg:space-y-4 lg:px-4 lg:py-8">
{children}
</div>
</div>
);
};
export const PermissionGuard = ({ children }: React.PropsWithChildren) => {
const PermissionGuard = ({ children }: React.PropsWithChildren) => {
const poll = usePoll();
const { user } = useUser();
if (!poll.adminUrlId) {
@ -226,13 +112,13 @@ export const PermissionGuard = ({ children }: React.PropsWithChildren) => {
{user.isGuest ? (
<Button asChild variant="primary">
<LoginLink>
<LogInIcon className="size-4 -ml-1" />
<LogInIcon className="-ml-1 size-4" />
<Trans i18nKey="login" defaults="Login" />
</LoginLink>
</Button>
) : (
<LogoutButton>
<LogOutIcon className="size-4 -ml-1" />
<LogOutIcon className="-ml-1 size-4" />
<Trans i18nKey="loginDifferent" defaults="Switch user" />
</LogoutButton>
)}
@ -258,7 +144,7 @@ const Prefetch = ({ children }: React.PropsWithChildren) => {
const poll = trpc.polls.get.useQuery({ urlId });
const participants = trpc.polls.participants.list.useQuery({ pollId: urlId });
const watchers = trpc.polls.getWatchers.useQuery({ pollId: urlId });
usePlan(); // prefetch plan
if (!poll.data || !watchers.data || !participants.data) {
return <Loader />;
}

View file

@ -0,0 +1,36 @@
"use client";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { ClockIcon } from "lucide-react";
import { Trans } from "@/components/trans";
import { usePreferences } from "@/contexts/preferences";
import { useDayjs } from "@/utils/dayjs";
export function TimeFormatControl() {
const { timeFormat } = useDayjs();
const { updatePreferences } = usePreferences();
return (
<Button
variant="ghost"
onClick={() => {
if (timeFormat === "hours12") {
updatePreferences({ timeFormat: "hours24" });
} else {
updatePreferences({ timeFormat: "hours12" });
}
}}
>
<Icon>
<ClockIcon />
</Icon>
{timeFormat === "hours12" ? (
<Trans i18nKey="12h" />
) : (
<Trans i18nKey="24h" />
)}
</Button>
);
}

View file

@ -0,0 +1,40 @@
"use client";
import { Button } from "@rallly/ui/button";
import { CommandDialog } from "@rallly/ui/command";
import { useDialog } from "@rallly/ui/dialog";
import { Icon } from "@rallly/ui/icon";
import { GlobeIcon } from "lucide-react";
import { TimeZoneCommand } from "@/components/time-zone-picker/time-zone-select";
import { usePreferences } from "@/contexts/preferences";
import { useDayjs } from "@/utils/dayjs";
export function TimezoneControl() {
const { timeZone } = useDayjs();
const { updatePreferences } = usePreferences();
const dialog = useDialog();
return (
<>
<Button
variant="ghost"
onClick={() => {
dialog.trigger();
}}
>
<Icon>
<GlobeIcon />
</Icon>
{timeZone}
</Button>
<CommandDialog {...dialog.dialogProps}>
<TimeZoneCommand
value={timeZone}
onSelect={(newTimeZone) => {
dialog.dismiss();
updatePreferences({ timeZone: newTimeZone });
}}
/>
</CommandDialog>
</>
);
}

View file

@ -1,23 +1,20 @@
import { cn } from "@rallly/ui";
import { Input, InputProps } from "@rallly/ui/input";
import { useTranslation } from "next-i18next";
import * as React from "react";
import UserAvatar from "./poll/user-avatar";
interface NameInputProps
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
interface NameInputProps extends InputProps {
value?: string;
defaultValue?: string;
error?: boolean;
}
const NameInput: React.ForwardRefRenderFunction<
HTMLInputElement,
NameInputProps
> = ({ value, defaultValue, className, error, ...forwardProps }, ref) => {
const NameInput = React.forwardRef<HTMLInputElement, NameInputProps>(function (
{ value, defaultValue, className, error, ...forwardProps },
ref,
) {
const { t } = useTranslation();
return (
<div className="relative flex items-center">
@ -27,7 +24,7 @@ const NameInput: React.ForwardRefRenderFunction<
className="absolute left-2"
/>
) : null}
<input
<Input
ref={ref}
className={cn(
"input text-sm",
@ -43,6 +40,8 @@ const NameInput: React.ForwardRefRenderFunction<
/>
</div>
);
};
});
export default React.forwardRef(NameInput);
NameInput.displayName = "NameInput";
export default NameInput;

View file

@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { VoteType } from "@rallly/database";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Input } from "@rallly/ui/input";
import clsx from "clsx";
@ -66,15 +67,13 @@ const VoteSummary = ({
return (
<div
key={voteType}
className="flex h-8 select-none divide-x rounded border bg-gray-50 text-sm"
className="flex h-8 select-none gap-2.5 rounded-lg border bg-gray-50 p-1 text-sm"
>
<div className="flex items-center gap-2 pl-2 pr-3">
<div className="flex items-center gap-2">
<VoteIcon type={voteType} />
<div>{t(voteType)}</div>
</div>
<div className="flex h-full items-center justify-center px-2 text-sm font-semibold text-gray-800">
{voteByType[voteType].length}
<div className="text-muted-foreground">{t(voteType)}</div>
</div>
<Badge>{voteByType[voteType].length}</Badge>
</div>
);
})}

View file

@ -1,63 +1,57 @@
import { cn } from "@rallly/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import clsx from "clsx";
import { ColoredAvatar } from "@/components/poll/participant-avatar";
interface ParticipantAvatarBarProps {
participants: { name: string }[];
max: number;
max?: number;
}
export const ParticipantAvatarBar = ({
participants,
max = Infinity,
}: ParticipantAvatarBarProps) => {
const hiddenCount = participants.length - max;
const visibleCount = participants.length > max ? max - 1 : max;
const hiddenCount = participants.length - visibleCount;
return (
<div className="flex items-center">
{participants
.slice(0, hiddenCount === 1 ? max + 1 : max)
.map((participant, index) => (
<Tooltip delayDuration={0} key={index}>
<ul className="flex items-center -space-x-1 rounded-full border p-0.5">
{participants.slice(0, visibleCount).map((participant, index) => (
<Tooltip key={index}>
<TooltipTrigger asChild>
<div
className={cn({
"-mr-1":
index !== max - 1 || index !== participants.length - 1,
})}
>
<ColoredAvatar
className="select-none ring-2 ring-white"
name={participant.name}
/>
</div>
<li className="inline-flex items-center justify-center rounded-full ring-2 ring-white">
<ColoredAvatar name={participant.name} />
</li>
</TooltipTrigger>
<TooltipContent>{participant.name}</TooltipContent>
</Tooltip>
))}
{hiddenCount > 1 ? (
<li className="inline-flex items-center justify-center rounded-full ring-2 ring-white">
<Tooltip>
<TooltipTrigger asChild>
<div
className={clsx(
"select-none ring-2 ring-white",
<span
className={cn(
"select-none",
"rounded-full bg-gray-200 px-1.5 text-xs font-semibold",
"inline-flex h-6 min-w-[24px] items-center justify-center",
"inline-flex h-5 items-center justify-center",
)}
>
<div>+{hiddenCount}</div>
</div>
+{hiddenCount}
</span>
</TooltipTrigger>
<TooltipContent>
<ul>
{participants.slice(max, 10).map((participant, index) => (
{participants
.slice(visibleCount, 10)
.map((participant, index) => (
<li key={index}>{participant.name}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</li>
) : null}
</div>
</ul>
);
};

View file

@ -91,12 +91,11 @@ export const ParticipantDropdown = ({
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
<DropdownMenuItem
className="text-rose-600"
className="text-destructive"
onClick={() => setIsDeleteParticipantModalVisible(true)}
>
<DropdownMenuItemIconLabel icon={TrashIcon}>
<TrashIcon className="size-4" />
<Trans i18nKey="delete" />
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -37,7 +37,11 @@ const Teaser = () => {
className="text-center"
aria-hidden="true"
>
<Badge className="translate-y-0 px-4 py-0.5 text-lg">
<Badge
size="lg"
variant="primary"
className="translate-y-0 px-4 py-0.5"
>
<Trans i18nKey="planPro" />
</Badge>
</m.div>

View file

@ -25,7 +25,12 @@ type PollContextValue = {
optionIds: string[];
// TODO (Luke Vella) [2022-05-18]: Move this stuff to participants provider
getParticipantsWhoVotedForOption: (optionId: string) => Participant[]; // maybe just attach votes to parsed options
getScore: (optionId: string) => { yes: number; ifNeedBe: number };
getScore: (optionId: string) => {
yes: number;
ifNeedBe: number;
no: number;
skip: number;
};
getParticipantById: (
participantId: string,
) => (Participant & { votes: Vote[] }) | undefined;
@ -60,17 +65,17 @@ export const PollContextProvider: React.FunctionComponent<{
}
if (vote.type === "yes") {
acc.yes += 1;
}
if (vote.type === "ifNeedBe") {
} else if (vote.type === "ifNeedBe") {
acc.ifNeedBe += 1;
}
if (vote.type === "no") {
} else if (vote.type === "no") {
acc.no += 1;
} else {
acc.skip += 1;
}
});
return acc;
},
{ yes: 0, ifNeedBe: 0, no: 0 },
{ yes: 0, ifNeedBe: 0, no: 0, skip: 0 },
);
},
[participants],

View file

@ -1,26 +1,9 @@
import { PollStatus } from "@rallly/database";
import { cn } from "@rallly/ui";
import { CheckCircleIcon, PauseCircleIcon, RadioIcon } from "lucide-react";
import { Badge } from "@rallly/ui/badge";
import { CalendarCheckIcon, PauseIcon, RadioIcon } from "lucide-react";
import { Trans } from "@/components/trans";
import { IconComponent } from "@/types";
const LabelWithIcon = ({
icon: Icon,
children,
className,
}: {
icon: IconComponent;
children: React.ReactNode;
className?: string;
}) => {
return (
<span className={cn("inline-flex items-center gap-1.5", className)}>
<Icon className="size-4 -ml-0.5" />
<span className="font-medium">{children}</span>
</span>
);
};
export const PollStatusLabel = ({
status,
@ -32,38 +15,56 @@ export const PollStatusLabel = ({
switch (status) {
case "live":
return (
<LabelWithIcon icon={RadioIcon} className={className}>
<span
className={cn(
"inline-flex items-center gap-x-1.5 text-sm font-medium text-gray-800",
className,
)}
>
<RadioIcon className="inline-block size-4 opacity-75" />
<Trans i18nKey="pollStatusOpen" defaults="Live" />
</LabelWithIcon>
</span>
);
case "paused":
return (
<LabelWithIcon icon={PauseCircleIcon} className={className}>
<span
className={cn(
"text-muted-foreground inline-flex items-center gap-x-1.5 text-sm font-medium",
className,
)}
>
<PauseIcon className="inline-block size-4 opacity-75" />
<Trans i18nKey="pollStatusPaused" defaults="Paused" />
</LabelWithIcon>
</span>
);
case "finalized":
return (
<LabelWithIcon icon={CheckCircleIcon} className={className}>
<Trans i18nKey="pollStatusClosed" defaults="Finalized" />
</LabelWithIcon>
<span
className={cn(
"text-primary-50 inline-flex items-center gap-x-1.5 text-sm font-medium",
className,
)}
>
<CalendarCheckIcon className="inline-block size-4 opacity-75" />
<Trans i18nKey="pollStatusFinalized" defaults="Finalized" />
</span>
);
}
};
export const PollStatusBadge = ({ status }: { status: PollStatus }) => {
return (
<PollStatusLabel
className={cn(
"whitespace-nowrap rounded-md border px-2 py-1 text-xs font-medium",
{
"border-pink-200 bg-pink-50 text-pink-600": status === "live",
"border-gray-200 bg-gray-100 text-gray-500": status === "paused",
"border-indigo-200 bg-indigo-50 text-indigo-600":
status === "finalized",
},
)}
status={status}
/>
<Badge
size="lg"
variant={
status === "finalized"
? "primary"
: status === "paused"
? "default"
: "outline"
}
>
<PollStatusLabel status={status} />
</Badge>
);
};

View file

@ -1,72 +0,0 @@
"use client";
import { cn } from "@rallly/ui";
import Link from "next/link";
import React from "react";
import { Trans } from "react-i18next";
import { Card } from "@/components/card";
import Discussion from "@/components/discussion";
import { EventCard } from "@/components/event-card";
import DesktopPoll from "@/components/poll/desktop-poll";
import MobilePoll from "@/components/poll/mobile-poll";
import { VotingForm } from "@/components/poll/voting-form";
import { usePoll } from "@/contexts/poll";
import { useTouchBeacon } from "./poll/use-touch-beacon";
const checkIfWideScreen = () => window.innerWidth > 640;
export const Poll = () => {
const poll = usePoll();
useTouchBeacon(poll.id);
React.useEffect(() => {
const listener = () => setIsWideScreen(checkIfWideScreen());
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("resize", listener);
};
}, []);
const [isWideScreen, setIsWideScreen] = React.useState(checkIfWideScreen);
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
return (
<div className={cn("space-y-3 sm:space-y-6")}>
<EventCard />
<Card fullWidthOnMobile={false}>
<VotingForm>
<PollComponent />
</VotingForm>
</Card>
{poll.disableComments ? null : (
<>
<hr className="my-4" />
<Card fullWidthOnMobile={false}>
<Discussion />
</Card>
</>
)}
<div className="mt-4 space-y-4 text-center text-gray-500">
<div className="py-8">
<Trans
defaults="Powered by <a>{name}</a>"
i18nKey="poweredByRallly"
values={{ name: "rallly.co" }}
components={{
a: (
<Link
className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold"
href="https://rallly.co"
/>
),
}}
/>
</div>
</div>
</div>
);
};

View file

@ -1,5 +1,8 @@
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@rallly/ui/card";
import { Icon } from "@rallly/ui/icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import {
ArrowLeftIcon,
@ -9,20 +12,26 @@ import {
ShrinkIcon,
Users2Icon,
} from "lucide-react";
import { Trans, useTranslation } from "next-i18next";
import { Trans } from "next-i18next";
import * as React from "react";
import { RemoveScroll } from "react-remove-scroll";
import { useMeasure, useScroll } from "react-use";
import { TimesShownIn } from "@/components/clock";
import { useVotingForm } from "@/components/poll/voting-form";
import { usePermissions } from "@/contexts/permissions";
import {
useParticipants,
useVisibleParticipants,
} from "../participants-provider";
import { usePoll } from "../poll-context";
EmptyState,
EmptyStateDescription,
EmptyStateIcon,
EmptyStateTitle,
} from "@/app/components/empty-state";
import { useTranslation } from "@/app/i18n/client";
import { TimesShownIn } from "@/components/clock";
import { ConnectedScoreSummary } from "@/components/poll/score-summary";
import { useVotingForm } from "@/components/poll/voting-form";
import { IfScoresVisible } from "@/components/visibility";
import { usePermissions } from "@/contexts/permissions";
import { usePoll } from "@/contexts/poll";
import { useVisibleParticipants } from "../participants-provider";
import ParticipantRow from "./desktop-poll/participant-row";
import ParticipantRowForm from "./desktop-poll/participant-row-form";
import PollHeader from "./desktop-poll/poll-header";
@ -60,26 +69,17 @@ const useIsOverflowing = <E extends Element | null>(
};
const DesktopPoll: React.FunctionComponent = () => {
const { t } = useTranslation();
const { poll } = usePoll();
const { participants } = useParticipants();
const votingForm = useVotingForm();
const mode = votingForm.watch("mode");
const poll = usePoll();
const [measureRef, { height }] = useMeasure<HTMLDivElement>();
const { canAddNewParticipant } = usePermissions();
const goToNextPage = () => {
if (scrollRef.current) {
scrollRef.current.scrollLeft += 240;
}
};
const { canAddNewParticipant } = usePermissions();
const [expanded, setExpanded] = React.useState(false);
const expand = () => {
@ -97,6 +97,9 @@ const DesktopPoll: React.FunctionComponent = () => {
scrollRef.current.scrollLeft -= 240;
}
};
const { t } = useTranslation();
const votingForm = useVotingForm();
const mode = votingForm.watch("mode");
const visibleParticipants = useVisibleParticipants();
@ -106,7 +109,106 @@ const DesktopPoll: React.FunctionComponent = () => {
const { x } = useScroll(scrollRef);
function TableControls() {
return (
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-sm">
<Trans
i18nKey="optionCount"
values={{ count: poll.options.length }}
/>
</div>
<div className="flex gap-x-1">
{isOverflowing ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={x === 0}
onClick={goToPreviousPage}
>
<Icon>
<ArrowLeftIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="scrollLeft" defaults="Scroll Left" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={Boolean(
scrollRef.current &&
x + scrollRef.current.offsetWidth >=
scrollRef.current.scrollWidth,
)}
onClick={() => {
goToNextPage();
}}
>
<Icon>
<ArrowRightIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="scrollRight" defaults="Scroll Right" />
</TooltipContent>
</Tooltip>
</>
) : null}
{expanded ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
collapse();
}}
>
<Icon>
<ShrinkIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="shrink" defaults="Shrink" />
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
expand();
}}
>
<Icon>
<ExpandIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="expand" defaults="Expand" />
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
}
return (
<Card>
<div ref={measureRef} style={{ height: expanded ? height : undefined }}>
<div
className={cn(
@ -117,130 +219,55 @@ const DesktopPoll: React.FunctionComponent = () => {
>
<div
className={cn(
"shadow-huge flex max-h-full flex-col overflow-hidden rounded-md bg-white",
"flex max-h-full max-w-7xl flex-col overflow-hidden rounded-md bg-white",
{
"shadow-huge": expanded,
},
)}
>
<div className="flex h-14 shrink-0 items-center justify-between rounded-t-md border-b bg-gradient-to-b from-gray-50 to-gray-100/50 px-4 py-3">
<div>
{mode !== "view" ? (
<p className="text-sm">
<Trans
t={t}
i18nKey="saveInstruction"
values={{
action: mode === "new" ? t("continue") : t("save"),
}}
components={{ b: <strong /> }}
/>
</p>
) : (
<div className="flex items-center gap-2">
<Users2Icon className="size-5 shrink-0" />
<div className="font-semibold">
{t("participants", { count: participants.length })} (
{participants.length})
</div>
{canAddNewParticipant ? (
<CardHeader className="flex items-center justify-between gap-4">
<div className="flex items-center gap-x-2.5">
<CardTitle>
<Trans i18nKey="participants" />
</CardTitle>
<Badge>{visibleParticipants.length}</Badge>
{canAddNewParticipant && mode !== "new" ? (
<Button
className="ml-2"
size="sm"
data-testid="add-participant-button"
icon={PlusIcon}
onClick={() => {
votingForm.newParticipant();
}}
/>
) : null}
</div>
)}
</div>
<div className="flex items-center gap-4">
<div className="text-sm font-medium">
{t("optionCount", { count: poll.options.length })}
</div>
{isOverflowing || expanded ? (
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button disabled={x === 0} onClick={goToPreviousPage}>
<ArrowLeftIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="scrollLeft" defaults="Scroll Left" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={Boolean(
scrollRef.current &&
x + scrollRef.current.offsetWidth >=
scrollRef.current.scrollWidth,
)}
onClick={() => {
goToNextPage();
}}
>
<ArrowRightIcon className="size-4" />
<Icon>
<PlusIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="scrollRight" defaults="Scroll Right" />
</TooltipContent>
</Tooltip>
{expanded ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
icon={ShrinkIcon}
onClick={() => {
collapse();
}}
/>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="shrink" defaults="Shrink" />
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
icon={ExpandIcon}
onClick={() => {
expand();
}}
/>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="expand" defaults="Expand" />
</TooltipContent>
</Tooltip>
)}
</div>
) : null}
</div>
</div>
<TableControls />
</CardHeader>
{poll.options[0]?.duration !== 0 && poll.timeZone ? (
<div className="border-b bg-gray-50 p-3">
<CardHeader>
<TimesShownIn />
</div>
</CardHeader>
) : null}
{visibleParticipants.length > 0 || mode !== "view" ? (
<div className="relative flex min-h-0 flex-col">
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute bottom-2 left-[240px] top-0 z-30 w-4 border-l bg-gradient-to-r from-gray-800/5 via-transparent to-transparent transition-opacity",
"pointer-events-none absolute bottom-0 left-[240px] top-0 z-30 w-4 border-l bg-gradient-to-r from-gray-800/5 via-transparent to-transparent transition-opacity",
x > 0 ? "opacity-100" : "opacity-0",
)}
/>
<RemoveScroll
enabled={expanded}
ref={scrollRef}
className={cn(
"scrollbar-thin hover:scrollbar-thumb-gray-400 scrollbar-thumb-gray-300 scrollbar-track-gray-100 relative z-10 flex-grow overflow-auto scroll-smooth pb-3 pr-3",
expanded ? "" : "max-h-[calc(75vh)]",
"scrollbar-thin hover:scrollbar-thumb-gray-400 scrollbar-thumb-gray-300 scrollbar-track-gray-100 relative z-10 flex-grow overflow-auto scroll-smooth",
)}
>
<table className="w-full table-auto border-separate border-spacing-0 ">
@ -249,15 +276,7 @@ const DesktopPoll: React.FunctionComponent = () => {
</thead>
<tbody>
{mode === "new" ? (
<>
<ParticipantRowForm />
<tr aria-hidden="true">
<td
colSpan={poll.options.length + 1}
className="py-2"
/>
</tr>
</>
<ParticipantRowForm isNew={true} />
) : null}
{visibleParticipants.length > 0
? visibleParticipants.map((participant, i) => {
@ -270,6 +289,11 @@ const DesktopPoll: React.FunctionComponent = () => {
votingForm.watch("participantId") ===
participant.id
}
className={
i === visibleParticipants.length - 1
? "last-row"
: ""
}
onChangeEditMode={(isEditing) => {
if (isEditing) {
votingForm.setEditingParticipantId(
@ -282,44 +306,73 @@ const DesktopPoll: React.FunctionComponent = () => {
})
: null}
</tbody>
<IfScoresVisible>
<tfoot>
<tr>
<th className="sticky bottom-0 left-0 z-20 h-12 bg-white"></th>
{poll.options.map((option) => {
return (
<th
className="sticky bottom-0 border-l border-t bg-gray-50 p-2"
key={option.id}
>
<div className="flex justify-center">
<ConnectedScoreSummary optionId={option.id} />
</div>
</th>
);
})}
<th className="bg-diagonal-lines -ml-4 w-full min-w-4 border-l"></th>
</tr>
</tfoot>
</IfScoresVisible>
</table>
</RemoveScroll>
</div>
{mode !== "view" ? (
<div className="flex shrink-0 items-center border-t bg-gray-50">
<div className="flex w-full items-center justify-between gap-3 p-3">
) : (
<EmptyState className="p-16">
<EmptyStateIcon>
<Users2Icon />
</EmptyStateIcon>
<EmptyStateTitle>
<Trans i18nKey="noParticipants" defaults="No participants" />
</EmptyStateTitle>
<EmptyStateDescription>
<Trans
i18nKey="noParticipantsDescription"
components={{ b: <strong className="font-semibold" /> }}
defaults="Click <b>Share</b> to invite participants"
/>
</EmptyStateDescription>
</EmptyState>
)}
{mode === "new" ? (
<CardFooter className="flex items-center justify-between">
<Button
onClick={() => {
votingForm.cancel();
}}
>
{t("cancel")}
<Trans i18nKey="cancel" />
</Button>
{mode === "new" ? (
<Button
form="voting-form"
type="submit"
variant="primary"
loading={votingForm.formState.isSubmitting}
>
{t("continue")}
<p className="text-sm">
<Trans
i18nKey="saveInstruction"
values={{
action: mode === "new" ? t("continue") : t("save"),
}}
components={{ b: <strong className="font-semibold" /> }}
/>
</p>
<Button type="submit" variant="primary" form="voting-form">
<Trans i18nKey="continue" />
</Button>
) : (
<Button
form="voting-form"
type="submit"
variant="primary"
loading={votingForm.formState.isSubmitting}
>
{t("save")}
</Button>
)}
</div>
</div>
</CardFooter>
) : null}
</div>
</div>
</div>
</Card>
);
};

View file

@ -1,26 +1,37 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@rallly/ui/tooltip";
import { CheckIcon, UndoIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller } from "react-hook-form";
import { useVotingForm } from "@/components/poll/voting-form";
import { Trans } from "@/components/trans";
import { usePoll } from "../../poll-context";
import UserAvatar, { YouAvatar } from "../user-avatar";
import { VoteSelector } from "../vote-selector";
import { toggleVote, VoteSelector } from "../vote-selector";
export interface ParticipantRowFormProps {
name?: string;
className?: string;
isYou?: boolean;
isNew?: boolean;
onCancel?: () => void;
}
const ParticipantRowForm = ({
name,
isNew,
isYou,
className,
onCancel,
}: ParticipantRowFormProps) => {
const { t } = useTranslation();
@ -28,43 +39,106 @@ const ParticipantRowForm = ({
const form = useVotingForm();
React.useEffect(() => {
window.addEventListener("keydown", (e) => {
function cancel(e: KeyboardEvent) {
if (e.key === "Escape") {
onCancel?.();
form.cancel();
}
});
}, [onCancel]);
}
window.addEventListener("keydown", cancel);
return () => {
window.removeEventListener("keydown", cancel);
};
}, [form]);
return (
<tr className={cn(className)}>
<td className="sticky left-0 z-10 bg-white pl-4 pr-4">
<div className="flex items-center">
<tr className={cn("group", className)}>
<td
style={{ minWidth: 240, maxWidth: 240 }}
className="sticky left-0 z-10 bg-white px-4"
>
<div className="flex items-center justify-between gap-x-2.5">
{name ? (
<UserAvatar name={name ?? t("you")} isYou={isYou} showName={true} />
) : (
<YouAvatar />
)}
{!isNew ? (
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => {
form.cancel();
}}
size="sm"
>
<Icon>
<UndoIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
<Trans i18nKey="cancel" />
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
loading={form.formState.isSubmitting}
size="sm"
form="voting-form"
type="submit"
>
<Icon>
<CheckIcon />
</Icon>
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
<Trans i18nKey="save" />
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
) : null}
</div>
</td>
{optionIds.map((optionId, i) => {
return (
<td key={optionId} className="h-12 bg-white p-1">
<td
key={optionId}
className="relative h-12 border-l border-t bg-gray-50"
>
<Controller
control={form.control}
name={`votes.${i}`}
render={({ field }) => (
<div
onClick={() => {
field.onChange({
optionId,
type: toggleVote(field.value.type),
});
}}
className="absolute inset-0 flex cursor-pointer items-center justify-center hover:bg-gray-100 active:bg-gray-200/50 active:ring-1 active:ring-inset active:ring-gray-200"
>
<VoteSelector
className="h-full w-full"
value={field.value.type}
onChange={(vote) => {
field.onChange({ optionId, type: vote });
}}
/>
</div>
)}
/>
</td>
);
})}
<td className="border-l bg-gray-100" />
</tr>
);
};

View file

@ -1,6 +1,7 @@
import { Participant, VoteType } from "@rallly/database";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import clsx from "clsx";
import { Icon } from "@rallly/ui/icon";
import { MoreHorizontalIcon } from "lucide-react";
import * as React from "react";
@ -33,35 +34,48 @@ export const ParticipantRowView: React.FunctionComponent<{
<tr
data-testid="participant-row"
data-participantid={participantId}
className={clsx(className)}
className={cn("group", className)}
>
<td
style={{ minWidth: 240, maxWidth: 240 }}
className="sticky left-0 z-10 bg-white px-4"
>
<div className="flex max-w-full items-center justify-between gap-x-4 ">
<div className="flex max-w-full items-center justify-between gap-x-4">
<UserAvatar name={name} showName={true} isYou={isYou} />
{action}
</div>
</td>
{votes.map((vote, i) => {
return (
<td key={i} className={clsx("h-12 p-1")}>
<div
className={clsx(
"flex h-full items-center justify-center rounded-md border",
<td
key={i}
className={cn(
"h-12 border-l border-t",
!vote || vote === "no" ? "bg-gray-100" : "bg-white",
{
"border-green-200 bg-green-50": vote === "yes",
"border-amber-200 bg-amber-50": vote === "ifNeedBe",
"bg-gray-50": vote === "no" || !vote,
"bg-gray-100": vote === "no",
// "bg-waves": vote === "ifNeedBe",
},
)}
>
<div className={cn("flex items-center justify-center")}>
<div
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full",
{
"bg-green-50": vote === "yes",
"bg-amber-50": vote === "ifNeedBe",
"bg-gray-200": vote === "no",
},
)}
>
<VoteIcon type={vote} />
</div>
</div>
</td>
);
})}
<td className="bg-diagonal-lines border-l"></td>
</tr>
);
};
@ -105,7 +119,11 @@ const ParticipantRow: React.FunctionComponent<ParticipantRowProps> = ({
align="start"
onEdit={() => onChangeEditMode?.(true)}
>
<Button size="sm" icon={MoreHorizontalIcon} />
<Button size="sm" variant="ghost">
<Icon>
<MoreHorizontalIcon />
</Icon>
</Button>
</ParticipantDropdown>
) : null
}

View file

@ -1,26 +1,41 @@
import { cn } from "@rallly/ui";
import clsx from "clsx";
import { Icon } from "@rallly/ui/icon";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@rallly/ui/tooltip";
import { ClockIcon } from "lucide-react";
import * as React from "react";
import { DateIconInner } from "@/components/date-icon";
import { useOptions } from "@/components/poll-context";
import { ConnectedScoreSummary } from "../score-summary";
import { Trans } from "@/components/trans";
const TimeRange: React.FunctionComponent<{
start: string;
end: string;
duration: string;
className?: string;
}> = ({ start, end, className }) => {
}> = ({ start, end, duration, className }) => {
return (
<div
className={clsx(
"relative -mr-2 inline-block whitespace-nowrap pr-2 text-right text-xs font-normal after:absolute after:right-0 after:top-2 after:h-4 after:w-1 after:border-b after:border-r after:border-t after:border-gray-300 after:content-['']",
className,
)}
>
<div className="font-medium tabular-nums">{start}</div>
<div className="text-muted-foreground tabular-nums">{end}</div>
<div className={cn("text-muted-foreground text-xs font-normal", className)}>
<Tooltip delayDuration={0}>
<TooltipTrigger>{start}</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="flex gap-x-2.5 text-xs">
<span>
{start} - {end}
</span>
<span className="flex items-center gap-x-1 text-gray-500">
<Icon>
<ClockIcon />
</Icon>
{duration}
</span>
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
);
};
@ -36,33 +51,16 @@ const TimelineRow = ({
className="sticky left-0 z-30 bg-white pl-4 pr-4"
></th>
{children}
<th className="w-full" />
<th className="bg-diagonal-lines -ml-4 w-full min-w-4 border-l" />
</tr>
);
};
const monthRowHeight = 48;
const dayRowHeight = 64;
const dayRowHeight = 60;
const scoreRowTop = monthRowHeight + dayRowHeight;
const Trail = ({ end }: { end?: boolean }) => {
return end ? (
<div aria-hidden="true" className="absolute left-0 top-6 z-10 h-full w-1/2">
<div className="h-px bg-gray-200" />
<div className="absolute right-0 top-0 h-5 w-px bg-gray-200" />
</div>
) : (
<div
aria-hidden="true"
className={cn("absolute left-0 top-6 z-10 h-full w-full")}
>
<div className="h-px bg-gray-200" />
<div className={cn("absolute right-1/2 top-0 h-2 w-px bg-gray-200")} />
</div>
);
};
const PollHeader: React.FunctionComponent = () => {
const { options } = useOptions();
return (
@ -71,26 +69,25 @@ const PollHeader: React.FunctionComponent = () => {
{options.map((option, i) => {
const firstOfMonth =
i === 0 || options[i - 1]?.month !== option.month;
const lastOfMonth = options[i + 1]?.month !== option.month;
return (
<th
key={option.optionId}
style={{ height: monthRowHeight }}
className={cn(
"sticky top-0 space-y-3 bg-white",
firstOfMonth ? "left-[240px] z-20" : "z-10",
"sticky top-0 space-y-3 bg-gray-50",
firstOfMonth ? "left-[240px] z-20 border-l" : "z-10",
)}
>
<div className="flex items-center justify-center">
{firstOfMonth ? null : <Trail end={lastOfMonth} />}
<div className="flex">
<div
className={cn(
"h-5 px-2 py-0.5 text-sm font-semibold",
"inline-flex h-5 gap-1 px-2 py-0.5 text-xs font-medium uppercase",
firstOfMonth ? "opacity-100" : "opacity-0",
)}
>
{option.month}
<span>{option.month}</span>
<span className="text-muted-foreground">{option.year}</span>
</div>
</div>
</th>
@ -103,31 +100,35 @@ const PollHeader: React.FunctionComponent = () => {
i === 0 ||
options[i - 1]?.day !== option.day ||
options[i - 1]?.month !== option.month;
const lastOfDay =
options[i + 1]?.day !== option.day ||
options[i + 1]?.month !== option.month;
i === options.length - 1 || options[i + 1]?.day !== option.day;
return (
<th
key={option.optionId}
style={{
minWidth: 80,
width: 80,
maxWidth: 90,
height: dayRowHeight,
left: firstOfDay && !lastOfDay ? 240 : 0,
top: monthRowHeight,
}}
className={cn(
"sticky space-y-2 bg-white align-top",
"sticky space-y-2 border-t bg-gray-50",
firstOfDay ? "z-20" : "z-10",
{
"border-l": firstOfDay,
},
)}
>
{firstOfDay ? null : <Trail end={lastOfDay} />}
<DateIconInner
className={firstOfDay ? "opacity-100" : "opacity-0"}
day={option.day}
dow={option.dow}
/>
{firstOfDay ? (
<div className="mt-1 flex flex-col gap-1">
<div className="text-muted-foreground text-xs font-normal">
{option.dow}
</div>
<div className="text-sm font-medium">{option.day}</div>
</div>
) : null}
</th>
);
})}
@ -138,13 +139,20 @@ const PollHeader: React.FunctionComponent = () => {
<th
key={option.optionId}
style={{ minWidth: 80, maxWidth: 90, top: scoreRowTop }}
className="sticky z-20 space-y-2 bg-white pb-3 pt-2"
className="sticky z-20 border-l bg-gray-50 pb-2.5 align-top"
>
<div className="flex flex-col items-center gap-2.5">
{option.type === "timeSlot" ? (
<TimeRange start={option.startTime} end={option.endTime} />
) : null}
<div>
<ConnectedScoreSummary optionId={option.optionId} />
<TimeRange
start={option.startTime}
end={option.endTime}
duration={option.duration}
/>
) : (
<p className="text-muted-foreground text-xs font-normal">
<Trans i18nKey="allDay" defaults="All-Day" />
</p>
)}
</div>
</th>
);

View file

@ -1,4 +1,5 @@
import languages from "@rallly/languages";
import { Button } from "@rallly/ui/button";
import {
Select,
SelectContent,
@ -14,8 +15,10 @@ export const LanguageSelect: React.FunctionComponent<{
}> = ({ className, value, onChange }) => {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className={className}>
<SelectTrigger asChild className={className}>
<Button>
<SelectValue />
</Button>
</SelectTrigger>
<SelectContent>
{Object.entries(languages).map(([code, name]) => (

View file

@ -7,12 +7,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import {
CalendarCheck2Icon,
ChevronDownIcon,
CopyIcon,
DownloadIcon,
PauseIcon,
PencilIcon,
PlayIcon,
RotateCcwIcon,
Settings2Icon,
SettingsIcon,
TableIcon,
@ -21,9 +25,10 @@ import {
import Link from "next/link";
import * as React from "react";
import { ProBadge } from "@/components/pro-badge";
import { ProFeatureBadge } from "@/components/pro-feature-badge";
import { Trans } from "@/components/trans";
import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
import { DeletePollDialog } from "./manage-poll/delete-poll-dialog";
import { useCsvExporter } from "./manage-poll/use-csv-exporter";
@ -32,6 +37,56 @@ const ManagePoll: React.FunctionComponent<{
disabled?: boolean;
}> = ({ disabled }) => {
const poll = usePoll();
const queryClient = trpc.useUtils();
const reopen = trpc.polls.reopen.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
event: null,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
const pause = trpc.polls.pause.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
closed: true,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
const resume = trpc.polls.resume.useMutation({
onMutate: () => {
queryClient.polls.get.setData({ urlId: poll.id }, (oldPoll) => {
if (!oldPoll) {
return;
}
return {
...oldPoll,
closed: false,
};
});
},
onSuccess: () => {
queryClient.polls.invalidate();
},
});
const [showDeletePollDialog, setShowDeletePollDialog] = React.useState(false);
@ -41,12 +96,64 @@ const ManagePoll: React.FunctionComponent<{
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild={true}>
<Button icon={SettingsIcon} disabled={disabled}>
<Button disabled={disabled}>
<Icon>
<SettingsIcon />
</Icon>
<Trans i18nKey="manage" />
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<>
{poll.status === "finalized" ? (
<DropdownMenuItem
onSelect={() => {
reopen.mutate({ pollId: poll.id });
}}
>
<Icon>
<RotateCcwIcon />
</Icon>
<Trans i18nKey="reopenPoll" defaults="Reopen" />
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem asChild disabled={!!poll.event}>
<Link href={`/poll/${poll.id}/finalize`}>
<DropdownMenuItemIconLabel icon={CalendarCheck2Icon}>
<Trans i18nKey="finishPoll" defaults="Finalize" />
<ProFeatureBadge />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
{poll.status === "live" ? (
<DropdownMenuItem
onSelect={() => {
pause.mutate({ pollId: poll.id });
}}
>
<Icon>
<PauseIcon />
</Icon>
<Trans i18nKey="pausePoll" defaults="Pause" />
</DropdownMenuItem>
) : (
<DropdownMenuItem
onSelect={() => {
resume.mutate({ pollId: poll.id });
}}
>
<Icon>
<PlayIcon />
</Icon>
<Trans i18nKey="resumePoll" defaults="Resume" />
</DropdownMenuItem>
)}
</>
)}
</>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/poll/${poll.id}/edit-details`}>
<DropdownMenuItemIconLabel icon={PencilIcon}>
@ -78,15 +185,7 @@ const ManagePoll: React.FunctionComponent<{
<Link href={`/poll/${poll.id}/duplicate`}>
<DropdownMenuItemIconLabel icon={CopyIcon}>
<Trans i18nKey="duplicate" defaults="Duplicate" />
<ProBadge />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild disabled={!!poll.event}>
<Link href={`/poll/${poll.id}/finalize`}>
<DropdownMenuItemIconLabel icon={CalendarCheck2Icon}>
<Trans i18nKey="finishPoll" defaults="Finalize" />
<ProBadge />
<ProFeatureBadge />
</DropdownMenuItemIconLabel>
</Link>
</DropdownMenuItem>
@ -97,9 +196,8 @@ const ManagePoll: React.FunctionComponent<{
setShowDeletePollDialog(true);
}}
>
<DropdownMenuItemIconLabel icon={TrashIcon}>
<TrashIcon className="size-4" />
<Trans i18nKey="deletePoll" />
</DropdownMenuItemIconLabel>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -2,7 +2,6 @@ import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -38,10 +37,10 @@ export const DeletePollDialog: React.FunctionComponent<{
<DialogTitle>
<Trans i18nKey="deletePoll" />
</DialogTitle>
<DialogDescription>
<Trans i18nKey="deletePollDescription" />
</DialogDescription>
</DialogHeader>
<p className="text-sm">
<Trans i18nKey="deletePollDescription" />
</p>
<DialogFooter>
<Button
onClick={() => {

View file

@ -1,7 +1,16 @@
import { Listbox } from "@headlessui/react";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@rallly/ui/card";
import { Icon } from "@rallly/ui/icon";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@rallly/ui/select";
import { AnimatePresence, m } from "framer-motion";
import { ChevronDownIcon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import { MoreHorizontalIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import * as React from "react";
import smoothscroll from "smoothscroll-polyfill";
@ -10,13 +19,10 @@ import { TimesShownIn } from "@/components/clock";
import { ParticipantDropdown } from "@/components/participant-dropdown";
import { useVotingForm } from "@/components/poll/voting-form";
import { useOptions, usePoll } from "@/components/poll-context";
import { Trans } from "@/components/trans";
import { usePermissions } from "@/contexts/permissions";
import { styleMenuItem } from "../menu-styles";
import {
useParticipants,
useVisibleParticipants,
} from "../participants-provider";
import { useVisibleParticipants } from "../participants-provider";
import { useUser } from "../user-provider";
import GroupedOptions from "./mobile-poll/grouped-options";
import UserAvatar, { YouAvatar } from "./user-avatar";
@ -31,14 +37,13 @@ const MobilePoll: React.FunctionComponent = () => {
const { poll, getParticipantById } = pollContext;
const { options } = useOptions();
const { participants } = useParticipants();
const session = useUser();
const votingForm = useVotingForm();
const { formState } = votingForm;
const selectedParticipantId = votingForm.watch("participantId");
const selectedParticipantId = votingForm.watch("participantId") ?? "";
const visibleParticipants = useVisibleParticipants();
const selectedParticipant = selectedParticipantId
@ -52,68 +57,59 @@ const MobilePoll: React.FunctionComponent = () => {
const isEditing = votingForm.watch("mode") !== "view";
return (
<>
<div className="flex flex-col space-y-2 border-b bg-gray-50 p-2">
<div className="flex space-x-2">
<Card>
<CardHeader>
<div className="flex items-center gap-x-2.5">
<CardTitle>
<Trans i18nKey="participants" />
</CardTitle>
<Badge>{visibleParticipants.length}</Badge>
</div>
</CardHeader>
<div className="sticky top-0 z-20 flex flex-col space-y-2 border-b bg-gray-50 p-2">
<div className="flex gap-x-2.5">
{selectedParticipantId || !isEditing ? (
<Listbox
<Select
value={selectedParticipantId}
onChange={(participantId) => {
onValueChange={(participantId) => {
votingForm.setValue("participantId", participantId);
}}
disabled={isEditing}
>
<div className="menu min-w-0 grow">
<Listbox.Button
as={Button}
className="w-full shadow-none"
data-testid="participant-selector"
>
<div className="min-w-0 grow text-left">
{selectedParticipant ? (
<div className="flex items-center space-x-2">
<UserAvatar
name={selectedParticipant.name}
showName={true}
isYou={session.ownsObject(selectedParticipant)}
/>
<SelectTrigger asChild className="w-full">
<Button>
<SelectValue />
</Button>
</SelectTrigger>
<SelectContent>
<SelectItem value="">
<div className="flex items-center gap-x-2.5">
<div className="flex w-5 justify-center">
<Icon>
<UsersIcon />
</Icon>
</div>
) : (
t("participantCount", { count: participants.length })
)}
<span className="font-medium">
{t("allParticipants", {
defaultValue: "All Participants",
})}
</span>
</div>
<ChevronDownIcon className="h-5 shrink-0" />
</Listbox.Button>
<Listbox.Options
as={m.div}
transition={{
duration: 0.1,
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="menu-items max-h-72 w-full overflow-auto"
>
<Listbox.Option value={undefined} className={styleMenuItem}>
{t("participantCount", { count: participants.length })}
</Listbox.Option>
</SelectItem>
{visibleParticipants.map((participant) => (
<Listbox.Option
key={participant.id}
value={participant.id}
className={styleMenuItem}
>
<div className="flex items-center space-x-2">
<SelectItem key={participant.id} value={participant.id}>
<div className="flex items-center gap-x-2.5">
<UserAvatar
name={participant.name}
showName={true}
isYou={session.ownsObject(participant)}
/>
</div>
</Listbox.Option>
</SelectItem>
))}
</Listbox.Options>
</div>
</Listbox>
</SelectContent>
</Select>
) : (
<div className="flex grow items-center px-1">
<YouAvatar />
@ -140,22 +136,29 @@ const MobilePoll: React.FunctionComponent = () => {
votingForm.setEditingParticipantId(selectedParticipant.id);
}}
>
<Button icon={MoreHorizontalIcon} />
<Button>
<Icon>
<MoreHorizontalIcon />
</Icon>
</Button>
</ParticipantDropdown>
) : canAddNewParticipant ? (
<Button
icon={PlusIcon}
onClick={() => {
votingForm.newParticipant();
}}
/>
>
<Icon>
<PlusIcon />
</Icon>
</Button>
) : null}
</div>
</div>
{poll.options[0].duration !== 0 && poll.timeZone ? (
<div className="flex border-b bg-gray-50 p-3">
{poll.options[0]?.duration !== 0 && poll.timeZone ? (
<CardHeader>
<TimesShownIn />
</div>
</CardHeader>
) : null}
<GroupedOptions
selectedParticipantId={selectedParticipantId}
@ -184,7 +187,7 @@ const MobilePoll: React.FunctionComponent = () => {
transition: { duration: 0.2 },
}}
>
<div className="space-y-3 border-t bg-gray-50 p-3">
<CardFooter>
<Button
form="voting-form"
className="w-full"
@ -194,11 +197,11 @@ const MobilePoll: React.FunctionComponent = () => {
>
{selectedParticipantId ? t("save") : t("continue")}
</Button>
</div>
</CardFooter>
</m.div>
) : null}
</AnimatePresence>
</>
</Card>
);
};

View file

@ -1,7 +1,5 @@
import * as React from "react";
import { DateIconInner } from "@/components/date-icon";
import PollOption, { PollOptionProps } from "./poll-option";
export interface DateOptionProps extends PollOptionProps {
@ -13,7 +11,6 @@ export interface DateOptionProps extends PollOptionProps {
const DateOption: React.FunctionComponent<DateOptionProps> = ({
dow,
day,
month,
...rest
}) => {
return (
@ -22,7 +19,11 @@ const DateOption: React.FunctionComponent<DateOptionProps> = ({
* Intentionally using the month prop for the day of week here as a temporary measure
* until we update this component.
*/}
<DateIconInner day={day} dow={dow} month={month} />
<div className="text-sm">
<span className="font-semibold">
{day} {dow}
</span>
</div>
</PollOption>
);
};

View file

@ -29,7 +29,7 @@ const GroupedOptions: React.FunctionComponent<GroupedOptionsProps> = ({
<div key={day}>
<div
className={clsx(
"flex border-b bg-gray-50 px-4 py-2 text-sm font-semibold",
"flex border-b bg-gray-50 px-4 py-2 text-xs font-medium uppercase",
groupClassName,
)}
>

View file

@ -1,15 +1,18 @@
"use client";
import { Participant, VoteType } from "@rallly/database";
import clsx from "clsx";
import { AnimatePresence, m } from "framer-motion";
import { ChevronDownIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { useToggle } from "react-use";
import { IfParticipantsVisible } from "@/components/visibility";
import { useTranslation } from "@/app/i18n/client";
import { useParticipants } from "@/components/participants-provider";
import { UserAvatar } from "@/components/user";
import { usePoll } from "@/contexts/poll";
import { useParticipants } from "../../participants-provider";
import { ConnectedScoreSummary } from "../score-summary";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon";
import { VoteSelector } from "../vote-selector";
@ -25,53 +28,6 @@ export interface PollOptionProps {
optionId: string;
}
const CollapsibleContainer: React.FunctionComponent<{
expanded?: boolean;
children?: React.ReactNode;
className?: string;
}> = ({ className, children, expanded }) => {
return (
<AnimatePresence initial={false}>
{expanded ? (
<m.div
variants={{
collapsed: {
width: 0,
opacity: 0,
},
expanded: {
opacity: 1,
width: "auto",
},
}}
initial="collapsed"
animate="expanded"
exit="collapsed"
className={className}
>
{children}
</m.div>
) : null}
</AnimatePresence>
);
};
const PopInOut: React.FunctionComponent<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return (
<m.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
className={clsx(className)}
>
{children}
</m.div>
);
};
const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
optionId,
}) => {
@ -83,94 +39,59 @@ const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
const noVotes =
participantsWhoVotedYes.length + participantsWhoVotedIfNeedBe.length === 0;
return (
<m.div
transition={{
duration: 0.1,
}}
initial={{ height: 0, opacity: 0, y: -10 }}
animate={{ height: "auto", opacity: 1, y: 0 }}
exit={{ height: 0, opacity: 0, y: -10 }}
className="text-sm"
>
<div>
{noVotes ? (
<div className="rounded-lg bg-gray-50 p-2 text-center text-gray-500">
{t("noVotes")}
</div>
) : (
<div className="grid grid-cols-2 gap-x-4">
<div className="col-span-1 space-y-2">
<div className="grid grid-cols-1 gap-2">
<div className="col-span-1 space-y-2.5">
{participantsWhoVotedYes.map(({ name }, i) => (
<div key={i} className="flex">
<div className="relative mr-2 flex items-center justify-center">
<UserAvatar name={name} />
<div className="relative mr-2.5 flex size-5 items-center justify-center">
<UserAvatar size="xs" name={name} />
<VoteIcon
type="yes"
size="sm"
className="absolute -right-1 -top-1 rounded-full bg-white"
className="absolute bottom-full left-full -translate-x-1/2 translate-y-1/2 rounded-full bg-white"
/>
</div>
<div className="text-gray-500">{name}</div>
<div className="truncate text-sm">{name}</div>
</div>
))}
</div>
<div className="col-span-1 space-y-2">
<div className="col-span-1 space-y-2.5">
{participantsWhoVotedIfNeedBe.map(({ name }, i) => (
<div key={i} className="flex">
<div className="relative mr-2 flex items-center justify-center">
<UserAvatar name={name} />
<div className="relative mr-2.5 flex size-5 items-center justify-center">
<UserAvatar size="xs" name={name} />
<VoteIcon
type="ifNeedBe"
size="sm"
className="absolute -right-1 -top-1 rounded-full bg-white"
className="absolute bottom-full left-full -translate-x-1/2 translate-y-1/2 rounded-full bg-white"
/>
</div>
<div className="text-gray-500"> {name}</div>
<div className="truncate text-sm"> {name}</div>
</div>
))}
{participantsWhoVotedNo.map(({ name }, i) => (
<div key={i} className="flex">
<div className="relative mr-2 flex items-center justify-center">
<UserAvatar name={name} />
<div className="relative mr-2.5 flex size-5 items-center justify-center">
<UserAvatar size="xs" name={name} />
<VoteIcon
type="no"
size="sm"
className="absolute -right-1 -top-1 rounded-full bg-white"
className="absolute bottom-full left-full -translate-x-1/2 translate-y-1/2 rounded-full bg-white"
/>
</div>
<div className="text-gray-500"> {name}</div>
<div className="truncate text-sm">{name}</div>
</div>
))}
</div>
</div>
)}
</div>
</m.div>
);
};
const SummarizedParticipantList: React.FunctionComponent<{
participants: Participant[];
}> = ({ participants }) => {
return (
<div className="flex -space-x-1">
{participants
.slice(0, participants.length <= 6 ? 6 : 5)
.map((participant, i) => {
return (
<UserAvatar
key={i}
className="ring-1 ring-white"
name={participant.name}
/>
);
})}
{participants.length > 6 ? (
<span className="inline-flex h-5 items-center justify-center rounded-full bg-gray-100 px-1 text-xs font-medium ring-1 ring-white">
+{participants.length - 5}
</span>
) : null}
</div>
);
};
@ -179,90 +100,69 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
selectedParticipantId,
vote,
onChange,
participants,
editable = false,
optionId,
}) => {
const poll = usePoll();
const showVotes = !!(selectedParticipantId || editable);
const [expanded, setExpanded] = React.useState(false);
const selectorRef = React.useRef<HTMLButtonElement>(null);
const [active, setActive] = React.useState(false);
const [isExpanded, toggle] = useToggle(false);
return (
<div
className={clsx("space-y-4 overflow-hidden px-4 py-3", {
className={cn("space-y-4 bg-white p-4", {
"bg-gray-500/5": editable && active,
})}
onTouchStart={() => setActive(editable)}
onTouchEnd={() => setActive(false)}
onPointerDown={() => setActive(editable)}
onPointerUp={() => setActive(false)}
onPointerOut={() => setActive(false)}
data-testid="poll-option"
onClick={() => {
selectorRef.current?.click();
}}
>
<div className="flex select-none items-center transition duration-75">
<div className="mr-3 shrink-0 grow">{children}</div>
<AnimatePresence initial={false}>
{editable ? null : (
<m.button
exit={{ opacity: 0, x: -10 }}
type="button"
onTouchStart={(e) => e.stopPropagation()}
className="flex min-w-0 justify-end gap-1 overflow-hidden p-1 active:bg-gray-500/10"
<div className="flex h-7 items-center justify-between gap-x-4">
<div className="shrink-0">{children}</div>
<div className="flex items-center gap-x-2.5">
{poll.hideParticipants ? (
<ConnectedScoreSummary optionId={optionId} />
) : (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setExpanded((value) => !value);
toggle();
}}
>
<IfParticipantsVisible>
{participants.length > 0 ? (
<SummarizedParticipantList participants={participants} />
) : null}
<ChevronDownIcon
className={clsx(
"h-5 shrink-0 text-gray-500 transition-transform",
{
"-rotate-180": expanded,
},
)}
/>
</IfParticipantsVisible>
</m.button>
)}
</AnimatePresence>
<div className="mx-3">
<ConnectedScoreSummary optionId={optionId} />
</div>
<CollapsibleContainer
expanded={showVotes}
className="relative flex justify-center"
>
<Icon>
{isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
</Icon>
</Button>
)}
{showVotes ? (
<div className="relative flex size-7 items-center justify-center">
{editable ? (
<div className="flex h-full items-center justify-center">
<VoteSelector
className="w-9"
ref={selectorRef}
value={vote}
onChange={onChange}
/>
</div>
) : (
<AnimatePresence initial={false} mode="wait">
<PopInOut
<div
key={vote}
className="flex h-full w-9 items-center justify-center"
className="flex h-full items-center justify-center"
>
<VoteIcon type={vote} />
</PopInOut>
</AnimatePresence>
)}
</CollapsibleContainer>
</div>
<AnimatePresence initial={false}>
{expanded && !editable ? (
<PollOptionVoteSummary optionId={optionId} />
)}
</div>
) : null}
</AnimatePresence>
</div>
</div>
{isExpanded ? <PollOptionVoteSummary optionId={optionId} /> : null}
</div>
);
};

View file

@ -1,4 +1,3 @@
import { ClockIcon } from "lucide-react";
import * as React from "react";
import PollOption, { PollOptionProps } from "./poll-option";
@ -11,17 +10,13 @@ export interface TimeSlotOptionProps extends PollOptionProps {
const TimeSlotOption: React.FunctionComponent<TimeSlotOptionProps> = ({
startTime,
duration,
endTime,
...rest
}) => {
return (
<PollOption {...rest}>
<div className="grow">
<div className="h-7">{`${startTime}`}</div>
<div className="flex grow items-center text-sm text-gray-500">
<ClockIcon className="leading- mr-1 inline w-4" />
{duration}
</div>
<div className="flex items-center gap-x-4 text-sm">
<div>{`${startTime} - ${endTime}`}</div>
</div>
</PollOption>
);

View file

@ -1,4 +1,5 @@
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { BellOffIcon, BellRingIcon } from "lucide-react";
import { signIn } from "next-auth/react";
@ -16,7 +17,7 @@ import { usePoll } from "../poll-context";
const NotificationsToggle: React.FunctionComponent = () => {
const { poll } = usePoll();
const { data: watchers, refetch } = trpc.polls.getWatchers.useQuery(
const { data: watchers } = trpc.polls.getWatchers.useQuery(
{
pollId: poll.id,
},
@ -31,6 +32,8 @@ const NotificationsToggle: React.FunctionComponent = () => {
const posthog = usePostHog();
const queryClient = trpc.useUtils();
const watch = trpc.polls.watch.useMutation({
onSuccess: () => {
// TODO (Luke Vella) [2023-04-08]: We should have a separate query for getting watchers
@ -38,7 +41,16 @@ const NotificationsToggle: React.FunctionComponent = () => {
pollId: poll.id,
source: "notifications-toggle",
});
refetch();
queryClient.polls.getWatchers.setData(
{ pollId: poll.id },
(oldWatchers) => {
if (!oldWatchers) {
return;
}
return [...oldWatchers, { userId: user.id }];
},
);
queryClient.polls.invalidate();
},
});
@ -48,7 +60,16 @@ const NotificationsToggle: React.FunctionComponent = () => {
pollId: poll.id,
source: "notifications-toggle",
});
refetch();
queryClient.polls.getWatchers.setData(
{ pollId: poll.id },
(oldWatchers) => {
if (!oldWatchers) {
return;
}
return oldWatchers.filter(({ userId }) => userId !== user.id);
},
);
queryClient.polls.invalidate();
},
});
@ -62,8 +83,6 @@ const NotificationsToggle: React.FunctionComponent = () => {
<Tooltip>
<TooltipTrigger asChild>
<Button
loading={watch.isLoading || unwatch.isLoading}
icon={isWatching ? BellRingIcon : BellOffIcon}
data-testid="notifications-toggle"
disabled={user.isGuest}
className="flex items-center gap-2 px-2.5"
@ -79,7 +98,17 @@ const NotificationsToggle: React.FunctionComponent = () => {
await watch.mutateAsync({ pollId: poll.id });
}
}}
/>
>
{isWatching ? (
<Icon>
<BellRingIcon />
</Icon>
) : (
<Icon>
<BellOffIcon />
</Icon>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{user.isGuest ? (

View file

@ -1,11 +1,10 @@
import { Badge } from "@rallly/ui/badge";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { getRandomAvatarColor } from "@/utils/color-hash";
import Badge from "../badge";
export interface UserAvaterProps {
name: string;
seed?: string;
@ -64,7 +63,7 @@ export const ColoredAvatar = (props: {
return (
<div
className={clsx(
"inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold uppercase",
"inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold uppercase",
requiresDarkText ? "text-gray-800" : "text-white",
props.className,
)}

View file

@ -0,0 +1,23 @@
import Link from "next/link";
import { Trans } from "@/components/trans";
export function PollFooter() {
return (
<div className="py-4 text-center text-sm text-gray-500">
<Trans
defaults="Powered by <a>{name}</a>"
i18nKey="poweredByRallly"
values={{ name: "rallly.co" }}
components={{
a: (
<Link
className="hover:text-primary-600 rounded-none border-b border-b-gray-500 font-semibold"
href="https://rallly.co"
/>
),
}}
/>
</div>
);
}

View file

@ -0,0 +1,15 @@
import { LogoLink } from "@/app/components/logo-link";
import { UserDropdown } from "@/components/user-dropdown";
export function PollHeader() {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-2.5">
<LogoLink />
</div>
<div className="flex items-center gap-x-2.5">
<UserDropdown />
</div>
</div>
);
}

View file

@ -1,37 +0,0 @@
import dayjs from "dayjs";
import { Trans, useTranslation } from "next-i18next";
import * as React from "react";
import { usePoll } from "../poll-context";
const PollSubheader: React.FunctionComponent = () => {
const { poll } = usePoll();
const { t } = useTranslation();
return (
<div className="text-sm text-gray-500">
<div className="flex gap-1.5">
<div>
<Trans
i18nKey="createdBy"
t={t}
values={{
name: poll.user?.name ?? t("guest"),
}}
components={{
b: <span />,
}}
/>
</div>
<span>&bull;</span>
<span className="whitespace-nowrap">
<Trans
i18nKey="createdTime"
values={{ relativeTime: dayjs(poll.createdAt).fromNow() }}
/>
</span>
</div>
</div>
);
};
export default PollSubheader;

View file

@ -0,0 +1,25 @@
"use client";
import React from "react";
import DesktopPoll from "@/components/poll/desktop-poll";
import MobilePoll from "@/components/poll/mobile-poll";
const checkIfWideScreen = () => window.innerWidth > 640;
export function PollViz() {
React.useEffect(() => {
const listener = () => setIsWideScreen(checkIfWideScreen());
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("resize", listener);
};
}, []);
const [isWideScreen, setIsWideScreen] = React.useState(checkIfWideScreen);
const PollComponent = isWideScreen ? DesktopPoll : MobilePoll;
return <PollComponent />;
}

View file

@ -0,0 +1,13 @@
import { createBreakpoint } from "react-use";
import DesktopPoll from "@/components/poll/desktop-poll";
import MobilePoll from "@/components/poll/mobile-poll";
const useBreakpoint = createBreakpoint({ list: 320, table: 640 });
export function ResponsiveResults() {
const breakpoint = useBreakpoint();
const PollComponent = breakpoint === "table" ? DesktopPoll : MobilePoll;
return <PollComponent />;
}

View file

@ -20,52 +20,32 @@ export const ConnectedScoreSummary: React.FunctionComponent<{
const { getScore, highScore } = usePoll();
const { yes, ifNeedBe } = getScore(optionId);
const score = yes + ifNeedBe;
const highlight = score === highScore && score > 1;
return (
<IfScoresVisible>
<ScoreSummary
yesScore={yes}
ifNeedBeScore={ifNeedBe}
highScore={highScore}
highlight={score === highScore && score > 1}
highlight={highlight}
/>
</IfScoresVisible>
);
};
export const ScoreSummary: React.FunctionComponent<PopularityScoreProps> =
React.memo(function PopularityScore({
yesScore,
ifNeedBeScore = 0,
highScore,
highlight,
}) {
const score = yesScore + ifNeedBeScore;
function AnimatedNumber({ score }: { score: number }) {
const prevScore = usePrevious(score);
const direction = prevScore !== undefined ? score - prevScore : 0;
return (
<div
data-testid="popularity-score"
className={cn(
"relative inline-flex select-none items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-normal tabular-nums",
highlight
? "border-green-500 text-green-500"
: "border-transparent text-gray-600",
)}
style={{
opacity: Math.max(score / highScore, 0.2),
}}
>
<User2Icon className="size-3" />
<AnimatePresence initial={false} mode="wait">
<m.span
transition={{
duration: 0.1,
}}
initial={{
y: 10 * direction,
}}
transition={{
duration: 0.1,
}}
animate={{ opacity: 1, y: 0 }}
exit={{
y: 10 * direction,
@ -76,9 +56,36 @@ export const ScoreSummary: React.FunctionComponent<PopularityScoreProps> =
{score}
</m.span>
</AnimatePresence>
{highlight && ifNeedBeScore > 0 ? (
<span className="absolute -right-1 -top-0.5 size-2 rounded-full bg-amber-400 ring-2 ring-white" />
) : null}
</div>
);
});
}
const ScoreSummary: React.FunctionComponent<PopularityScoreProps> = React.memo(
function PopularityScore({
yesScore = 0,
ifNeedBeScore = 0,
highlight,
highScore,
}) {
const score = yesScore + ifNeedBeScore;
return (
<span
className={cn(
"relative inline-flex items-center gap-x-1 text-xs",
highlight ? "font-medium text-gray-800" : "font-normal text-gray-500",
)}
style={{
opacity: Math.max(score / highScore, 0.2),
}}
>
<User2Icon className="size-4 opacity-75" />
<AnimatedNumber score={score} />
{highlight ? (
ifNeedBeScore > 0 ? (
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
) : null
) : null}
</span>
);
},
);

View file

@ -37,7 +37,7 @@ export const truncateLink = (href: string, text: string, key: number) => {
{finalText}
</Link>
</TooltipTrigger>
<TooltipContent className="max-w-md break-all font-mono text-xs">
<TooltipContent className="max-w-md break-all text-xs">
{href}
</TooltipContent>
</Tooltip>

View file

@ -1,17 +1,19 @@
import { useMount } from "react-use";
import { usePoll } from "@/contexts/poll";
import { trpc } from "@/utils/trpc/client";
/**
* Touching a poll updates a column with the current date. This information is used to
* find polls that haven't been accessed for some time so that they can be deleted by house keeping.
*/
export const useTouchBeacon = (pollId: string) => {
export const useTouchBeacon = () => {
const poll = usePoll();
const touchMutation = trpc.polls.touch.useMutation({
meta: { doNotInvalidate: true },
});
useMount(() => {
touchMutation.mutate({ pollId });
touchMutation.mutate({ pollId: poll.id });
});
};

View file

@ -1,3 +1,4 @@
import { Badge } from "@rallly/ui/badge";
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
@ -5,8 +6,6 @@ import * as React from "react";
import { ColoredAvatar } from "@/components/poll/participant-avatar";
import { stringToValue } from "@/utils/string-to-value";
import Badge from "../badge";
export interface UserAvaterProps {
name: string;
className?: string;
@ -110,8 +109,8 @@ export const YouAvatar = () => {
const { t } = useTranslation();
const you = t("you");
return (
<span className="inline-flex items-center gap-x-2.5">
<span className="inline-flex size-6 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold uppercase">
<span className="inline-flex items-center gap-x-2.5 text-sm">
<span className="inline-flex size-5 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold uppercase">
{you[0]}
</span>
{t("you")}

View file

@ -1,5 +1,5 @@
import { VoteType } from "@rallly/database";
import clsx from "clsx";
import { cn } from "@rallly/ui";
import * as React from "react";
import VoteIcon from "./vote-icon";
@ -15,7 +15,8 @@ export interface VoteSelectorProps {
const orderedVoteTypes: VoteType[] = ["yes", "ifNeedBe", "no"];
const getNext = (value: VoteType) => {
export const toggleVote = (value?: VoteType) => {
if (!value) return orderedVoteTypes[0];
return orderedVoteTypes[
(orderedVoteTypes.indexOf(value) + 1) % orderedVoteTypes.length
];
@ -35,12 +36,12 @@ export const VoteSelector = React.forwardRef<
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
className={clsx(
"relative flex h-9 items-center justify-center overflow-hidden rounded-md border hover:bg-gray-50 focus:ring-1 focus:ring-gray-200 active:bg-gray-100",
className={cn(
"flex size-7 items-center justify-center rounded-md border bg-white ring-gray-200 focus:ring-2",
className,
)}
onClick={() => {
onChange?.(value ? getNext(value) : orderedVoteTypes[0]);
onChange?.(value ? toggleVote(value) : orderedVoteTypes[0]);
}}
ref={ref}
>

View file

@ -1,15 +1,10 @@
"use client";
import { Badge } from "@rallly/ui/badge";
import { Trans } from "next-i18next";
import { IfFreeUser } from "@/contexts/plan";
export const ProBadge = ({ className }: { className?: string }) => {
return (
<IfFreeUser>
<Badge className={className}>
<Badge variant="primary" className={className}>
<Trans i18nKey="planPro" />
</Badge>
</IfFreeUser>
);
};

View file

@ -0,0 +1,12 @@
"use client";
import { usePlan } from "@/contexts/plan";
import { ProBadge } from "./pro-badge";
export const ProFeatureBadge = ({ className }: { className?: string }) => {
const plan = usePlan();
if (plan === "free") {
return <ProBadge className={className} />;
}
return null;
};

View file

@ -0,0 +1,10 @@
import { generateGradient } from "@/utils/color-hash";
export function RandomGradientBar({ seed }: { seed?: string }) {
return (
<div
className="-mx-px -mt-px h-2 rounded-t-md"
style={{ background: generateGradient(seed ?? "") }}
/>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { Card, CardContent } from "@rallly/ui/card";
import { DateIconInner } from "@/components/date-icon";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { useParticipants } from "@/components/participants-provider";
import { usePoll } from "@/contexts/poll";
import { useDayjs } from "@/utils/dayjs";
function FinalDate({ start }: { start: Date }) {
const poll = usePoll();
const { adjustTimeZone } = useDayjs();
return <span>{adjustTimeZone(start, !poll.timeZone).format("LL")}</span>;
}
function DateIcon({ start }: { start: Date }) {
const poll = usePoll();
const { adjustTimeZone } = useDayjs();
const d = adjustTimeZone(start, !poll.timeZone);
return <DateIconInner dow={d.format("ddd")} day={d.format("D")} />;
}
function FinalTime({ start, duration }: { start: Date; duration: number }) {
const poll = usePoll();
const { adjustTimeZone, dayjs } = useDayjs();
return (
<span>{`${adjustTimeZone(start, !poll.timeZone).format("LT")} - ${adjustTimeZone(dayjs(start).add(duration, "minutes"), !poll.timeZone).format("LT")}`}</span>
);
}
function Attendees() {
const { participants } = useParticipants();
const poll = usePoll();
const attendees = participants.filter((participant) =>
participant.votes.some(
(vote) =>
vote.optionId === poll?.event?.optionId &&
(vote.type === "yes" || vote.type === "ifNeedBe"),
),
);
return <ParticipantAvatarBar participants={attendees} max={5} />;
}
export function ScheduledEvent() {
const poll = usePoll();
if (!poll.event) {
return null;
}
return (
<Card className="bg-gray-50">
<CardContent>
<div className="flex justify-between gap-4">
<div className="flex items-center gap-x-4">
<DateIcon start={poll.event.start} />
<div>
<div className="text-sm font-medium">
<FinalDate start={poll.event.start} />
</div>
<div className="text-muted-foreground text-sm">
<FinalTime
start={poll.event.start}
duration={poll.event.duration}
/>
</div>
</div>
</div>
<div>
<Attendees />
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -99,8 +99,10 @@ const DateTimePreferencesForm = () => {
field.onChange(parseInt(value));
}}
>
<SelectTrigger>
<SelectTrigger asChild>
<Button>
<SelectValue />
</Button>
</SelectTrigger>
<SelectContent>
{dayjs.weekdays().map((day, index) => (

View file

@ -1,4 +1,11 @@
import { cn } from "@rallly/ui";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@rallly/ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { InfoIcon } from "lucide-react";
@ -24,13 +31,13 @@ export const SettingsSection = (props: {
children: React.ReactNode;
}) => {
return (
<div className="grid grid-cols-1 gap-3 md:gap-8 lg:grid-cols-10">
<div className="col-span-3">
<h2 className="mb-1 text-base font-semibold">{props.title}</h2>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
<div className="col-span-7">{props.children}</div>
</div>
<Card>
<CardHeader>
<CardTitle>{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
</CardHeader>
<CardContent>{props.children}</CardContent>
</Card>
);
};

View file

@ -1,43 +1,47 @@
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Flex } from "@rallly/ui/flex";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
OnChangeFn,
PaginationState,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import clsx from "clsx";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import React from "react";
import { Trans } from "@/components/trans";
export const Table = <
T extends Record<string, unknown>,
export const Table = <TData extends Record<string, unknown>>(props: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
C extends ColumnDef<T, any>,
>(props: {
columns: C[];
data: T[];
columns: ColumnDef<TData, any>[];
data: TData[];
footer?: React.ReactNode;
pageCount?: number;
enableTableFooter?: boolean;
enableTableHeader?: boolean;
layout?: "fixed" | "auto";
onPaginationChange?: OnChangeFn<PaginationState>;
sortingState?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
paginationState: PaginationState | undefined;
className?: string;
}) => {
const table = useReactTable<T>({
const table = useReactTable<TData>({
data: props.data,
columns: props.columns,
pageCount: props.pageCount,
state: {
pagination: props.paginationState,
sorting: props.sortingState,
},
onSortingChange: props.onSortingChange,
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
onPaginationChange: props.onPaginationChange,
getCoreRowModel: getCoreRowModel(),
@ -47,13 +51,13 @@ export const Table = <
return (
<div>
<div
className={clsx(
className={cn(
props.className,
"scrollbar-thin max-w-full overflow-x-auto",
)}
>
<table
className={clsx(
className={cn(
"border-collapse",
props.layout === "auto" ? "w-full table-auto" : "table-fixed",
)}
@ -72,7 +76,7 @@ export const Table = <
? header.getSize()
: undefined,
}}
className="whitespace-nowrap border-b border-gray-100 px-3 py-2.5 text-left align-bottom text-sm font-semibold"
className="text-muted-foreground h-9 border-b px-2.5 text-left text-xs font-normal"
>
{header.isPlaceholder
? null
@ -99,11 +103,10 @@ export const Table = <
: undefined,
}}
key={cell.id}
className={clsx(
"overflow-hidden border-gray-100 py-4 pr-8 align-middle",
className={cn(
"relative h-14 overflow-hidden border-gray-100 px-2.5 align-middle",
{
"border-b": table.getRowModel().rows.length !== i + 1,
"pt-0": !props.enableTableHeader && i === 0,
},
)}
>
@ -118,7 +121,7 @@ export const Table = <
{table.getFooterGroups().map((footerGroup) => (
<tr key={footerGroup.id} className="relative">
{footerGroup.headers.map((header) => (
<th className="border-t bg-gray-50" key={header.id}>
<th className="border-t" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
@ -133,19 +136,9 @@ export const Table = <
) : null}
</table>
</div>
<hr className="my-2" />
<div className="flex items-center justify-between space-x-2 py-4">
<Button
variant="ghost"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeftIcon
className={cn("size-4", {
"text-gray-400": !table.getCanPreviousPage(),
})}
/>
</Button>
{table.getPageCount() > 1 ? (
<div className="flex items-center justify-between space-x-2 border-t px-4 py-3 lg:px-5">
<div>
<span className="text-muted-foreground text-sm">
<Trans
i18nKey="pageXOfY"
@ -156,18 +149,30 @@ export const Table = <
}}
/>
</span>
</div>
<Flex>
<Button
variant="ghost"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronRightIcon
<ArrowLeftIcon
className={cn("size-4", {
"text-gray-400": !table.getCanNextPage(),
"text-gray-400": !table.getCanPreviousPage(),
})}
/>
</Button>
<Button
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ArrowRightIcon className="size-4 text-gray-500" />
</Button>
</Flex>
</div>
) : null}
</div>
);
};

View file

@ -0,0 +1,44 @@
import { cn } from "@rallly/ui";
import { Icon } from "@rallly/ui/icon";
import { Column } from "@tanstack/react-table";
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from "lucide-react";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<button
className="flex w-full items-center gap-x-2.5"
onClick={() => {
column.toggleSorting();
}}
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
<Icon>
<ArrowDownIcon />
</Icon>
) : column.getIsSorted() === "asc" ? (
<Icon>
<ArrowUpIcon />
</Icon>
) : (
<Icon>
<ChevronsUpDownIcon />
</Icon>
)}
</button>
);
}

View file

@ -1,18 +1,21 @@
"use client";
import { SelectProps } from "@radix-ui/react-select";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@rallly/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@rallly/ui/popover";
import { useDialog } from "@rallly/ui/dialog";
import { Icon } from "@rallly/ui/icon";
import dayjs from "dayjs";
import { CheckIcon, ChevronDownIcon, GlobeIcon } from "lucide-react";
import { CheckIcon, GlobeIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import React from "react";
@ -49,16 +52,15 @@ export const TimeZoneCommand = ({ onSelect, value }: TimeZoneCommandProps) => {
onSelect={() => onSelect?.(timezone)}
className="flex min-w-0 gap-x-2.5"
>
<CheckIcon
className={cn(
"size-4 shrink-0",
value === timezone ? "opacity-100" : "opacity-0",
)}
/>
<div className="w-6 shrink-0">
{value === timezone ? (
<Icon>
<CheckIcon />
</Icon>
) : null}
</div>
<span className="min-w-0 grow truncate">{city}</span>
<span className="whitespace-nowrap rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{dayjs().tz(timezone).format("LT")}
</span>
<Badge>{dayjs().tz(timezone).format("z")}</Badge>
</CommandItem>
);
})}
@ -71,49 +73,31 @@ export const TimeZoneCommand = ({ onSelect, value }: TimeZoneCommandProps) => {
export const TimeZoneSelect = React.forwardRef<HTMLButtonElement, SelectProps>(
({ value, onValueChange, disabled }, ref) => {
const [open, setOpen] = React.useState(false);
const popoverContentId = "timeZoneSelect__popoverContent";
const dialog = useDialog();
return (
<Popover modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild={true}>
<button
ref={ref}
disabled={disabled}
type="button"
role="combobox"
aria-expanded={open}
aria-controls={popoverContentId}
className="bg-input-background flex h-9 w-full min-w-0 items-center gap-x-1.5 rounded-md border px-2 py-2 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<GlobeIcon className="size-4" />
<span className="grow truncate text-left">
{value ? (
value.replaceAll("_", " ")
) : (
<Trans
i18nKey="timeZoneSelect__defaultValue"
defaults="Select time zone…"
/>
)}
</span>
<ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
id={popoverContentId}
align="start"
className="z-[1000] max-w-[var(--radix-popover-trigger-width)] bg-white p-0"
>
<>
<CommandDialog {...dialog.dialogProps}>
<TimeZoneCommand
value={value}
onSelect={(newValue) => {
onValueChange?.(newValue);
setOpen(false);
dialog.dismiss();
}}
/>
</PopoverContent>
</Popover>
</CommandDialog>
<Button
ref={ref}
disabled={disabled}
onClick={() => {
dialog.trigger();
}}
>
<Icon>
<GlobeIcon />
</Icon>
{value}
</Button>
</>
);
},
);

View file

@ -9,8 +9,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import {
ChevronDown,
ArrowUpRight,
ChevronDownIcon,
CreditCardIcon,
GemIcon,
LifeBuoyIcon,
@ -29,7 +31,7 @@ import { RegisterLink } from "@/components/register-link";
import { Trans } from "@/components/trans";
import { CurrentUserAvatar } from "@/components/user";
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
import { Plan } from "@/contexts/plan";
import { Plan, usePlan } from "@/contexts/plan";
import { isFeedbackEnabled } from "@/utils/constants";
import { IfAuthenticated, IfGuest, useUser } from "./user-provider";
@ -46,7 +48,7 @@ function logout() {
export const UserDropdown = ({ className }: { className?: string }) => {
const { user } = useUser();
usePlan(); // prefetch plan data
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
@ -54,12 +56,12 @@ export const UserDropdown = ({ className }: { className?: string }) => {
asChild
className={cn("group min-w-0", className)}
>
<Button variant="ghost" className="flex justify-between">
<span className="flex items-center gap-x-2.5">
<CurrentUserAvatar size="sm" className="-ml-1 shrink-0 " />
<Button variant="ghost">
<CurrentUserAvatar size="xs" className="shrink-0 " />
<span className="truncate">{user.name}</span>
</span>
<ChevronDown className="text-muted-foreground size-4" />
<Icon>
<ChevronDownIcon />
</Icon>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -112,7 +114,6 @@ export const UserDropdown = ({ className }: { className?: string }) => {
</Link>
</DropdownMenuItem>
</IfCloudHosted>
<DropdownMenuSeparator />
<DropdownMenuItem asChild={true}>
<Link
target="_blank"
@ -121,6 +122,9 @@ export const UserDropdown = ({ className }: { className?: string }) => {
>
<LifeBuoyIcon className="text-muted-foreground size-4" />
<Trans i18nKey="support" defaults="Support" />
<Icon>
<ArrowUpRight />
</Icon>
</Link>
</DropdownMenuItem>
<IfSelfHosted>

View file

@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import { UserIcon } from "lucide-react";
import { cn } from "@rallly/ui";
import { Icon } from "@rallly/ui/icon";
import { User2Icon } from "lucide-react";
import { useUser } from "@/components/user-provider";
import { getRandomAvatarColor } from "@/utils/color-hash";
@ -22,7 +23,7 @@ export const CurrentUserAvatar = ({
interface UserAvatarProps {
name?: string;
size?: "sm" | "md" | "lg";
size?: "xs" | "sm" | "md" | "lg";
className?: string;
}
@ -34,9 +35,10 @@ export const UserAvatar = ({
const colors = name ? getRandomAvatarColor(name) : null;
return (
<span
className={clsx(
className={cn(
"inline-flex items-center justify-center overflow-hidden rounded-full font-semibold",
{
"size-5 text-[10px]": size === "xs",
"size-6 text-sm": size === "sm",
"size-8 text-base": size === "md",
"size-14 text-2xl": size === "lg",
@ -55,13 +57,9 @@ export const UserAvatar = ({
{name ? (
name[0].toUpperCase()
) : (
<UserIcon
className={clsx({
"size-4": size === "sm",
"size-6": size === "md",
"size-8": size === "lg",
})}
/>
<Icon>
<User2Icon />
</Icon>
)}
</span>
);

Some files were not shown because too many files have changed in this diff Show more