feat: display profile picture

This commit is contained in:
Henry
2025-01-22 13:35:44 +00:00
parent ef8f42a460
commit 0bd23d24ac
12 changed files with 130 additions and 74 deletions

View File

@@ -1,47 +1,71 @@
import Image from "next/image";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers"; import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
const sizeMap = {
xs: 20,
sm: 24,
md: 36,
lg: 48,
} as const;
const Avatar = ({ const Avatar = ({
size = "md", size = "md",
name, name,
email, email,
icon, icon,
imageUrl,
isLoading, isLoading,
}: { }: {
size?: "sm" | "md" | "lg"; size?: "xs" | "sm" | "md" | "lg";
name: string; name: string;
email: string; email: string;
imageUrl?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
isLoading: boolean; isLoading?: boolean;
}) => { }) => {
const initials = name const initials = name
? getInitialsFromName(name) ? getInitialsFromName(name)
: inferInitialsFromEmail(email ?? ""); : inferInitialsFromEmail(email ?? "");
return ( return (
<span <>
className={twMerge( {imageUrl ? (
"inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400", <Image
isLoading && "animate-pulse bg-light-200 dark:bg-dark-200", src={imageUrl}
size === "sm" && "h-6 w-6", className="rounded-full bg-gray-50"
size === "lg" && "h-12 w-12", width={sizeMap[size]}
)} height={sizeMap[size]}
> alt=""
{icon ? ( />
<span className="text-[12px] text-white">{icon}</span>
) : ( ) : (
<span <span
className={twMerge( className={twMerge(
"text-sm font-medium leading-none text-white", "inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400",
size === "sm" && "text-[10px]", isLoading && "animate-pulse bg-light-200 dark:bg-dark-200",
size === "lg" && "text-md", size === "xs" && "h-5 w-5",
size === "sm" && "h-6 w-6",
size === "lg" && "h-12 w-12",
)} )}
> >
{initials} {icon ? (
<span className="text-[12px] text-white">{icon}</span>
) : (
<span
className={twMerge(
"text-sm font-medium leading-none text-white",
size === "xs" && "text-[8px]",
size === "sm" && "text-[10px]",
size === "lg" && "text-md",
)}
>
{initials}
</span>
)}
</span> </span>
)} )}
</span> </>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { usePathname } from "next/navigation"; import { useRouter } from "next/router";
import boardsIconDark from "~/assets/boards-dark.json"; import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json"; import boardsIconLight from "~/assets/boards-light.json";
@@ -25,7 +25,10 @@ export default function SideNavigation({
user, user,
isLoading, isLoading,
}: SideNavigationProps) { }: SideNavigationProps) {
const pathname = usePathname(); const router = useRouter();
const { pathname } = router;
const { activeTheme } = useTheme(); const { activeTheme } = useTheme();
const isDarkMode = activeTheme === "dark"; const isDarkMode = activeTheme === "dark";

View File

@@ -5,6 +5,7 @@ import { Fragment } from "react";
import { useTheme } from "~/providers/theme"; import { useTheme } from "~/providers/theme";
import createClient from "~/utils/supabase/client"; import createClient from "~/utils/supabase/client";
import { getPublicUrl } from "~/utils/supabase/getPublicUrl";
interface UserMenuProps { interface UserMenuProps {
imageUrl: string | undefined; imageUrl: string | undefined;
@@ -31,9 +32,7 @@ export default function UserMenu({
router.push("/login"); router.push("/login");
}; };
const avatarUrl = imageUrl const avatarUrl = imageUrl ? getPublicUrl(imageUrl) : null;
? db.storage.from("avatars").getPublicUrl(imageUrl).data.publicUrl
: null;
return ( return (
<Menu as="div" className="relative inline-block w-full text-left"> <Menu as="div" className="relative inline-block w-full text-left">
@@ -48,9 +47,9 @@ export default function UserMenu({
{avatarUrl ? ( {avatarUrl ? (
<Image <Image
src={avatarUrl} src={avatarUrl}
className="h-8 w-8 rounded-full bg-gray-50" className="rounded-full bg-gray-50"
width={30} width={24}
height={30} height={24}
alt="" alt=""
/> />
) : ( ) : (

View File

@@ -6,13 +6,13 @@ import { useModal } from "~/providers/modal";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
modalSize?: "sm" | "md" | "lg"; modalSize?: "sm" | "md" | "lg";
positionFromTop?: string; positionFromTop?: "sm" | "md" | "lg";
} }
const Modal: React.FC<Props> = ({ const Modal: React.FC<Props> = ({
children, children,
modalSize = "sm", modalSize = "sm",
positionFromTop = "25vh", positionFromTop = "md",
}) => { }) => {
const { isOpen, closeModal } = useModal(); const { isOpen, closeModal } = useModal();
@@ -22,6 +22,12 @@ const Modal: React.FC<Props> = ({
lg: "max-w-[800px]", lg: "max-w-[800px]",
}; };
const positionFromTopMap = {
sm: "mt-[12vh]",
md: "mt-[25vh]",
lg: "mt-[50vh]",
};
return ( return (
<Transition.Root show={isOpen} as={Fragment}> <Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
@@ -49,7 +55,7 @@ const Modal: React.FC<Props> = ({
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel <Dialog.Panel
className={`relative mt-[${positionFromTop}] w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`} className={`relative ${positionFromTopMap[positionFromTop]} w-full transform rounded-lg border border-light-600 bg-white/90 text-left shadow-3xl-light backdrop-blur-[6px] transition-all dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark ${modalSizeMap[modalSize]}`}
> >
{children} {children}
</Dialog.Panel> </Dialog.Panel>

View File

@@ -0,0 +1,7 @@
import createClient from "~/utils/supabase/client";
export const getPublicUrl = (fileName: string) => {
const supabase = createClient();
return supabase.storage.from("avatars").getPublicUrl(fileName).data.publicUrl;
};

View File

@@ -6,9 +6,11 @@ import {
} from "react-icons/hi2"; } from "react-icons/hi2";
import { IoFilterOutline } from "react-icons/io5"; import { IoFilterOutline } from "react-icons/io5";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown"; import CheckboxDropdown from "~/components/CheckboxDropdown";
import { formatToArray } from "~/utils/helpers"; import { formatToArray } from "~/utils/helpers";
import { getPublicUrl } from "~/utils/supabase/getPublicUrl";
interface BoardData { interface BoardData {
publicId: string; publicId: string;
@@ -20,6 +22,8 @@ interface BoardData {
publicId: string; publicId: string;
user: { user: {
name: string | null; name: string | null;
image: string | null;
email: string;
} | null; } | null;
}[]; }[];
} | null; } | null;
@@ -65,17 +69,6 @@ const LabelIcon = ({ colourCode }: { colourCode: string | null }) => (
</svg> </svg>
); );
const Avatar = ({ name }: { name: string }) => (
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500">
<span className="text-[8px] font-medium leading-none text-white">
{name
.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("")}
</span>
</span>
);
const Filters = ({ const Filters = ({
position = "right", position = "right",
boardData, boardData,
@@ -104,7 +97,16 @@ const Filters = ({
key: member.publicId, key: member.publicId,
value: member.user?.name ?? "", value: member.user?.name ?? "",
selected: !!router.query.members?.includes(member.publicId), selected: !!router.query.members?.includes(member.publicId),
leftIcon: <Avatar name={member.user?.name ?? ""} />, leftIcon: (
<Avatar
size="xs"
name={member.user?.name ?? ""}
imageUrl={
member.user?.image ? getPublicUrl(member.user.image) : undefined
}
email={member.user?.email ?? ""}
/>
),
})) ?? []; })) ?? [];
const formattedLabels = const formattedLabels =

View File

@@ -9,6 +9,7 @@ import { HiOutlinePlusSmall } from "react-icons/hi2";
import type { UpdateBoardInput } from "@kan/api/types"; import type { UpdateBoardInput } from "@kan/api/types";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -20,6 +21,8 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers"; import { formatToArray } from "~/utils/helpers";
import createClient from "~/utils/supabase/client";
import { getPublicUrl } from "~/utils/supabase/getPublicUrl";
import BoardDropdown from "./components/BoardDropdown"; import BoardDropdown from "./components/BoardDropdown";
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation"; import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
import { DeleteListConfirmation } from "./components/DeleteListConfirmation"; import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
@@ -33,6 +36,7 @@ import VisibilityButton from "./components/VisibilityButton";
type PublicListId = string; type PublicListId = string;
export default function BoardPage() { export default function BoardPage() {
const supabase = createClient();
const params = useParams() as { boardId: string[] } | null; const params = useParams() as { boardId: string[] } | null;
const router = useRouter(); const router = useRouter();
const { boardData, setBoardData, updateCard, updateList } = useBoard(); const { boardData, setBoardData, updateCard, updateList } = useBoard();
@@ -270,23 +274,26 @@ export default function BoardPage() {
</span> </span>
))} ))}
<div className="isolate flex -space-x-1 overflow-hidden"> <div className="isolate flex -space-x-1 overflow-hidden">
{card.members.map((member) => ( {card.members.map((member) => {
<span const fileName =
key={member.publicId} member.user?.image;
className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-light-900 ring-2 ring-light-50 dark:bg-gray-500 dark:ring-dark-500"
> const avatarUrl = fileName
<span className="text-[10px] font-medium leading-none text-white"> ? getPublicUrl(fileName)
{member.user?.name : undefined;
?.split(" ")
.map((namePart) => return (
namePart <Avatar
.charAt(0) key={member.publicId}
.toUpperCase(), name={member.user?.name ?? ""}
) email={
.join("")} member.user?.email ?? ""
</span> }
</span> imageUrl={avatarUrl}
))} size="sm"
/>
);
})}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -1,6 +1,7 @@
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2"; import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Avatar from "~/components/Avatar";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -9,6 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers"; import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
import { getPublicUrl } from "~/utils/supabase/getPublicUrl";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation"; import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import { InviteMemberForm } from "./components/InviteMemberForm"; import { InviteMemberForm } from "./components/InviteMemberForm";
@@ -44,6 +46,7 @@ export default function MembersPage() {
memberPublicId, memberPublicId,
memberName, memberName,
memberEmail, memberEmail,
memberImage,
memberRole, memberRole,
memberStatus, memberStatus,
isLastRow, isLastRow,
@@ -52,6 +55,7 @@ export default function MembersPage() {
memberPublicId?: string; memberPublicId?: string;
memberName?: string | null | undefined; memberName?: string | null | undefined;
memberEmail?: string | null | undefined; memberEmail?: string | null | undefined;
memberImage?: string | null | undefined;
memberRole?: string; memberRole?: string;
memberStatus?: string; memberStatus?: string;
isLastRow?: boolean; isLastRow?: boolean;
@@ -66,16 +70,15 @@ export default function MembersPage() {
<td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}> <td className={twMerge("w-[65%]", isLastRow ? "rounded-bl-lg" : "")}>
<div className="flex items-center p-4"> <div className="flex items-center p-4">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<span {showSkeleton ? (
className={twMerge( <div className="h-9 w-9 animate-pulse rounded-full bg-light-200 dark:bg-dark-200" />
"inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400", ) : (
showSkeleton && "animate-pulse bg-light-200 dark:bg-dark-200", <Avatar
)} name={memberName ?? ""}
> email={memberEmail ?? ""}
<span className="text-sm font-medium leading-none text-white"> imageUrl={memberImage ? getPublicUrl(memberImage) : undefined}
{initials} />
</span> )}
</span>
</div> </div>
<div className="ml-2 min-w-0 flex-1"> <div className="ml-2 min-w-0 flex-1">
<div> <div>
@@ -207,6 +210,7 @@ export default function MembersPage() {
memberPublicId={member.publicId} memberPublicId={member.publicId}
memberName={member.user?.name} memberName={member.user?.name}
memberEmail={member.user?.email} memberEmail={member.user?.email}
memberImage={member.user?.image}
memberRole={member.role} memberRole={member.role}
memberStatus={member.status} memberStatus={member.status}
isLastRow={index === data.members.length - 1} isLastRow={index === data.members.length - 1}

View File

@@ -201,7 +201,7 @@ export default function PublicBoardView() {
</div> </div>
</div> </div>
<Popup /> <Popup />
<Modal modalSize={"md"} positionFromTop={"12vh"}> <Modal modalSize={"md"} positionFromTop={"sm"}>
<CardModal <CardModal
cardPublicId={cardPublicId} cardPublicId={cardPublicId}
workspaceSlug={data?.workspace?.slug} workspaceSlug={data?.workspace?.slug}

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import createClient from "~/utils/supabase/client"; import createClient from "~/utils/supabase/client";
import { getPublicUrl } from "~/utils/supabase/getPublicUrl";
export default function Avatar({ export default function Avatar({
userId, userId,
@@ -35,9 +36,7 @@ export default function Avatar({
}, },
}); });
const avatarUrl = userImage const avatarUrl = userImage ? getPublicUrl(userImage) : undefined;
? supabase.storage.from("avatars").getPublicUrl(userImage).data.publicUrl
: null;
const uploadAvatar = async (event: React.ChangeEvent<HTMLInputElement>) => { const uploadAvatar = async (event: React.ChangeEvent<HTMLInputElement>) => {
try { try {

View File

@@ -37,7 +37,9 @@ export const getByPublicId = async (
members:workspace_members ( members:workspace_members (
publicId, publicId,
user!workspace_members_userId_user_id_fk ( user!workspace_members_userId_user_id_fk (
name name,
email,
image
) )
) )
), ),
@@ -65,7 +67,9 @@ export const getByPublicId = async (
members:workspace_members${filters.members.length > 0 ? "!inner" : ""} ( members:workspace_members${filters.members.length > 0 ? "!inner" : ""} (
publicId, publicId,
user!workspace_members_userId_user_id_fk ( user!workspace_members_userId_user_id_fk (
name name,
email,
image
) )
) )
) )

View File

@@ -95,7 +95,8 @@ export const getByPublicIdWithMembers = async (
user!workspace_members_userId_user_id_fk ( user!workspace_members_userId_user_id_fk (
id, id,
name, name,
email email,
image
) )
) )
`, `,