mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-21 12:56:21 +02:00
✨ Show participated polls on polls page + UI refresh (#1089)
This commit is contained in:
parent
bd9e9fe95b
commit
f8a217ae75
125 changed files with 3007 additions and 2363 deletions
|
@ -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">
|
||||
|
|
|
@ -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]) => (
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
37
apps/web/src/app/[locale]/(admin)/menu/menu-button.tsx
Normal file
37
apps/web/src/app/[locale]/(admin)/menu/menu-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
apps/web/src/app/[locale]/(admin)/menu/page.tsx
Normal file
9
apps/web/src/app/[locale]/(admin)/menu/page.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Sidebar } from "@/app/[locale]/(admin)/sidebar";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="p-3">
|
||||
<Sidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
27
apps/web/src/app/[locale]/(admin)/mobile-navigation.tsx
Normal file
27
apps/web/src/app/[locale]/(admin)/mobile-navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 />
|
||||
|
|
139
apps/web/src/app/[locale]/(admin)/polls/[[...list]]/columns.tsx
Normal file
139
apps/web/src/app/[locale]/(admin)/polls/[[...list]]/columns.tsx
Normal 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],
|
||||
);
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export default function Loader() {
|
||||
return null;
|
||||
}
|
23
apps/web/src/app/[locale]/(admin)/polls/[[...list]]/page.tsx
Normal file
23
apps/web/src/app/[locale]/(admin)/polls/[[...list]]/page.tsx
Normal 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"),
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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"),
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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" />}
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
38
apps/web/src/app/[locale]/(admin)/settings/settings-menu.tsx
Normal file
38
apps/web/src/app/[locale]/(admin)/settings/settings-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
57
apps/web/src/app/[locale]/invite/[urlId]/layout.tsx
Normal file
57
apps/web/src/app/[locale]/invite/[urlId]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
28
apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx
Normal file
28
apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -46,7 +46,6 @@ const Page: NextPageWithLayout = () => {
|
|||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mx-auto max-w-3xl"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
//submit
|
||||
updatePollMutation(
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -38,7 +38,7 @@ const FinalizationForm = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<Card className="mx-auto max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="finalize" />
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
45
apps/web/src/app/components/empty-state.tsx
Normal file
45
apps/web/src/app/components/empty-state.tsx
Normal 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>;
|
||||
}
|
|
@ -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>;
|
||||
}
|
||||
|
|
118
apps/web/src/app/components/responsive-menu.tsx
Normal file
118
apps/web/src/app/components/responsive-menu.tsx
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
36
apps/web/src/components/layouts/timeformat.tsx
Normal file
36
apps/web/src/components/layouts/timeformat.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
apps/web/src/components/layouts/timezone-control.tsx
Normal file
40
apps/web/src/components/layouts/timezone-control.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
0
apps/web/src/components/poll/guest-alert.tsx
Normal file
0
apps/web/src/components/poll/guest-alert.tsx
Normal 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]) => (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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,
|
||||
)}
|
||||
|
|
23
apps/web/src/components/poll/poll-footer.tsx
Normal file
23
apps/web/src/components/poll/poll-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
15
apps/web/src/components/poll/poll-header.tsx
Normal file
15
apps/web/src/components/poll/poll-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>•</span>
|
||||
<span className="whitespace-nowrap">
|
||||
<Trans
|
||||
i18nKey="createdTime"
|
||||
values={{ relativeTime: dayjs(poll.createdAt).fromNow() }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollSubheader;
|
25
apps/web/src/components/poll/poll-viz.tsx
Normal file
25
apps/web/src/components/poll/poll-viz.tsx
Normal 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 />;
|
||||
}
|
13
apps/web/src/components/poll/responsive-results.tsx
Normal file
13
apps/web/src/components/poll/responsive-results.tsx
Normal 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 />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
12
apps/web/src/components/pro-feature-badge.tsx
Normal file
12
apps/web/src/components/pro-feature-badge.tsx
Normal 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;
|
||||
};
|
10
apps/web/src/components/random-gradient-bar.tsx
Normal file
10
apps/web/src/components/random-gradient-bar.tsx
Normal 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 ?? "") }}
|
||||
/>
|
||||
);
|
||||
}
|
76
apps/web/src/components/scheduled-event.tsx
Normal file
76
apps/web/src/components/scheduled-event.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -99,8 +99,10 @@ const DateTimePreferencesForm = () => {
|
|||
field.onChange(parseInt(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger asChild>
|
||||
<Button>
|
||||
<SelectValue />
|
||||
</Button>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dayjs.weekdays().map((day, index) => (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
44
apps/web/src/components/table/table-column-header.tsx
Normal file
44
apps/web/src/components/table/table-column-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue