♻️ Standard avatar sizes (#1375)

This commit is contained in:
Luke Vella 2024-10-04 18:53:29 +01:00 committed by GitHub
parent b0e0a8f09c
commit 3c340bdf90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 120 additions and 275 deletions

View file

@ -5,7 +5,7 @@ import React, { useState } from "react";
import { z } from "zod";
import { useTranslation } from "@/app/i18n/client";
import { CurrentUserAvatar } from "@/components/current-user-avatar";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { useAvatarsEnabled } from "@/features/avatars";
@ -189,9 +189,14 @@ function Upload() {
}
export function ProfilePicture() {
const { user } = useUser();
return (
<div className="flex items-center gap-x-4">
<CurrentUserAvatar size={56} />
<OptimizedAvatarImage
src={user.image ?? undefined}
name={user.name}
size="lg"
/>
<Upload />
</div>
);

View file

@ -19,7 +19,7 @@ import {
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CurrentUserAvatar } from "@/components/current-user-avatar";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { PayWallDialog } from "@/components/pay-wall-dialog";
import { ProBadge } from "@/components/pro-badge";
import { Trans } from "@/components/trans";
@ -171,7 +171,11 @@ export function Sidebar() {
>
<Link href="/settings/profile">
<div>
<CurrentUserAvatar size={40} />
<OptimizedAvatarImage
src={user.image ?? undefined}
name={user.name}
size="md"
/>
</div>
<span className="ml-1 grid grow">
<span className="font-semibold">{user.name}</span>

View file

@ -57,7 +57,7 @@ export const LoginPage = ({ magicLink, email }: PageProps) => {
<OptimizedAvatarImage
src={data?.image ?? undefined}
name={data?.name ?? ""}
size={56}
size="xl"
/>
<div className="text-center">
<div className="mb-1 h-6 font-medium">

View file

@ -1,22 +0,0 @@
"use client";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { useUser } from "@/components/user-provider";
export const CurrentUserAvatar = ({
size,
className,
}: {
size: number;
className?: string;
}) => {
const { user } = useUser();
return (
<OptimizedAvatarImage
className={className}
src={user.image ?? undefined}
name={user.name}
size={size}
/>
);
};

View file

@ -15,6 +15,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Flex } from "@rallly/ui/flex";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Icon } from "@rallly/ui/icon";
import { Input } from "@rallly/ui/input";
@ -29,11 +30,8 @@ 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 { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { ParticipantName } from "@/components/participant";
import { useParticipants } from "@/components/participants-provider";
import { Trans } from "@/components/trans";
import { usePermissions } from "@/contexts/permissions";
@ -220,15 +218,18 @@ function DiscussionInner() {
<div className="" key={comment.id}>
<div data-testid="comment">
<div className="mb-1 flex items-center space-x-2">
<Participant>
<ParticipantAvatar name={comment.authorName} />
<Flex gap="sm">
<OptimizedAvatarImage
name={comment.authorName}
size="xs"
/>
<ParticipantName>{comment.authorName}</ParticipantName>
{session.ownsObject(comment) ? (
<Badge>
<Trans i18nKey="you" />
</Badge>
) : null}
</Participant>
</Flex>
<div className="flex items-center gap-2 text-sm ">
<div className="text-gray-500">
{dayjs(comment.createdAt).fromNow()}
@ -257,7 +258,7 @@ function DiscussionInner() {
)}
</div>
</div>
<div className="ml-0.5 w-fit whitespace-pre-wrap pl-7 text-sm leading-relaxed">
<div className="w-fit whitespace-pre-wrap pl-7 text-sm leading-relaxed">
<TruncatedLinkify>{comment.content}</TruncatedLinkify>
</div>
</div>

View file

@ -4,7 +4,13 @@ import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
import Image from "next/image";
import React from "react";
import { useAvatarsEnabled } from "@/features/avatars";
const sizeToWidth = {
xs: 20,
sm: 24,
md: 36,
lg: 48,
xl: 56,
};
export function OptimizedAvatarImage({
size,
@ -12,19 +18,21 @@ export function OptimizedAvatarImage({
src,
name,
}: {
size: number;
size: "xs" | "sm" | "md" | "lg" | "xl";
src?: string;
name: string;
className?: string;
}) {
const isAvatarsEnabled = useAvatarsEnabled();
const [isLoaded, setLoaded] = React.useState(false);
return (
<Avatar className={className} style={{ width: size, height: size }}>
<Avatar
className={className}
style={{ width: sizeToWidth[size], height: sizeToWidth[size] }}
>
{src ? (
src.startsWith("https") ? (
<AvatarImage src={src} alt={name} />
) : isAvatarsEnabled ? (
) : (
<Image
src={`/api/storage/${src}`}
width={128}
@ -35,14 +43,17 @@ export function OptimizedAvatarImage({
setLoaded(true);
}}
/>
) : null
)
) : null}
{!src || !isLoaded ? (
<AvatarFallback
seed={name}
className={cn({
"text-xs": size <= 24,
"text-lg": size >= 48,
className={cn("shrink-0", {
"text-xs": size === "xs",
"text-sm": size === "sm",
"text-md": size === "md",
"text-lg": size === "lg",
"text-xl": size === "xl",
})}
>
{name[0]?.toUpperCase()}

View file

@ -1,7 +1,7 @@
import { cn } from "@rallly/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
import { ParticipantAvatar } from "@/components/participant";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
interface ParticipantAvatarBarProps {
participants: { name: string }[];
@ -20,7 +20,7 @@ export const ParticipantAvatarBar = ({
<Tooltip key={index}>
<TooltipTrigger asChild>
<li className="z-10 inline-flex items-center justify-center rounded-full ring-2 ring-white">
<ParticipantAvatar name={participant.name} />
<OptimizedAvatarImage name={participant.name} size="xs" />
</li>
</TooltipTrigger>
<TooltipContent>{participant.name}</TooltipContent>

View file

@ -34,6 +34,7 @@ import { SubmitHandler, useForm } from "react-hook-form";
import { useMount } from "react-use";
import { z } from "zod";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { useDeleteParticipantMutation } from "@/components/poll/mutations";
import { Trans } from "@/components/trans";
import { useFormValidation } from "@/utils/form-validation";
@ -75,13 +76,18 @@ export const ParticipantDropdown = ({
</DropdownMenuTrigger>
<DropdownMenuContent align={align}>
<DropdownMenuLabel>
<div className="grid gap-0.5">
<div>{participant.name}</div>
{participant.email ? (
<div className="text-muted-foreground text-xs font-normal">
{participant.email}
</div>
) : null}
<div className="flex items-center gap-x-2">
<div>
<OptimizedAvatarImage name={participant.name} size="md" />
</div>
<div className="grid gap-0.5">
<div>{participant.name}</div>
{participant.email ? (
<div className="text-muted-foreground text-xs font-normal">
{participant.email}
</div>
) : null}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />

View file

@ -6,16 +6,31 @@ export function Participant({ children }: { children: React.ReactNode }) {
return <div className="flex min-w-0 items-center gap-x-2">{children}</div>;
}
const sizeToWidth = {
xs: 20,
sm: 24,
md: 32,
lg: 48,
};
export const ParticipantAvatar = ({
size = 20,
size = "md",
name,
}: {
size?: number;
size?: "xs" | "sm" | "md" | "lg";
name: string;
}) => {
return (
<Avatar size={size}>
<AvatarFallback className="text-xs" seed={name}>
<Avatar size={sizeToWidth[size]}>
<AvatarFallback
className={cn({
"text-xs": size === "xs",
"text-sm": size === "sm",
"text-md": size === "md",
"text-lg": size === "lg",
})}
seed={name}
>
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>

View file

@ -62,7 +62,7 @@ const ParticipantRowForm = ({
<div className="flex items-center justify-between gap-x-2.5">
<Participant>
{name ? (
<OptimizedAvatarImage name={participantName} size={20} />
<OptimizedAvatarImage name={participantName} size="xs" />
) : (
<YouAvatar />
)}

View file

@ -2,15 +2,13 @@ import type { VoteType } from "@rallly/database";
import { cn } from "@rallly/ui";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Flex } from "@rallly/ui/flex";
import { Icon } from "@rallly/ui/icon";
import { MoreHorizontalIcon } from "lucide-react";
import * as React from "react";
import {
Participant,
ParticipantAvatar,
ParticipantName,
} from "@/components/participant";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { ParticipantName } from "@/components/participant";
import { ParticipantDropdown } from "@/components/participant-dropdown";
import { usePoll } from "@/components/poll-context";
import { Trans } from "@/components/trans";
@ -53,16 +51,18 @@ 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">
<Participant>
<ParticipantAvatar name={name} />
<ParticipantName>{name}</ParticipantName>
{isYou ? (
<Badge>
<Trans i18nKey="you" />
</Badge>
) : null}
</Participant>
{action}
<div>
<Flex gap="sm">
<OptimizedAvatarImage size="xs" name={name} />
<ParticipantName>{name}</ParticipantName>
{isYou ? (
<Badge>
<Trans i18nKey="you" />
</Badge>
) : null}
</Flex>
</div>
<div>{action}</div>
</div>
</td>
{votes.map((vote, i) => {

View file

@ -1,6 +1,7 @@
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@rallly/ui/card";
import { Flex } from "@rallly/ui/flex";
import { Icon } from "@rallly/ui/icon";
import {
Select,
@ -101,20 +102,15 @@ const MobilePoll: React.FunctionComponent = () => {
</SelectItem>
{visibleParticipants.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
<div className="flex items-center gap-x-2.5">
<Participant>
<OptimizedAvatarImage
name={participant.name}
size={20}
/>
<ParticipantName>{participant.name}</ParticipantName>
{session.ownsObject(participant) && (
<Badge>
<Trans i18nKey="you" />
</Badge>
)}
</Participant>
</div>
<Flex gap="sm">
<OptimizedAvatarImage name={participant.name} size="xs" />
<ParticipantName>{participant.name}</ParticipantName>
{session.ownsObject(participant) && (
<Badge>
<Trans i18nKey="you" />
</Badge>
)}
</Flex>
</SelectItem>
))}
</SelectContent>

View file

@ -8,7 +8,7 @@ import * as React from "react";
import { useToggle } from "react-use";
import { useTranslation } from "@/app/i18n/client";
import { ParticipantAvatar } from "@/components/participant";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { useParticipants } from "@/components/participants-provider";
import { usePoll } from "@/contexts/poll";
import { useRole } from "@/contexts/role";
@ -51,7 +51,7 @@ 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">
<ParticipantAvatar size={20} name={name} />
<OptimizedAvatarImage size="xs" name={name} />
<VoteIcon
type="yes"
size="sm"
@ -64,7 +64,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">
<ParticipantAvatar size={20} name={name} />
<OptimizedAvatarImage size="xs" 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">
<ParticipantAvatar size={20} name={name} />
<OptimizedAvatarImage size="xs" name={name} />
<VoteIcon
type="no"
size="sm"

View file

@ -1,178 +0,0 @@
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 { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import React from "react";
import { Trans } from "@/components/trans";
export const Table = <TData extends Record<string, unknown>>(props: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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<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(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div>
<div
className={cn(
props.className,
"scrollbar-thin max-w-full overflow-x-auto",
)}
>
<table
className={cn(
"border-collapse",
props.layout === "auto" ? "w-full table-auto" : "table-fixed",
)}
>
{props.enableTableHeader ? (
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.getSize(),
maxWidth:
props.layout === "auto"
? header.getSize()
: undefined,
}}
className="text-muted-foreground h-9 whitespace-nowrap border-b px-2.5 text-left text-xs font-normal"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
) : null}
<tbody>
{table.getRowModel().rows.map((row, i) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
style={{
width: cell.column.getSize(),
maxWidth:
props.layout === "auto"
? cell.column.getSize()
: undefined,
}}
key={cell.id}
className={cn(
"relative h-14 overflow-hidden border-gray-100 px-2.5 align-middle",
{
"border-b": table.getRowModel().rows.length !== i + 1,
},
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
{props.enableTableFooter ? (
<tfoot>
{table.getFooterGroups().map((footerGroup) => (
<tr key={footerGroup.id} className="relative">
{footerGroup.headers.map((header) => (
<th className="border-t" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.footer,
header.getContext(),
)}
</th>
))}
</tr>
))}
</tfoot>
) : null}
</table>
</div>
{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"
defaults="Page {currentPage} of {pageCount}"
values={{
currentPage: table.getState().pagination.pageIndex + 1,
pageCount: table.getPageCount(),
}}
/>
</span>
</div>
<Flex>
<Button
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ArrowLeftIcon
className={cn("size-4", {
"text-gray-400": !table.getCanPreviousPage(),
})}
/>
</Button>
<Button
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ArrowRightIcon className="size-4 text-gray-500" />
</Button>
</Flex>
</div>
) : null}
</div>
);
};

View file

@ -26,8 +26,8 @@ import {
} from "lucide-react";
import Link from "next/link";
import { CurrentUserAvatar } from "@/components/current-user-avatar";
import { LoginLink } from "@/components/login-link";
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
import { RegisterLink } from "@/components/register-link";
import { Trans } from "@/components/trans";
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
@ -57,7 +57,11 @@ export const UserDropdown = ({ className }: { className?: string }) => {
className={cn("group min-w-0", className)}
>
<Button variant="ghost">
<CurrentUserAvatar size={24} />
<OptimizedAvatarImage
src={user.image ?? undefined}
name={user.name}
size="sm"
/>
<span className="truncate">{user.name}</span>
<Icon>
<ChevronDownIcon />

View file

@ -39,8 +39,11 @@ const flexVariants = cva("box-border flex justify-start", {
},
gap: {
none: "gap-0",
md: "gap-2.5",
lg: "gap-4",
xs: "gap-1",
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
xl: "gap-8",
},
},
defaultVariants: {
@ -48,7 +51,7 @@ const flexVariants = cva("box-border flex justify-start", {
align: "center",
justify: "start",
wrap: "noWrap",
gap: "md",
gap: "none",
},
});