From 314c363e5ca69a50626650ade8968feec583e5ce Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
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/components/settings/UserAvatar.tsx | 149 ++++++++++++++++++++++++++++
 1 file changed, 149 insertions(+)
 create mode 100644 apps/web/components/settings/UserAvatar.tsx

(limited to 'apps/web/components/settings/UserAvatar.tsx')

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>
+  );
+}
-- 
cgit v1.2.3-70-g09d2

