aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-24 12:18:08 +0200
committerGitHub <noreply@github.com>2025-12-24 10:18:08 +0000
commit314c363e5ca69a50626650ade8968feec583e5ce (patch)
tree2251691c2a79598b50b4417ee5632b602e5faf78 /apps
parent3408e6e4854dc79b963eef455e9a69231de3cd28 (diff)
downloadkarakeep-314c363e5ca69a50626650ade8968feec583e5ce.tar.zst
feat: add support for user avatars (#2296)
* feat: add support for user avatars * more fixes * more fixes * more fixes * more fixes
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/settings/info/page.tsx2
-rw-r--r--apps/web/components/dashboard/header/ProfileOptions.tsx23
-rw-r--r--apps/web/components/settings/UserAvatar.tsx149
-rw-r--r--apps/web/components/ui/avatar.tsx49
-rw-r--r--apps/web/components/ui/user-avatar.tsx52
-rw-r--r--apps/web/lib/attachments.tsx2
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json11
-rw-r--r--apps/web/lib/i18n/locales/en_US/translation.json11
-rw-r--r--apps/web/package.json1
-rw-r--r--apps/web/server/auth.ts1
10 files changed, 297 insertions, 4 deletions
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<Metadata> {
export default async function InfoPage() {
return (
<div className="flex flex-col gap-4">
+ <UserAvatar />
<UserDetails />
<ChangePassword />
<UserOptions />
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"}
+ <UserAvatar
+ image={avatarUrl}
+ name={session.user.name}
+ className="h-full w-full rounded-full"
+ />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="mr-2 min-w-64 p-2">
<div className="flex gap-2">
- <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center rounded-full border-4 bg-black p-0 text-white">
- {session.user.name?.charAt(0) ?? "U"}
+ <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center overflow-hidden rounded-full border-4 bg-black p-0 text-white">
+ <UserAvatar
+ image={avatarUrl}
+ name={session.user.name}
+ className="h-full w-full"
+ />
</div>
<div className="flex flex-col">
<p>{session.user.name}</p>
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<HTMLInputElement>(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<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+ upload.mutate(file);
+ event.target.value = "";
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2 text-xl">
+ <ImageIcon className="h-5 w-5" />
+ {t("settings.info.avatar.title")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <p className="text-sm text-muted-foreground">
+ {t("settings.info.avatar.description")}
+ </p>
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+ <div className="flex items-center gap-4">
+ <div className="flex size-16 items-center justify-center overflow-hidden rounded-full border bg-muted">
+ <UserAvatarImage
+ image={image}
+ name={t("settings.info.avatar.title")}
+ fallback={<User className="h-7 w-7 text-muted-foreground" />}
+ className="h-full w-full"
+ />
+ </div>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept="image/*"
+ className="hidden"
+ onChange={handleFileChange}
+ />
+ <ActionButton
+ type="button"
+ variant="secondary"
+ onClick={handleSelectFile}
+ loading={upload.isPending}
+ disabled={isBusy}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ {image
+ ? t("settings.info.avatar.change")
+ : t("settings.info.avatar.upload")}
+ </ActionButton>
+ </div>
+ <ActionConfirmingDialog
+ title={t("settings.info.avatar.remove_confirm_title")}
+ description={
+ <p>{t("settings.info.avatar.remove_confirm_description")}</p>
+ }
+ actionButton={(setDialogOpen) => (
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={updateAvatar.isPending}
+ onClick={() =>
+ updateAvatar.mutate(
+ { assetId: null },
+ {
+ onSuccess: () => {
+ toast({
+ description: t("settings.info.avatar.removed"),
+ });
+ setDialogOpen(false);
+ },
+ },
+ )
+ }
+ >
+ {t("settings.info.avatar.remove")}
+ </ActionButton>
+ )}
+ >
+ <Button type="button" variant="outline" disabled={!image || isBusy}>
+ <X className="mr-2 h-4 w-4" />
+ {t("settings.info.avatar.remove")}
+ </Button>
+ </ActionConfirmingDialog>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
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<typeof AvatarPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Root
+ ref={ref}
+ className={cn(
+ "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
+ className,
+ )}
+ {...props}
+ />
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Image>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Image
+ ref={ref}
+ className={cn("aspect-square h-full w-full", className)}
+ {...props}
+ />
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Fallback
+ ref={ref}
+ className={cn(
+ "flex h-full w-full items-center justify-center rounded-full bg-muted",
+ className,
+ )}
+ {...props}
+ />
+));
+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 (
+ <Avatar className={className}>
+ {avatarUrl && (
+ <AvatarImage
+ src={avatarUrl}
+ alt={name ?? "User"}
+ className={cn("object-cover", imgClassName)}
+ />
+ )}
+ <AvatarFallback className={cn("text-sm font-medium", fallbackClassName)}>
+ {fallbackContent}
+ </AvatarFallback>
+ </Avatar>
+ );
+}
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<ZAssetType, React.ReactNode> = {
bookmarkAsset: <Paperclip className="size-4" />,
linkHtmlContent: <FileCode className="size-4" />,
userUploaded: <Upload className="size-4" />,
+ avatar: <SquareUser className="size-4" />,
unknown: <Paperclip className="size-4" />,
};
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",
};
},