aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/settings
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/web/components/settings
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/web/components/settings')
-rw-r--r--apps/web/components/settings/UserAvatar.tsx149
1 files changed, 149 insertions, 0 deletions
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>
+ );
+}