mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
🎨 Improve participant definition (#1337)
This commit is contained in:
parent
12110be7c7
commit
4e7b391a9f
19 changed files with 269 additions and 238 deletions
|
@ -15,7 +15,7 @@ export default async function Layout({
|
|||
<div className="flex flex-col pb-16 md:pb-0">
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 py-5 md:flex",
|
||||
"fixed inset-y-0 z-50 hidden w-72 shrink-0 flex-col gap-y-4 overflow-y-auto p-6 md:flex",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
|
@ -24,8 +24,8 @@ export default async function Layout({
|
|||
</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:px-8 lg:pb-8")}>
|
||||
{children}
|
||||
<div className={cn("grow space-y-4 p-3 md:ml-72 md:p-4 lg:p-6")}>
|
||||
<div className="max-w-5xl">{children}</div>
|
||||
</div>
|
||||
<div className="fixed bottom-0 z-20 flex h-16 w-full flex-col justify-center bg-gray-100/90 backdrop-blur-md md:hidden">
|
||||
<MobileNavigation />
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
import { PollStatus } from "@rallly/database";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Badge } from "@rallly/ui/badge";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { RadioCards, RadioCardsItem } from "@rallly/ui/radio-pills";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import dayjs from "dayjs";
|
||||
import { CalendarPlusIcon, CheckIcon, LinkIcon, UserIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
@ -132,31 +133,33 @@ function CopyLinkButton({ pollId }: { pollId: string }) {
|
|||
const [, copy] = useCopyToClipboard();
|
||||
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 (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
disabled={didCopy}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(`${window.location.origin}/invite/${pollId}`);
|
||||
setDidCopy(true);
|
||||
setTimeout(() => {
|
||||
setDidCopy(false);
|
||||
}, 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" />
|
||||
</button>
|
||||
<Trans i18nKey="copied" defaults="Copied" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LinkIcon className="size-4" />
|
||||
<Trans i18nKey="copyLink" defaults="Copy Link" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -208,38 +211,36 @@ function PollsListView({
|
|||
}
|
||||
|
||||
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) => (
|
||||
<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}
|
||||
>
|
||||
<div className="relative space-y-4 p-3 focus-within:bg-gray-100">
|
||||
<div className="flex items-start justify-between">
|
||||
<GroupPollIcon size="sm" />
|
||||
<PollStatusBadge status={row.original.status} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="truncate text-base font-medium">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<GroupPollIcon size="xs" />
|
||||
<h2 className="truncate text-base font-medium group-hover:underline">
|
||||
<Link
|
||||
href={`/poll/${row.original.id}`}
|
||||
className="absolute inset-0 z-10"
|
||||
/>
|
||||
{row.original.title}
|
||||
</h2>
|
||||
<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 className="flex items-end justify-between p-3">
|
||||
<div className="flex items-end justify-between">
|
||||
<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>
|
||||
))}
|
||||
|
|
|
@ -50,7 +50,7 @@ export function PageHeader({
|
|||
className?: string;
|
||||
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 }) {
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@rallly/ui/dropdown-menu";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import { Textarea } from "@rallly/ui/textarea";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
|
@ -27,6 +28,11 @@ import { useTranslation } from "next-i18next";
|
|||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
Participant,
|
||||
ParticipantAvatar,
|
||||
ParticipantName,
|
||||
} from "@/components/participant";
|
||||
import { useParticipants } from "@/components/participants-provider";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { usePermissions } from "@/contexts/permissions";
|
||||
|
@ -36,9 +42,7 @@ import { usePostHog } from "@/utils/posthog";
|
|||
import { trpc } from "@/utils/trpc/client";
|
||||
|
||||
import { requiredString } from "../../utils/form-validation";
|
||||
import NameInput from "../name-input";
|
||||
import TruncatedLinkify from "../poll/truncated-linkify";
|
||||
import UserAvatar from "../poll/user-avatar";
|
||||
import { useUser } from "../user-provider";
|
||||
|
||||
interface CommentForm {
|
||||
|
@ -119,7 +123,13 @@ function NewCommentForm({
|
|||
control={control}
|
||||
rules={{ validate: requiredString }}
|
||||
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>
|
||||
|
@ -203,11 +213,15 @@ function DiscussionInner() {
|
|||
<div className="" key={comment.id}>
|
||||
<div data-testid="comment">
|
||||
<div className="mb-1 flex items-center space-x-2">
|
||||
<UserAvatar
|
||||
name={comment.authorName}
|
||||
showName={true}
|
||||
isYou={session.ownsObject(comment)}
|
||||
/>
|
||||
<Participant>
|
||||
<ParticipantAvatar name={comment.authorName} />
|
||||
<ParticipantName>{comment.authorName}</ParticipantName>
|
||||
{session.ownsObject(comment) ? (
|
||||
<Badge>
|
||||
<Trans i18nKey="you" />
|
||||
</Badge>
|
||||
) : null}
|
||||
</Participant>
|
||||
<div className="flex items-center gap-2 text-sm ">
|
||||
<div className="text-gray-500">
|
||||
{dayjs(comment.createdAt).fromNow()}
|
||||
|
|
|
@ -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;
|
|
@ -1,4 +1,5 @@
|
|||
"use client";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
@ -36,7 +37,16 @@ export function OptimizedAvatarImage({
|
|||
/>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { cn } from "@rallly/ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
|
||||
|
||||
import { ColoredAvatar } from "@/components/poll/user-avatar";
|
||||
import { ParticipantAvatar } from "@/components/participant";
|
||||
|
||||
interface ParticipantAvatarBarProps {
|
||||
participants: { name: string }[];
|
||||
|
@ -15,25 +15,25 @@ export const ParticipantAvatarBar = ({
|
|||
const visibleCount = participants.length > max ? max - 1 : max;
|
||||
const hiddenCount = participants.length - visibleCount;
|
||||
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) => (
|
||||
<Tooltip key={index}>
|
||||
<TooltipTrigger asChild>
|
||||
<li className="inline-flex items-center justify-center rounded-full ring-2 ring-white">
|
||||
<ColoredAvatar name={participant.name} />
|
||||
<li className="z-10 inline-flex items-center justify-center rounded-full ring-2 ring-white">
|
||||
<ParticipantAvatar 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">
|
||||
<li className="relative z-20 inline-flex items-center justify-center rounded-full ring-2 ring-white">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -40,8 +40,6 @@ import { useFormValidation } from "@/utils/form-validation";
|
|||
import { usePostHog } from "@/utils/posthog";
|
||||
import { trpc } from "@/utils/trpc/client";
|
||||
|
||||
import { Participant } from ".prisma/client";
|
||||
|
||||
export const ParticipantDropdown = ({
|
||||
participant,
|
||||
onEdit,
|
||||
|
@ -50,7 +48,12 @@ export const ParticipantDropdown = ({
|
|||
align,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
participant: Participant;
|
||||
participant: {
|
||||
name: string;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
id: string;
|
||||
};
|
||||
align?: "start" | "end";
|
||||
onEdit: () => void;
|
||||
children: React.ReactNode;
|
||||
|
|
55
apps/web/src/components/participant.tsx
Normal file
55
apps/web/src/components/participant.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -304,7 +304,13 @@ const DesktopPoll: React.FunctionComponent = () => {
|
|||
return (
|
||||
<ParticipantRow
|
||||
key={i}
|
||||
participant={participant}
|
||||
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") ===
|
||||
|
|
|
@ -12,11 +12,12 @@ import { useTranslation } from "next-i18next";
|
|||
import * as React from "react";
|
||||
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 { Trans } from "@/components/trans";
|
||||
|
||||
import { usePoll } from "../../poll-context";
|
||||
import UserAvatar, { YouAvatar } from "../user-avatar";
|
||||
import { toggleVote, VoteSelector } from "../vote-selector";
|
||||
|
||||
export interface ParticipantRowFormProps {
|
||||
|
@ -30,7 +31,6 @@ export interface ParticipantRowFormProps {
|
|||
const ParticipantRowForm = ({
|
||||
name,
|
||||
isNew,
|
||||
isYou,
|
||||
className,
|
||||
}: ParticipantRowFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
@ -50,6 +50,8 @@ const ParticipantRowForm = ({
|
|||
};
|
||||
}, [form]);
|
||||
|
||||
const participantName = name ?? t("you");
|
||||
|
||||
return (
|
||||
<tr className={cn("group", className)}>
|
||||
<td
|
||||
|
@ -57,11 +59,10 @@ const ParticipantRowForm = ({
|
|||
className="sticky left-0 z-10 h-12 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 />
|
||||
)}
|
||||
<Participant>
|
||||
<OptimizedAvatarImage name={participantName} size={20} />
|
||||
<ParticipantName>{participantName}</ParticipantName>
|
||||
</Participant>
|
||||
{!isNew ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
|
|
|
@ -1,22 +1,34 @@
|
|||
import { Participant, VoteType } from "@rallly/database";
|
||||
import type { VoteType } from "@rallly/database";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Badge } from "@rallly/ui/badge";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { MoreHorizontalIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Participant,
|
||||
ParticipantAvatar,
|
||||
ParticipantName,
|
||||
} from "@/components/participant";
|
||||
import { ParticipantDropdown } from "@/components/participant-dropdown";
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { useUser } from "@/components/user-provider";
|
||||
import { usePermissions } from "@/contexts/permissions";
|
||||
import { Vote } from "@/utils/trpc/types";
|
||||
|
||||
import UserAvatar from "../user-avatar";
|
||||
import VoteIcon from "../vote-icon";
|
||||
import ParticipantRowForm from "./participant-row-form";
|
||||
|
||||
export interface ParticipantRowProps {
|
||||
participant: Participant & { votes: Vote[] };
|
||||
participant: {
|
||||
id: string;
|
||||
name: string;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
votes: Vote[];
|
||||
};
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
@ -16,6 +16,8 @@ import * as React from "react";
|
|||
import smoothscroll from "smoothscroll-polyfill";
|
||||
|
||||
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 { useVotingForm } from "@/components/poll/voting-form";
|
||||
import { useOptions, usePoll } from "@/components/poll-context";
|
||||
|
@ -25,7 +27,6 @@ import { usePermissions } from "@/contexts/permissions";
|
|||
import { useVisibleParticipants } from "../participants-provider";
|
||||
import { useUser } from "../user-provider";
|
||||
import GroupedOptions from "./mobile-poll/grouped-options";
|
||||
import UserAvatar, { YouAvatar } from "./user-avatar";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
smoothscroll.polyfill();
|
||||
|
@ -100,11 +101,18 @@ const MobilePoll: React.FunctionComponent = () => {
|
|||
{visibleParticipants.map((participant) => (
|
||||
<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)}
|
||||
/>
|
||||
<Participant>
|
||||
<OptimizedAvatarImage
|
||||
name={participant.name}
|
||||
size={20}
|
||||
/>
|
||||
<ParticipantName>{participant.name}</ParticipantName>
|
||||
{session.ownsObject(participant) && (
|
||||
<Badge>
|
||||
<Trans i18nKey="you" />
|
||||
</Badge>
|
||||
)}
|
||||
</Participant>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
@ -112,7 +120,10 @@ const MobilePoll: React.FunctionComponent = () => {
|
|||
</Select>
|
||||
) : (
|
||||
<div className="flex grow items-center px-1">
|
||||
<YouAvatar />
|
||||
<Participant>
|
||||
<OptimizedAvatarImage name={t("you")} size={20} />
|
||||
<ParticipantName>{t("you")}</ParticipantName>
|
||||
</Participant>
|
||||
</div>
|
||||
)}
|
||||
{isEditing ? (
|
||||
|
@ -131,7 +142,12 @@ const MobilePoll: React.FunctionComponent = () => {
|
|||
<ParticipantDropdown
|
||||
align="end"
|
||||
disabled={!canEditParticipant(selectedParticipant.id)}
|
||||
participant={selectedParticipant}
|
||||
participant={{
|
||||
name: selectedParticipant.name,
|
||||
userId: selectedParticipant.userId ?? undefined,
|
||||
email: selectedParticipant.email ?? undefined,
|
||||
id: selectedParticipant.id,
|
||||
}}
|
||||
onEdit={() => {
|
||||
votingForm.setEditingParticipantId(selectedParticipant.id);
|
||||
}}
|
||||
|
|
|
@ -8,8 +8,8 @@ import * as React from "react";
|
|||
import { useToggle } from "react-use";
|
||||
|
||||
import { useTranslation } from "@/app/i18n/client";
|
||||
import { ParticipantAvatar } from "@/components/participant";
|
||||
import { useParticipants } from "@/components/participants-provider";
|
||||
import { UserAvatar } from "@/components/user";
|
||||
import { usePoll } from "@/contexts/poll";
|
||||
import { useRole } from "@/contexts/role";
|
||||
|
||||
|
@ -51,11 +51,11 @@ const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
|
|||
{participantsWhoVotedYes.map(({ name }, i) => (
|
||||
<div key={i} className="flex">
|
||||
<div className="relative mr-2.5 flex size-5 items-center justify-center">
|
||||
<UserAvatar size="xs" name={name} />
|
||||
<ParticipantAvatar size={20} name={name} />
|
||||
<VoteIcon
|
||||
type="yes"
|
||||
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 className="truncate text-sm">{name}</div>
|
||||
|
@ -66,7 +66,7 @@ const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
|
|||
{participantsWhoVotedIfNeedBe.map(({ name }, i) => (
|
||||
<div key={i} className="flex">
|
||||
<div className="relative mr-2.5 flex size-5 items-center justify-center">
|
||||
<UserAvatar size="xs" name={name} />
|
||||
<ParticipantAvatar size={20} name={name} />
|
||||
<VoteIcon
|
||||
type="ifNeedBe"
|
||||
size="sm"
|
||||
|
@ -79,7 +79,7 @@ const PollOptionVoteSummary: React.FunctionComponent<{ optionId: string }> = ({
|
|||
{participantsWhoVotedNo.map(({ name }, i) => (
|
||||
<div key={i} className="flex">
|
||||
<div className="relative mr-2.5 flex size-5 items-center justify-center">
|
||||
<UserAvatar size="xs" name={name} />
|
||||
<ParticipantAvatar size={20} name={name} />
|
||||
<VoteIcon
|
||||
type="no"
|
||||
size="sm"
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
|
||||
import { AddToCalendarButton } from "@/components/add-to-calendar-button";
|
||||
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
|
||||
import { useVisibleParticipants } from "@/components/participants-provider";
|
||||
|
@ -11,7 +13,9 @@ 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>;
|
||||
return (
|
||||
<span>{adjustTimeZone(start, !poll.timeZone).format("dddd, LL")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DateIcon({ start }: { start: Date }) {
|
||||
|
@ -20,7 +24,7 @@ function DateIcon({ start }: { start: Date }) {
|
|||
const d = adjustTimeZone(start, !poll.timeZone);
|
||||
return (
|
||||
<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()}
|
||||
>
|
||||
<div className="border-b border-green-600/10 p-px text-xs">
|
||||
|
@ -74,9 +78,10 @@ export function ScheduledEvent() {
|
|||
|
||||
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="flex items-center gap-x-2 rounded-md border-b bg-green-500/10 p-3">
|
||||
<h2 className="text-sm font-medium text-green-800">
|
||||
<div className="rounded-lg border bg-white p-0.5 shadow-sm">
|
||||
<div className="flex h-9 items-center gap-x-2 rounded-md bg-gray-100 px-2">
|
||||
<CalendarIcon className="size-4" />
|
||||
<h2 className="text-sm font-medium">
|
||||
<Trans i18nKey="schedulateDate" defaults="Scheduled Date" />
|
||||
</h2>
|
||||
</div>
|
||||
|
@ -85,7 +90,7 @@ export function ScheduledEvent() {
|
|||
<div>
|
||||
<DateIcon start={event.start} />
|
||||
</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="text-sm font-medium">
|
||||
<FinalDate start={event.start} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -28,7 +28,7 @@ const userSchema = z.object({
|
|||
export const UserContext = React.createContext<{
|
||||
user: z.infer<typeof userSchema>;
|
||||
refresh: (data?: Record<string, unknown>) => Promise<Session | null>;
|
||||
ownsObject: (obj: { userId: string | null }) => boolean;
|
||||
ownsObject: (obj: { userId?: string | null }) => boolean;
|
||||
} | null>(null);
|
||||
|
||||
export const useUser = () => {
|
||||
|
|
|
@ -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} />;
|
||||
};
|
|
@ -2,9 +2,24 @@
|
|||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
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<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & {
|
||||
|
@ -35,16 +50,44 @@ const AvatarImage = React.forwardRef<
|
|||
));
|
||||
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<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> &
|
||||
VariantProps<typeof avatarFallbackVariants>
|
||||
>(({ className, color, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-primary-100 text-primary-800 flex h-full w-full items-center justify-center rounded-full font-medium",
|
||||
className,
|
||||
)}
|
||||
className={cn(avatarFallbackVariants({ color }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
Loading…
Add table
Reference in a new issue