From 314c363e5ca69a50626650ade8968feec583e5ce Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Wed, 24 Dec 2025 12:18:08 +0200 Subject: feat: add support for user avatars (#2296) * feat: add support for user avatars * more fixes * more fixes * more fixes * more fixes --- .../components/dashboard/header/ProfileOptions.tsx | 23 +++- apps/web/components/settings/UserAvatar.tsx | 149 +++++++++++++++++++++ apps/web/components/ui/avatar.tsx | 49 +++++++ apps/web/components/ui/user-avatar.tsx | 52 +++++++ 4 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 apps/web/components/settings/UserAvatar.tsx create mode 100644 apps/web/components/ui/avatar.tsx create mode 100644 apps/web/components/ui/user-avatar.tsx (limited to 'apps/web/components') diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index 7ccc0078..5199bdec 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; import { useToggleTheme } from "@/components/theme-provider"; @@ -11,11 +12,14 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; import { useSession } from "next-auth/react"; import { useTheme } from "next-themes"; +import { useWhoAmI } from "@karakeep/shared-react/hooks/users"; + import { AdminNoticeBadge } from "../../admin/AdminNotices"; function DarkModeToggle() { @@ -43,7 +47,12 @@ export default function SidebarProfileOptions() { const { t } = useTranslation(); const toggleTheme = useToggleTheme(); const { data: session } = useSession(); + const { data: whoami } = useWhoAmI(); const router = useRouter(); + + const avatarImage = whoami?.image ?? null; + const avatarUrl = useMemo(() => avatarImage ?? null, [avatarImage]); + if (!session) return redirect("/"); return ( @@ -53,13 +62,21 @@ export default function SidebarProfileOptions() { className="border-new-gray-200 aspect-square rounded-full border-4 bg-black p-0 text-white" variant="ghost" > - {session.user.name?.charAt(0) ?? "U"} +
-
- {session.user.name?.charAt(0) ?? "U"} +
+

{session.user.name}

diff --git a/apps/web/components/settings/UserAvatar.tsx b/apps/web/components/settings/UserAvatar.tsx new file mode 100644 index 00000000..fd773697 --- /dev/null +++ b/apps/web/components/settings/UserAvatar.tsx @@ -0,0 +1,149 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import { useRef } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { UserAvatar as UserAvatarImage } from "@/components/ui/user-avatar"; +import useUpload from "@/lib/hooks/upload-file"; +import { useTranslation } from "@/lib/i18n/client"; +import { Image as ImageIcon, Upload, User, X } from "lucide-react"; + +import { + useUpdateUserAvatar, + useWhoAmI, +} from "@karakeep/shared-react/hooks/users"; + +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { toast } from "../ui/use-toast"; + +export default function UserAvatar() { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const whoami = useWhoAmI(); + const image = whoami.data?.image ?? null; + + const updateAvatar = useUpdateUserAvatar({ + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }); + + const upload = useUpload({ + onSuccess: async (resp) => { + try { + await updateAvatar.mutateAsync({ assetId: resp.assetId }); + toast({ + description: t("settings.info.avatar.updated"), + }); + } catch { + // handled in onError + } + }, + onError: (err) => { + toast({ + description: err.error, + variant: "destructive", + }); + }, + }); + + const isBusy = upload.isPending || updateAvatar.isPending; + + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + upload.mutate(file); + event.target.value = ""; + }; + + return ( + + + + + {t("settings.info.avatar.title")} + + + +

+ {t("settings.info.avatar.description")} +

+
+
+
+ } + className="h-full w-full" + /> +
+ + + + {image + ? t("settings.info.avatar.change") + : t("settings.info.avatar.upload")} + +
+ {t("settings.info.avatar.remove_confirm_description")}

+ } + actionButton={(setDialogOpen) => ( + + updateAvatar.mutate( + { assetId: null }, + { + onSuccess: () => { + toast({ + description: t("settings.info.avatar.removed"), + }); + setDialogOpen(false); + }, + }, + ) + } + > + {t("settings.info.avatar.remove")} + + )} + > + +
+
+
+
+ ); +} diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx new file mode 100644 index 00000000..7afec626 --- /dev/null +++ b/apps/web/components/ui/avatar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/web/components/ui/user-avatar.tsx b/apps/web/components/ui/user-avatar.tsx new file mode 100644 index 00000000..4ebb6ec3 --- /dev/null +++ b/apps/web/components/ui/user-avatar.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useMemo } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; + +interface UserAvatarProps { + image?: string | null; + name?: string | null; + className?: string; + imgClassName?: string; + fallbackClassName?: string; + fallback?: React.ReactNode; +} + +const isExternalUrl = (value: string) => + value.startsWith("http://") || value.startsWith("https://"); + +export function UserAvatar({ + image, + name, + className, + imgClassName, + fallbackClassName, + fallback, +}: UserAvatarProps) { + const avatarUrl = useMemo(() => { + if (!image) { + return null; + } + return isExternalUrl(image) ? image : getAssetUrl(image); + }, [image]); + + const fallbackContent = fallback ?? name?.charAt(0) ?? "U"; + + return ( + + {avatarUrl && ( + + )} + + {fallbackContent} + + + ); +} -- cgit v1.2.3-70-g09d2