🎨 Improve participant definition (#1337)

This commit is contained in:
Luke Vella 2024-09-14 16:17:48 +01:00 committed by GitHub
parent 12110be7c7
commit 4e7b391a9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 269 additions and 238 deletions

View file

@ -15,7 +15,7 @@ export default async function Layout({
<div className="flex flex-col pb-16 md:pb-0"> <div className="flex flex-col pb-16 md:pb-0">
<div <div
className={cn( className={cn(
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 py-5 md:flex", "fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 md:flex",
)} )}
> >
<div className="flex w-full items-center justify-between gap-4"> <div className="flex w-full items-center justify-between gap-4">
@ -24,8 +24,8 @@ export default async function Layout({
</div> </div>
<Sidebar /> <Sidebar />
</div> </div>
<div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:px-8 lg:pb-8")}> <div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:p-6")}>
{children} <div className="max-w-5xl">{children}</div>
</div> </div>
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden"> <div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden">
<MobileNavigation /> <MobileNavigation />

View file

@ -1,10 +1,11 @@
"use client"; "use client";
import { PollStatus } from "@rallly/database"; import { PollStatus } from "@rallly/database";
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon"; import { Icon } from "@rallly/ui/icon";
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills"; import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import dayjs from "dayjs";
import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react"; import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
@ -132,31 +133,33 @@ function CopyLinkButton({ pollId }: { pollId: string }) {
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const [didCopy, setDidCopy] = React.useState(false); const [didCopy, setDidCopy] = React.useState(false);
if (didCopy) {
return (
<div className="inline-flex items-center gap-x-1.5 text-sm font-medium text-green-600">
<CheckIcon className="size-4" />
<Trans i18nKey="copied" />
</div>
);
}
return ( return (
<button <Button
type="button" type="button"
onClick={() => { disabled={didCopy}
onClick={(e) => {
e.stopPropagation();
copy(`${window.location.origin}/invite/${pollId}`); copy(`${window.location.origin}/invite/${pollId}`);
setDidCopy(true); setDidCopy(true);
setTimeout(() => { setTimeout(() => {
setDidCopy(false); setDidCopy(false);
}, 1000); }, 1000);
}} }}
className="text-foreground inline-flex items-center gap-x-1.5 text-sm hover:underline" className="relative z-20 w-full"
> >
<LinkIcon className="size-4" /> {didCopy ? (
<>
<CheckIcon className="size-4" />
<Trans i18nKey="copyLink" defaults="Copy Link" /> <Trans i18nKey="copied" defaults="Copied" />
</button> </>
) : (
<>
<LinkIcon className="size-4" />
<Trans i18nKey="copyLink" defaults="Copy Link" />
</>
)}
</Button>
); );
} }
@ -208,38 +211,36 @@ function PollsListView({
} }
return ( return (
<div className="grid gap-3 sm:gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:gap-4 md:grid-cols-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<div <div
className={cn("overflow-hidden rounded-lg border bg-white p-1")} className={cn(
"group relative space-y-4 overflow-hidden rounded-lg border bg-white p-4 focus-within:bg-gray-50",
)}
key={row.id} key={row.id}
> >
<div className="relative space-y-4 p-3 focus-within:bg-gray-100"> <div className="space-y-4">
<div className="flex items-start justify-between"> <div className="flex items-center gap-3">
<GroupPollIcon size="sm" /> <GroupPollIcon size="xs" />
<PollStatusBadge status={row.original.status} /> <h2 className="truncate text-base font-medium group-hover:underline">
</div>
<div className="space-y-2">
<h2 className="truncate text-base font-medium">
<Link <Link
href={`/poll/${row.original.id}`} href={`/poll/${row.original.id}`}
className="absolute inset-0 z-10" className="absolute inset-0 z-10"
/> />
{row.original.title} {row.original.title}
</h2> </h2>
<ParticipantCount count={row.original.participants.length} /> </div>
<div className="flex items-center gap-2">
<Badge size="lg">
<PollStatusBadge status={row.original.status} />
</Badge>
<Badge size="lg">
<ParticipantCount count={row.original.participants.length} />
</Badge>
</div> </div>
</div> </div>
<div className="flex items-end justify-between p-3"> <div className="flex items-end justify-between">
<CopyLinkButton pollId={row.original.id} /> <CopyLinkButton pollId={row.original.id} />
<p className="text-muted-foreground whitespace-nowrap text-sm">
<Trans
i18nKey="createdTime"
values={{
relativeTime: dayjs(row.original.createdAt).fromNow(),
}}
/>
</p>
</div> </div>
</div> </div>
))} ))}

