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 --- apps/web/app/settings/info/page.tsx | 2 + .../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 +++++++ apps/web/lib/attachments.tsx | 2 + apps/web/lib/i18n/locales/en/translation.json | 11 ++ apps/web/lib/i18n/locales/en_US/translation.json | 11 ++ apps/web/package.json | 1 + apps/web/server/auth.ts | 1 - 10 files changed, 297 insertions(+), 4 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') diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx index b42c1a28..da9b2e51 100644 --- a/apps/web/app/settings/info/page.tsx +++ b/apps/web/app/settings/info/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { ChangePassword } from "@/components/settings/ChangePassword"; import { DeleteAccount } from "@/components/settings/DeleteAccount"; import ReaderSettings from "@/components/settings/ReaderSettings"; +import UserAvatar from "@/components/settings/UserAvatar"; import UserDetails from "@/components/settings/UserDetails"; import UserOptions from "@/components/settings/UserOptions"; import { useTranslation } from "@/lib/i18n/server"; @@ -17,6 +18,7 @@ export async function generateMetadata(): Promise { export default async function InfoPage() { return (
+ 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} + + + ); +} diff --git a/apps/web/lib/attachments.tsx b/apps/web/lib/attachments.tsx index 67941098..81b9f12d 100644 --- a/apps/web/lib/attachments.tsx +++ b/apps/web/lib/attachments.tsx @@ -4,6 +4,7 @@ import { FileCode, Image, Paperclip, + SquareUser, Upload, Video, } from "lucide-react"; @@ -20,5 +21,6 @@ export const ASSET_TYPE_TO_ICON: Record = { bookmarkAsset: , linkHtmlContent: , userUploaded: , + avatar: , unknown: , }; diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 672d3e58..a4b94e4b 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -120,6 +120,17 @@ "confirm_new_password": "Confirm New Password", "options": "Options", "interface_lang": "Interface Language", + "avatar": { + "title": "Profile Photo", + "description": "Upload a square image to use as your avatar.", + "upload": "Upload avatar", + "change": "Change avatar", + "remove": "Remove avatar", + "remove_confirm_title": "Remove avatar?", + "remove_confirm_description": "This will clear your current profile photo.", + "updated": "Avatar updated", + "removed": "Avatar removed" + }, "user_settings": { "user_settings_updated": "User settings have been updated!", "bookmark_click_action": { diff --git a/apps/web/lib/i18n/locales/en_US/translation.json b/apps/web/lib/i18n/locales/en_US/translation.json index 12af64e8..6c3dd62b 100644 --- a/apps/web/lib/i18n/locales/en_US/translation.json +++ b/apps/web/lib/i18n/locales/en_US/translation.json @@ -200,6 +200,17 @@ "confirm_new_password": "Confirm New Password", "options": "Options", "interface_lang": "Interface Language", + "avatar": { + "title": "Profile Photo", + "description": "Upload a square image to use as your avatar.", + "upload": "Upload avatar", + "change": "Change avatar", + "remove": "Remove avatar", + "remove_confirm_title": "Remove avatar?", + "remove_confirm_description": "This will clear your current profile photo.", + "updated": "Avatar updated", + "removed": "Avatar removed" + }, "user_settings": { "user_settings_updated": "User settings have been updated!", "bookmark_click_action": { diff --git a/apps/web/package.json b/apps/web/package.json index 37cbe940..5f8eff0d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@lexical/react": "^0.20.2", "@lexical/rich-text": "^0.20.2", "@marsidev/react-turnstile": "^1.3.1", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 833cf174..52c5e9b3 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -141,7 +141,6 @@ if (oauth.wellKnownUrl) { id: profile.sub, name: profile.name || profile.email, email: profile.email, - image: profile.picture, role: admin || firstUser ? "admin" : "user", }; }, -- cgit v1.2.3-70-g09d2