diff options
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/app/settings/info/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/header/ProfileOptions.tsx | 23 | ||||
| -rw-r--r-- | apps/web/components/settings/UserAvatar.tsx | 149 | ||||
| -rw-r--r-- | apps/web/components/ui/avatar.tsx | 49 | ||||
| -rw-r--r-- | apps/web/components/ui/user-avatar.tsx | 52 | ||||
| -rw-r--r-- | apps/web/lib/attachments.tsx | 2 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 11 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en_US/translation.json | 11 | ||||
| -rw-r--r-- | apps/web/package.json | 1 | ||||
| -rw-r--r-- | apps/web/server/auth.ts | 1 |
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", }; }, |