View file

@ -50,7 +50,7 @@ export function PageHeader({
className?: string; className?: string;
variant?: "default" | "ghost"; variant?: "default" | "ghost";
}) { }) {
return <div className={cn("mb-4 md:mt-2", className)}>{children}</div>; return <div className={cn("mb-6", className)}>{children}</div>;
} }
export function PageSection({ children }: { children?: React.ReactNode }) { export function PageSection({ children }: { children?: React.ReactNode }) {

View file

@ -16,6 +16,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu"; } from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon"; import { Icon } from "@rallly/ui/icon";
import { Input } from "@rallly/ui/input";
import { Textarea } from "@rallly/ui/textarea"; import { Textarea } from "@rallly/ui/textarea";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
@ -27,6 +28,11 @@ import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import {
Participant,
ParticipantAvatar,
ParticipantName,
} from "@/components/participant";
import { useParticipants } from "@/components/participants-provider"; import { useParticipants } from "@/components/participants-provider";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePermissions } from "@/contexts/permissions"; import { usePermissions } from "@/contexts/permissions";
@ -36,9 +42,7 @@ import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client"; import { trpc } from "@/utils/trpc/client";
import { requiredString } from "../../utils/form-validation"; import { requiredString } from "../../utils/form-validation";
import NameInput from "../name-input";
import TruncatedLinkify from "../poll/truncated-linkify"; import TruncatedLinkify from "../poll/truncated-linkify";
import UserAvatar from "../poll/user-avatar";
import { useUser } from "../user-provider"; import { useUser } from "../user-provider";
interface CommentForm { interface CommentForm {
@ -119,7 +123,13 @@ function NewCommentForm({
control={control} control={control}
rules={{ validate: requiredString }} rules={{ validate: requiredString }}
render={({ field }) => ( render={({ field }) => (
<NameInput error={!!formState.errors.authorName} {...field} /> <Input
placeholder={t("yourName")}
className="lg:w-48"
data-1p-ignore="true"
error={!!formState.errors.authorName}
{...field}
/>
)} )}
/> />
</div> </div>
@ -203,11 +213,15 @@ function DiscussionInner() {
<div className="" key={comment.id}> <div className="" key={comment.id}>
<div data-testid="comment"> <div data-testid="comment">
<div className="mb-1 flex items-center space-x-2"> <div className="mb-1 flex items-center space-x-2">
<UserAvatar <Participant>
name={comment.authorName} <ParticipantAvatar name={comment.authorName} />
showName={true} <ParticipantName>{comment.authorName}</ParticipantName>
isYou={session.ownsObject(comment)} {session.ownsObject(comment) ? (
/> <Badge>
<Trans i18nKey="you" />
</Badge>
) : null}
</Participant>
<div className="flex items-center gap-2 text-sm "> <div className="flex items-center gap-2 text-sm ">
<div className="text-gray-500"> <div className="text-gray-500">
{dayjs(comment.createdAt).fromNow()} {dayjs(comment.createdAt).fromNow()}

View file

@ -1,47 +0,0 @@
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 InputProps {
value?: string;
defaultValue?: string;
error?: boolean;
}
const NameInput = React.forwardRef<HTMLInputElement, NameInputProps>(function (
{ value, defaultValue, className, error, ...forwardProps },
ref,
) {
const { t } = useTranslation();
return (
<div className="relative flex items-center">
{value ? (
<UserAvatar
name={value ?? defaultValue ?? ""}
className="absolute left-2"
/>
) : null}
<Input
ref={ref}
className={cn(
"input text-sm",
{
"pl-9": value || defaultValue,
"ring-destructive ring-1": error,
},
className,
)}
placeholder={t("yourName")}
value={value}
{...forwardProps}
/>
</div>
);
});
NameInput.displayName = "NameInput";
export default NameInput;

View file

@ -1,4 +1,5 @@
"use client"; "use client";
import { cn } from "@rallly/ui";
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
import Image from "next/image"; import Image from "next/image";
import React from "react"; import React from "react";
@ -36,7 +37,16 @@ export function OptimizedAvatarImage({
/> />
) : null ) : null
) : null} ) : null}
{!src || !isLoaded ? <AvatarFallback>{name[0]}</AvatarFallback> : null} {!src || !isLoaded ? (
<AvatarFallback
className={cn({
"text-xs": size <= 24,
"text-lg": size >= 48,
})}
>
{name[0]}
</AvatarFallback>
) : null}
</Avatar> </Avatar>
); );
} }

