mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-01 23:48:53 +02:00
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import { cn } from "@rallly/ui";
|
|
import { Badge } from "@rallly/ui/badge";
|
|
import { Button } from "@rallly/ui/button";
|
|
import { Card, CardHeader, CardTitle } from "@rallly/ui/card";
|
|
import { Icon } from "@rallly/ui/icon";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
|
|
import {
|
|
ArrowLeftIcon,
|
|
ArrowRightIcon,
|
|
ExpandIcon,
|
|
PlusIcon,
|
|
ShrinkIcon,
|
|
Users2Icon,
|
|
} from "lucide-react";
|
|
import { Trans } from "next-i18next";
|
|
import * as React from "react";
|
|
import { RemoveScroll } from "react-remove-scroll";
|
|
import { useMeasure, useScroll } from "react-use";
|
|
|
|
import {
|
|
EmptyState,
|
|
EmptyStateDescription,
|
|
EmptyStateIcon,
|
|
EmptyStateTitle,
|
|
} from "@/app/components/empty-state";
|
|
import { TimesShownIn } from "@/components/clock";
|
|
import { useVotingForm } from "@/components/poll/voting-form";
|
|
import { usePermissions } from "@/contexts/permissions";
|
|
import { usePoll } from "@/contexts/poll";
|
|
import { useTranslation } from "@/i18n/client";
|
|
|
|
import {
|
|
useParticipants,
|
|
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";
|
|
|
|
function EscapeListener({ onEscape }: { onEscape: () => void }) {
|
|
React.useEffect(() => {
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
onEscape();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleEscape);
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", handleEscape);
|
|
};
|
|
}, [onEscape]);
|
|
|
|
return null;
|
|
}
|
|
|
|
const useIsOverflowing = <E extends Element | null>(
|
|
ref: React.RefObject<E>,
|
|
) => {
|
|
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
const checkOverflow = () => {
|
|
if (ref.current) {
|
|
const element = ref.current;
|
|
const overflowX = element.scrollWidth > element.clientWidth;
|
|
const overflowY = element.scrollHeight > element.clientHeight;
|
|
|
|
setIsOverflowing(overflowX || overflowY);
|
|
}
|
|
};
|
|
|
|
if (ref.current) {
|
|
const resizeObserver = new ResizeObserver(checkOverflow);
|
|
resizeObserver.observe(ref.current);
|
|
|
|
// Initial check
|
|
checkOverflow();
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}
|
|
}, [ref]);
|
|
|
|
return isOverflowing;
|
|
};
|
|
|
|
const DesktopPoll: React.FunctionComponent = () => {
|
|
const poll = usePoll();
|
|
|
|
const [measureRef, { height }] = useMeasure<HTMLDivElement>();
|
|
|
|
const goToNextPage = () => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollLeft += 240;
|
|
}
|
|
};
|
|
|
|
const { canAddNewParticipant } = usePermissions();
|
|
const [expanded, setExpanded] = React.useState(false);
|
|
|
|
const expand = () => {
|
|
setExpanded(true);
|
|
};
|
|
|
|
const collapse = () => {
|
|
// enable scrolling on body
|
|
document.body.style.overflow = "";
|
|
setExpanded(false);
|
|
};
|
|
|
|
const goToPreviousPage = () => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollLeft -= 240;
|
|
}
|
|
};
|
|
const { t } = useTranslation();
|
|
const votingForm = useVotingForm();
|
|
const mode = votingForm.watch("mode");
|
|
|
|
const { participants } = useParticipants();
|
|
const visibleParticipants = useVisibleParticipants();
|
|
|
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
const isOverflowing = useIsOverflowing(scrollRef);
|
|
|
|
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>
|
|
)}
|
|
{expanded ? <EscapeListener onEscape={collapse} /> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<div ref={measureRef} style={{ height: expanded ? height : undefined }}>
|
|
<div
|
|
className={cn(
|
|
expanded
|
|
? "fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-gray-900/25 p-8"
|
|
: "",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex max-h-full max-w-7xl flex-col overflow-hidden rounded-md bg-white",
|
|
{
|
|
"shadow-huge": expanded,
|
|
},
|
|
)}
|
|
>
|
|
<CardHeader className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-x-2.5">
|
|
<CardTitle>
|
|
<Trans i18nKey="participants" />
|
|
</CardTitle>
|
|
<Badge>{participants.length}</Badge>
|
|
{canAddNewParticipant && mode !== "new" ? (
|
|
<Button
|
|
className="ml-2"
|
|
size="sm"
|
|
data-testid="add-participant-button"
|
|
onClick={() => {
|
|
votingForm.newParticipant();
|
|
}}
|
|
>
|
|
<Icon>
|
|
<PlusIcon />
|
|
</Icon>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<TableControls />
|
|
</CardHeader>
|
|
{poll.options[0]?.duration !== 0 && poll.timeZone ? (
|
|
<div className="border-b bg-gray-50 px-4 py-3">
|
|
<TimesShownIn />
|
|
</div>
|
|
) : null}
|
|
{participants.length > 0 || mode !== "view" ? (
|
|
<div className="relative flex min-h-0 flex-col">
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"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",
|
|
)}
|
|
>
|
|
<table className="w-full table-auto border-separate border-spacing-0 bg-gray-50 ">
|
|
<thead>
|
|
<PollHeader />
|
|
</thead>
|
|
<tbody>
|
|
{mode === "new" ? (
|
|
<ParticipantRowForm isNew={true} />
|
|
) : null}
|
|
{visibleParticipants.length > 0
|
|
? visibleParticipants.map((participant, i) => {
|
|
return (
|
|
<ParticipantRow
|
|
key={i}
|
|
participant={{
|
|
id: participant.id,
|
|
name: participant.name,
|
|
userId: participant.userId ?? undefined,
|
|
email: participant.email ?? undefined,
|
|
votes: participant.votes,
|
|
}}
|
|
editMode={
|
|
votingForm.watch("mode") === "edit" &&
|
|
votingForm.watch("participantId") ===
|
|
participant.id
|
|
}
|
|
className={
|
|
i === visibleParticipants.length - 1
|
|
? "last-row"
|
|
: ""
|
|
}
|
|
onChangeEditMode={(isEditing) => {
|
|
if (isEditing) {
|
|
votingForm.setEditingParticipantId(
|
|
participant.id,
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
})
|
|
: null}
|
|
</tbody>
|
|
</table>
|
|
{mode === "new" ? (
|
|
<div className="sticky left-[240px] flex w-[calc(100%-240px)] items-center justify-between gap-4 border-l border-t bg-gray-50 p-3">
|
|
<Button
|
|
onClick={() => {
|
|
votingForm.cancel();
|
|
}}
|
|
>
|
|
<Trans i18nKey="cancel" />
|
|
</Button>
|
|
<p className="hidden min-w-0 truncate text-sm md:block">
|
|
<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>
|
|
</div>
|
|
) : null}
|
|
</RemoveScroll>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default DesktopPoll;
|