View file

@ -1,7 +1,7 @@
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { ColoredAvatar } from "@/components/poll/user-avatar"; import { ParticipantAvatar } from "@/components/participant";
interface ParticipantAvatarBarProps { interface ParticipantAvatarBarProps {
participants: { name: string }[]; participants: { name: string }[];
@ -15,25 +15,25 @@ export const ParticipantAvatarBar = ({
const visibleCount = participants.length > max ? max - 1 : max; const visibleCount = participants.length > max ? max - 1 : max;
const hiddenCount = participants.length - visibleCount; const hiddenCount = participants.length - visibleCount;
return ( return (
<ul className="flex items-center -space-x-1 rounded-full border p-0.5"> <ul className="flex items-center -space-x-1">
{participants.slice(0, visibleCount).map((participant, index) => ( {participants.slice(0, visibleCount).map((participant, index) => (
<Tooltip key={index}> <Tooltip key={index}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<li className="inline-flex items-center justify-center rounded-full ring-2 ring-white"> <li className="z-10 inline-flex items-center justify-center rounded-full ring-2 ring-white">
<ColoredAvatar name={participant.name} /> <ParticipantAvatar name={participant.name} />
</li> </li>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{participant.name}</TooltipContent> <TooltipContent>{participant.name}</TooltipContent>
</Tooltip> </Tooltip>
))} ))}
{hiddenCount > 1 ? ( {hiddenCount > 1 ? (
<li className="inline-flex items-center justify-center rounded-full ring-2 ring-white"> <li className="relative z-20 inline-flex items-center justify-center rounded-full ring-2 ring-white">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span <span
className={cn( className={cn(
"select-none", "select-none",
"rounded-full bg-gray-200 px-1.5 text-xs font-semibold", "rounded-full bg-gray-100 px-1.5 text-xs font-semibold",
"inline-flex h-5 items-center justify-center", "inline-flex h-5 items-center justify-center",
)} )}
> >

View file

@ -40,8 +40,6 @@ import { useFormValidation } from "@/utils/form-validation";
import { usePostHog } from "@/utils/posthog"; import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client"; import { trpc } from "@/utils/trpc/client";
import { Participant } from ".prisma/client";
export const ParticipantDropdown = ({ export const ParticipantDropdown = ({
participant, participant,
onEdit, onEdit,
@ -50,7 +48,12 @@ export const ParticipantDropdown = ({
align, align,
}: { }: {
disabled?: boolean; disabled?: boolean;
participant: Participant; participant: {
name: string;
userId?: string;
email?: string;
id: string;
};
align?: "start" | "end"; align?: "start" | "end";
onEdit: () => void; onEdit: () => void;
children: React.ReactNode; children: React.ReactNode;

View file

@ -0,0 +1,55 @@
import { cn } from "@rallly/ui";
import { Avatar, AvatarFallback, getColor } from "@rallly/ui/avatar";
import React from "react";
export function Participant({ children }: { children: React.ReactNode }) {
return <div className="flex min-w-0 items-center gap-x-2">{children}</div>;
}
export const ParticipantAvatar = ({
size = 20,
name,
}: {
size?: number;
name: string;
}) => {
const color = getColor(name);
return (
<Avatar size={size}>
<AvatarFallback className="text-xs" color={color}>
{name[0]}
</AvatarFallback>
</Avatar>
);
};
export const ParticipantName = ({
children,
}: {
children: React.ReactNode;
}) => {
const ref = React.useRef<HTMLDivElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
return (
<div
ref={ref}
onMouseEnter={() => {
if (ref.current) {
setIsTruncated(ref.current.scrollWidth > ref.current.clientWidth);
}
}}
onMouseLeave={() => {
if (isTruncated) {
setIsTruncated(false);
}
}}
className={cn("truncate text-sm font-medium", {
"hover:-translate-x-2 hover:cursor-pointer hover:overflow-visible hover:whitespace-nowrap hover:rounded-md hover:bg-white hover:p-2":
isTruncated,
})}
>
{children}
</div>
);
};

View file

@ -304,7 +304,13 @@ const DesktopPoll: React.FunctionComponent = () => {
return ( return (
<ParticipantRow <ParticipantRow
key={i} key={i}
participant={participant} participant={{
id: participant.id,
name: participant.name,
userId: participant.userId ?? undefined,
email: participant.email ?? undefined,
votes: participant.votes,
}}
editMode={ editMode={
votingForm.watch("mode") === "edit" && votingForm.watch("mode") === "edit" &&
votingForm.watch("participantId") === votingForm.watch("participantId") ===

View file

@ -12,11 +12,12 @@ import { useTranslation } from "next-i18next";
import * as React from "react"; import * as React from "react";
import { Controller } from "react-hook-form"; import { Controller } from "react-hook-form";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { Participant, ParticipantName } from "@/components/participant";
import { useVotingForm } from "@/components/poll/voting-form"; import { useVotingForm } from "@/components/poll/voting-form";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { usePoll } from "../../poll-context"; import { usePoll } from "../../poll-context";
import UserAvatar, { YouAvatar } from "../user-avatar";
import { toggleVote, VoteSelector } from "../vote-selector"; import { toggleVote, VoteSelector } from "../vote-selector";
export interface ParticipantRowFormProps { export interface ParticipantRowFormProps {
@ -30,7 +31,6 @@ export interface ParticipantRowFormProps {
const ParticipantRowForm = ({ const ParticipantRowForm = ({
name, name,
isNew, isNew,
isYou,
className, className,
}: ParticipantRowFormProps) => { }: ParticipantRowFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -50,6 +50,8 @@ const ParticipantRowForm = ({
}; };
}, [form]); }, [form]);
const participantName = name ?? t("you");
return ( return (
<tr className={cn("group", className)}> <tr className={cn("group", className)}>
<td <td
@ -57,11 +59,10 @@ const ParticipantRowForm = ({
className="sticky left-0 z-10 h-12 bg-white px-4" className="sticky left-0 z-10 h-12 bg-white px-4"
> >
<div className="flex items-center justify-between gap-x-2.5"> <div className="flex items-center justify-between gap-x-2.5">
{name ? ( <Participant>
<UserAvatar name={name ?? t("you")} isYou={isYou} showName={true} /> <OptimizedAvatarImage name={participantName} size={20} />
) : ( <ParticipantName>{participantName}</ParticipantName>
<YouAvatar /> </Participant>
)}
{!isNew ? ( {!isNew ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tooltip> <Tooltip>

View file

@ -1,22 +1,34 @@
import { Participant, VoteType } from "@rallly/database"; import type { VoteType } from "@rallly/database";
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { Icon } from "@rallly/ui/icon"; import { Icon } from "@rallly/ui/icon";
import { MoreHorizontalIcon } from "lucide-react"; import { MoreHorizontalIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import {
Participant,
ParticipantAvatar,
ParticipantName,
} from "@/components/participant";
import { ParticipantDropdown } from "@/components/participant-dropdown"; import { ParticipantDropdown } from "@/components/participant-dropdown";
import { usePoll } from "@/components/poll-context"; import { usePoll } from "@/components/poll-context";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
import { usePermissions } from "@/contexts/permissions"; import { usePermissions } from "@/contexts/permissions";
import { Vote } from "@/utils/trpc/types"; import { Vote } from "@/utils/trpc/types";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon"; import VoteIcon from "../vote-icon";
import ParticipantRowForm from "./participant-row-form"; import ParticipantRowForm from "./participant-row-form";
export interface ParticipantRowProps { export interface ParticipantRowProps {
participant: Participant & { votes: Vote[] }; participant: {
id: string;
name: string;
userId?: string;
email?: string;
votes: Vote[];
};
className?: string; className?: string;
editMode?: boolean; editMode?: boolean;
onChangeEditMode?: (editMode: boolean) => void; onChangeEditMode?: (editMode: boolean) => void;
@ -41,7 +53,15 @@ export const ParticipantRowView: React.FunctionComponent<{
className="sticky left-0 z-10 h-12 bg-white px-4" className="sticky left-0 z-10 h-12 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} /> <Participant>
<ParticipantAvatar name={name} />
<ParticipantName>{name}</ParticipantName>
{isYou ? (
<Badge>
<Trans i18nKey="you" />
</Badge>
) : null}
</Participant>
{action} {action}
</div> </div>
</td> </td>

View file

@ -16,6 +16,8 @@ import * as React from "react";
import smoothscroll from "smoothscroll-polyfill"; import smoothscroll from "smoothscroll-polyfill";
import { TimesShownIn } from "@/components/clock"; import { TimesShownIn } from "@/components/clock";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { Participant, ParticipantName } from "@/components/participant";
import { ParticipantDropdown } from "@/components/participant-dropdown"; import { ParticipantDropdown } from "@/components/participant-dropdown";
import { useVotingForm } from "@/components/poll/voting-form"; import { useVotingForm } from "@/components/poll/voting-form";
import { useOptions, usePoll } from "@/components/poll-context"; import { useOptions, usePoll } from "@/components/poll-context";
@ -25,7 +27,6 @@ import { usePermissions } from "@/contexts/permissions";
import { useVisibleParticipants } from "../participants-provider"; import { useVisibleParticipants } from "../participants-provider";
import { useUser } from "../user-provider"; import { useUser } from "../user-provider";
import GroupedOptions from "./mobile-poll/grouped-options"; import GroupedOptions from "./mobile-poll/grouped-options";
import UserAvatar, { YouAvatar } from "./user-avatar";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
smoothscroll.polyfill(); smoothscroll.polyfill();
@ -100,11 +101,18 @@ const MobilePoll: React.FunctionComponent = () => {
{visibleParticipants.map((participant) => ( {visibleParticipants.map((participant) => (
<SelectItem key={participant.id} value={participant.id}> <SelectItem key={participant.id} value={participant.id}>
<div className="flex items-center gap-x-2.5"> <div className="flex items-center gap-x-2.5">
<UserAvatar <Participant>
name={participant.name} <OptimizedAvatarImage
showName={true} name={participant.name}
isYou={session.ownsObject(participant)} size={20}
/> />
<ParticipantName>{participant.name}</ParticipantName>
{session.ownsObject(participant) && (
<Badge>
<Trans i18nKey="you" />
</Badge>
)}
</Participant>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@ -112,7 +120,10 @@ const MobilePoll: React.FunctionComponent = () => {
</Select> </Select>
) : ( ) : (
<div className="flex grow items-center px-1"> <div className="flex grow items-center px-1">
<YouAvatar /> <Participant>
<OptimizedAvatarImage name={t("you")} size={20} />
<ParticipantName>{t("you")}</ParticipantName>
</Participant>
</div> </div>
)} )}
{isEditing ? ( {isEditing ? (
@ -131,7 +142,12 @@ const MobilePoll: React.FunctionComponent = () => {
<ParticipantDropdown <ParticipantDropdown
align="end" align="end"
disabled={!canEditParticipant(selectedParticipant.id)} disabled={!canEditParticipant(selectedParticipant.id)}
participant={selectedParticipant} participant={{
name: selectedParticipant.name,
userId: selectedParticipant.userId ?? undefined,
email: selectedParticipant.email ?? undefined,
id: selectedParticipant.id,
}}
onEdit={() => { onEdit={() => {
votingForm.setEditingParticipantId(selectedParticipant.id); votingForm.setEditingParticipantId(selectedParticipant.id);
}} }}

View file

@ -8,8 +8,8 @@ import * as React from "react";
import { useToggle } from "react-use"; import { useToggle } from "react-use";
import { useTranslation } from "@/app/i18n/client"; import { useTranslation } from "@/app/i18n/client";
import { ParticipantAvatar } from "@/components/participant";
import { useParticipants } from "@/components/participants-provider"; import { useParticipants } from "@/components/participants-provider";
import { UserAvatar } from "@/components/user";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { useRole } from "@/contexts/role"; import { useRole } from "@/contexts/role";
@ -51,11 +51,11 @@ const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
{participantsWhoVotedYes.map(({ name }, i) => ( {participantsWhoVotedYes.map(({ name }, i) => (
<div key={i} className="flex"> <div key={i} className="flex">
<div className="relative mr-2.5 flex size-5 items-center justify-center"> <div className="relative mr-2.5 flex size-5 items-center justify-center">
<UserAvatar size="xs" name={name} /> <ParticipantAvatar size={20} name={name} />
<VoteIcon <VoteIcon
type="yes" type="yes"
size="sm" size="sm"
className="absolute bottom-full left-full -translate-x-1/2 translate-y-1/2 rounded-full bg-white" className="absolute bottom-full left-full -translate-x-1.5 translate-y-2.5 rounded-full bg-white"
/> />
</div> </div>
<div className="truncate text-sm">{name}</div> <div className="truncate text-sm">{name}</div>
@ -66,7 +66,7 @@ const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
{participantsWhoVotedIfNeedBe.map(({ name }, i) => ( {participantsWhoVotedIfNeedBe.map(({ name }, i) => (
<div key={i} className="flex"> <div key={i} className="flex">
<div className="relative mr-2.5 flex size-5 items-center justify-center"> <div className="relative mr-2.5 flex size-5 items-center justify-center">
<UserAvatar size="xs" name={name} /> <ParticipantAvatar size={20} name={name} />
<VoteIcon <VoteIcon
type="ifNeedBe" type="ifNeedBe"
size="sm" size="sm"
@ -79,7 +79,7 @@ const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
{participantsWhoVotedNo.map(({ name }, i) => ( {participantsWhoVotedNo.map(({ name }, i) => (
<div key={i} className="flex"> <div key={i} className="flex">
<div className="relative mr-2.5 flex size-5 items-center justify-center"> <div className="relative mr-2.5 flex size-5 items-center justify-center">
<UserAvatar size="xs" name={name} /> <ParticipantAvatar size={20} name={name} />
<VoteIcon <VoteIcon
type="no" type="no"
size="sm" size="sm"

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import { CalendarIcon } from "lucide-react";
import { AddToCalendarButton } from "@/components/add-to-calendar-button"; import { AddToCalendarButton } from "@/components/add-to-calendar-button";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar"; import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { useVisibleParticipants } from "@/components/participants-provider"; import { useVisibleParticipants } from "@/components/participants-provider";
@ -11,7 +13,9 @@ import { useDayjs } from "@/utils/dayjs";
function FinalDate({ start }: { start: Date }) { function FinalDate({ start }: { start: Date }) {
const poll = usePoll(); const poll = usePoll();
const { adjustTimeZone } = useDayjs(); const { adjustTimeZone } = useDayjs();
return <span>{adjustTimeZone(start, !poll.timeZone).format("LL")}</span>; return (
<span>{adjustTimeZone(start, !poll.timeZone).format("dddd, LL")}</span>
);
} }
function DateIcon({ start }: { start: Date }) { function DateIcon({ start }: { start: Date }) {
@ -20,7 +24,7 @@ function DateIcon({ start }: { start: Date }) {
const d = adjustTimeZone(start, !poll.timeZone); const d = adjustTimeZone(start, !poll.timeZone);
return ( return (
<time <time
className="inline-flex size-12 flex-col rounded-lg border border-green-600/10 bg-green-600/5 text-center text-green-800/75" className="inline-flex size-12 flex-col rounded-lg border text-center"
dateTime={d.toISOString()} dateTime={d.toISOString()}
> >
<div className="border-b border-green-600/10 p-px text-xs"> <div className="border-b border-green-600/10 p-px text-xs">
@ -74,9 +78,10 @@ export function ScheduledEvent() {
return ( return (
<> <>
<div className="rounded-lg border border-green-400/20 bg-gradient-to-r from-green-200/15 to-green-200/5 p-0.5 shadow-sm"> <div className="rounded-lg border bg-white p-0.5 shadow-sm">
<div className="flex items-center gap-x-2 rounded-md border-b bg-green-500/10 p-3"> <div className="flex h-9 items-center gap-x-2 rounded-md bg-gray-100 px-2">
<h2 className="text-sm font-medium text-green-800"> <CalendarIcon className="size-4" />
<h2 className="text-sm font-medium">
<Trans i18nKey="schedulateDate" defaults="Scheduled Date" /> <Trans i18nKey="schedulateDate" defaults="Scheduled Date" />
</h2> </h2>
</div> </div>
@ -85,7 +90,7 @@ export function ScheduledEvent() {
<div> <div>
<DateIcon start={event.start} /> <DateIcon start={event.start} />
</div> </div>
<div className="items-center gap-x-4 text-green-800"> <div className="items-center gap-x-4">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-medium"> <div className="text-sm font-medium">
<FinalDate start={event.start} /> <FinalDate start={event.start} />

View file

@ -1,88 +0,0 @@
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";
interface UserAvatarProps {
name: string;
className?: string;
size?: "default" | "large";
color?: string;
showName?: boolean;
isYou?: boolean;
}
export const ColoredAvatar = (props: {
seed?: string;
name: string;
className?: string;
}) => {
const { color, requiresDarkText } = getRandomAvatarColor(
props.seed ?? props.name,
);
return (
<div
className={clsx(
"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,
)}
style={{
backgroundColor: color,
}}
>
{props.name[0]}
</div>
);
};
const UserAvatarInner: React.FunctionComponent<UserAvatarProps> = ({
name,
className,
}) => {
return <ColoredAvatar name={name} className={className} />;
};
const UserAvatar: React.FunctionComponent<UserAvatarProps> = ({
showName,
isYou,
className,
...forwardedProps
}) => {
const { t } = useTranslation();
if (!showName) {
return <UserAvatarInner className={className} {...forwardedProps} />;
}
return (
<div
className={clsx(
"inline-flex items-center gap-x-2.5 overflow-hidden",
className,
)}
>
<UserAvatarInner {...forwardedProps} />
<div className="min-w-0 truncate text-sm font-medium">
{forwardedProps.name}
</div>
{isYou ? <Badge>{t("you")}</Badge> : null}
</div>
);
};
export default UserAvatar;
export const YouAvatar = () => {
const { t } = useTranslation();
const you = t("you");
return (
<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")}
</span>
);
};

View file

@ -28,7 +28,7 @@ const userSchema = z.object({
export const UserContext = React.createContext<{ export const UserContext = React.createContext<{
user: z.infer<typeof userSchema>; user: z.infer<typeof userSchema>;
refresh: (data?: Record<string, unknown>) => Promise<Session | null>; refresh: (data?: Record<string, unknown>) => Promise<Session | null>;
ownsObject: (obj: { userId: string | null }) => boolean; ownsObject: (obj: { userId?: string | null }) => boolean;
} | null>(null); } | null>(null);
export const useUser = () => { export const useUser = () => {

View file

@ -1,8 +0,0 @@
import { useTranslation } from "next-i18next";
import UserAvatar from "./poll/user-avatar";
export const You = () => {
const { t } = useTranslation();
return <UserAvatar name={t("you")} showName={true} />;
};

View file

@ -2,9 +2,24 @@
import * as React from "react"; import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar"; import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@rallly/ui"; import { cn } from "@rallly/ui";
export const avatarColors = [
"indigo",
"green",
"blue",
"purple",
"emerald",
"violet",
"sky",
"cyan",
"pink",
] as const;
export type AvatarColor = (typeof avatarColors)[number];
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & { React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & {
@ -35,16 +50,44 @@ const AvatarImage = React.forwardRef<
)); ));
AvatarImage.displayName = AvatarPrimitive.Image.displayName; AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const avatarFallbackVariants = cva(
"flex h-full w-full items-center justify-center rounded-full font-medium",
{
variants: {
color: {
indigo: "bg-indigo-50 text-indigo-600",
green: "bg-green-50 text-green-600",
blue: "bg-blue-50 text-blue-600",
purple: "bg-purple-50 text-purple-600",
emerald: "bg-emerald-50 text-emerald-600",
violet: "bg-violet-50 text-violet-600",
sky: "bg-sky-50 text-sky-600",
cyan: "bg-cyan-50 text-cyan-600",
pink: "bg-pink-50 text-pink-600",
},
},
defaultVariants: {
color: "indigo",
},
},
);
export function getColor(seed: string): AvatarColor {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
}
return avatarColors[Math.abs(hash) % avatarColors.length];
}
const AvatarFallback = React.forwardRef< const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> &
>(({ className, ...props }, ref) => ( VariantProps<typeof avatarFallbackVariants>
>(({ className, color, ...props }, ref) => (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( className={cn(avatarFallbackVariants({ color }), className)}
"bg-primary-100 text-primary-800 flex h-full w-full items-center justify-center rounded-full font-medium",
className,
)}
{...props} {...props}
/> />
)); ));