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 - packages/db/schema.ts | 2 + packages/open-api/karakeep-openapi-spec.json | 7 + packages/shared-react/hooks/users.ts | 13 ++ packages/shared/types/bookmarks.ts | 1 + packages/shared/types/users.ts | 1 + packages/trpc/lib/attachments.ts | 5 + packages/trpc/models/users.ts | 102 +++++++++++++- packages/trpc/routers/users.test.ts | 75 +++++++++++ packages/trpc/routers/users.ts | 10 ++ pnpm-lock.yaml | 98 ++++++++++++++ 20 files changed, 610 insertions(+), 5 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 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", }; }, diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 278da072..2c2a997c 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -266,6 +266,7 @@ export const enum AssetTypes { LINK_HTML_CONTENT = "linkHtmlContent", BOOKMARK_ASSET = "bookmarkAsset", USER_UPLOADED = "userUploaded", + AVATAR = "avatar", BACKUP = "backup", UNKNOWN = "unknown", } @@ -286,6 +287,7 @@ export const assets = sqliteTable( AssetTypes.LINK_HTML_CONTENT, AssetTypes.BOOKMARK_ASSET, AssetTypes.USER_UPLOADED, + AssetTypes.AVATAR, AssetTypes.BACKUP, AssetTypes.UNKNOWN, ], diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json index 4532cd98..505cdfc2 100644 --- a/packages/open-api/karakeep-openapi-spec.json +++ b/packages/open-api/karakeep-openapi-spec.json @@ -325,6 +325,7 @@ "bookmarkAsset", "precrawledArchive", "userUploaded", + "avatar", "unknown" ] }, @@ -1747,6 +1748,7 @@ "bookmarkAsset", "precrawledArchive", "userUploaded", + "avatar", "unknown" ] } @@ -1782,6 +1784,7 @@ "bookmarkAsset", "precrawledArchive", "userUploaded", + "avatar", "unknown" ] }, @@ -3248,6 +3251,10 @@ "type": "string", "nullable": true }, + "image": { + "type": "string", + "nullable": true + }, "localUser": { "type": "boolean" } diff --git a/packages/shared-react/hooks/users.ts b/packages/shared-react/hooks/users.ts index eecde3f1..b1909761 100644 --- a/packages/shared-react/hooks/users.ts +++ b/packages/shared-react/hooks/users.ts @@ -13,6 +13,19 @@ export function useUpdateUserSettings( }); } +export function useUpdateUserAvatar( + ...opts: Parameters +) { + const apiUtils = api.useUtils(); + return api.users.updateAvatar.useMutation({ + ...opts[0], + onSuccess: (res, req, meta, context) => { + apiUtils.users.whoami.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta, context); + }, + }); +} + export function useDeleteAccount( ...opts: Parameters ) { diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index cbaa4574..8a294422 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -25,6 +25,7 @@ export const zAssetTypesSchema = z.enum([ "bookmarkAsset", "precrawledArchive", "userUploaded", + "avatar", "unknown", ]); export type ZAssetType = z.infer; diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index d4fff9a1..7338ee15 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -38,6 +38,7 @@ export const zWhoAmIResponseSchema = z.object({ id: z.string(), name: z.string().nullish(), email: z.string().nullish(), + image: z.string().nullish(), localUser: z.boolean(), }); diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts index 25d9be94..fb9e2079 100644 --- a/packages/trpc/lib/attachments.ts +++ b/packages/trpc/lib/attachments.ts @@ -17,6 +17,7 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType { [AssetTypes.LINK_HTML_CONTENT]: "linkHtmlContent", [AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset", [AssetTypes.USER_UPLOADED]: "userUploaded", + [AssetTypes.AVATAR]: "avatar", [AssetTypes.BACKUP]: "unknown", // Backups are not displayed as regular assets [AssetTypes.UNKNOWN]: "bannerImage", }; @@ -36,6 +37,7 @@ export function mapSchemaAssetTypeToDB( bookmarkAsset: AssetTypes.BOOKMARK_ASSET, linkHtmlContent: AssetTypes.LINK_HTML_CONTENT, userUploaded: AssetTypes.USER_UPLOADED, + avatar: AssetTypes.AVATAR, unknown: AssetTypes.UNKNOWN, }; return map[assetType]; @@ -52,6 +54,7 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) { bookmarkAsset: "Bookmark Asset", linkHtmlContent: "HTML Content", userUploaded: "User Uploaded File", + avatar: "Avatar", unknown: "Unknown", }; return map[type]; @@ -68,6 +71,7 @@ export function isAllowedToAttachAsset(type: ZAssetType) { bookmarkAsset: false, linkHtmlContent: false, userUploaded: true, + avatar: false, unknown: false, }; return map[type]; @@ -84,6 +88,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) { bookmarkAsset: false, linkHtmlContent: false, userUploaded: true, + avatar: false, unknown: false, }; return map[type]; diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts index 4c7272b1..0653349b 100644 --- a/packages/trpc/models/users.ts +++ b/packages/trpc/models/users.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { SqliteError } from "@karakeep/db"; import { assets, + AssetTypes, bookmarkLinks, bookmarkLists, bookmarks, @@ -17,7 +18,7 @@ import { users, verificationTokens, } from "@karakeep/db/schema"; -import { deleteUserAssets } from "@karakeep/shared/assetdb"; +import { deleteAsset, deleteUserAssets } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { zResetPasswordSchema, @@ -491,6 +492,104 @@ export class User { .where(eq(users.id, this.user.id)); } + async updateAvatar(assetId: string | null): Promise { + const previousImage = this.user.image ?? null; + const [asset, previousAsset] = await Promise.all([ + assetId + ? this.ctx.db.query.assets.findFirst({ + where: and(eq(assets.id, assetId), eq(assets.userId, this.user.id)), + columns: { + id: true, + bookmarkId: true, + contentType: true, + assetType: true, + }, + }) + : Promise.resolve(null), + previousImage && previousImage !== assetId + ? this.ctx.db.query.assets.findFirst({ + where: and( + eq(assets.id, previousImage), + eq(assets.userId, this.user.id), + ), + columns: { + id: true, + bookmarkId: true, + }, + }) + : Promise.resolve(null), + ]); + + if (assetId) { + if (!asset) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Avatar asset not found", + }); + } + + if (asset.bookmarkId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar asset must not be attached to a bookmark", + }); + } + + if (asset.contentType && !asset.contentType.startsWith("image/")) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar asset must be an image", + }); + } + + if ( + asset.assetType !== AssetTypes.AVATAR && + asset.assetType !== AssetTypes.UNKNOWN + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Avatar asset type is not supported", + }); + } + + if (asset.assetType !== AssetTypes.AVATAR) { + await this.ctx.db + .update(assets) + .set({ assetType: AssetTypes.AVATAR }) + .where(eq(assets.id, asset.id)); + } + } + if (previousImage === assetId) { + return; + } + + await this.ctx.db.transaction(async (tx) => { + await tx + .update(users) + .set({ image: assetId }) + .where(eq(users.id, this.user.id)); + + if (!previousImage || previousImage === assetId) { + return; + } + + if (previousAsset && !previousAsset.bookmarkId) { + await tx.delete(assets).where(eq(assets.id, previousAsset.id)); + } + }); + + this.user.image = assetId; + + if (!previousImage || previousImage === assetId) { + return; + } + + await deleteAsset({ + userId: this.user.id, + assetId: previousImage, + }).catch(() => ({})); + } + async getStats(): Promise> { const userObj = await this.ctx.db.query.users.findFirst({ where: eq(users.id, this.user.id), @@ -770,6 +869,7 @@ export class User { id: this.user.id, name: this.user.name, email: this.user.email, + image: this.user.image, localUser: this.user.password !== null, }; } diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index 27d6c2d6..21f05f5b 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -942,6 +942,81 @@ describe("User Routes", () => { }); }); + describe("Update Avatar", () => { + test("updateAvatar - promotes unknown asset", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Avatar Reject", + email: "avatar-reject@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id, user.email, user.role || "user"); + + await db.insert(assets).values({ + id: "avatar-asset-2", + assetType: AssetTypes.UNKNOWN, + userId: user.id, + contentType: "image/png", + size: 12, + fileName: "avatar.png", + bookmarkId: null, + }); + + await caller.users.updateAvatar({ assetId: "avatar-asset-2" }); + + const updatedAsset = await db + .select() + .from(assets) + .where(eq(assets.id, "avatar-asset-2")) + .then((rows) => rows[0]); + + expect(updatedAsset?.assetType).toBe(AssetTypes.AVATAR); + }); + + test("updateAvatar - deletes avatar asset", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Avatar Delete", + email: "avatar-delete@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id, user.email, user.role || "user"); + + await db.insert(assets).values({ + id: "avatar-asset-3", + assetType: AssetTypes.UNKNOWN, + userId: user.id, + contentType: "image/png", + size: 12, + fileName: "avatar.png", + bookmarkId: null, + }); + + await caller.users.updateAvatar({ assetId: "avatar-asset-3" }); + await caller.users.updateAvatar({ assetId: null }); + + const updatedUser = await db + .select() + .from(users) + .where(eq(users.id, user.id)) + .then((rows) => rows[0]); + const remainingAsset = await db + .select() + .from(assets) + .where(eq(assets.id, "avatar-asset-3")) + .then((rows) => rows[0]); + + expect(updatedUser?.image).toBeNull(); + expect(remainingAsset).toBeUndefined(); + }); + }); + describe("Who Am I", () => { test("whoami - returns user info", async ({ db, diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index d3bc06d9..dbfbbc3c 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -148,6 +148,16 @@ export const usersAppRouter = router({ const user = await User.fromCtx(ctx); await user.updateSettings(input); }), + updateAvatar: authedProcedure + .input( + z.object({ + assetId: z.string().nullable(), + }), + ) + .mutation(async ({ input, ctx }) => { + const user = await User.fromCtx(ctx); + await user.updateAvatar(input.assetId); + }), verifyEmail: publicProcedure .use( createRateLimitMiddleware({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index febde7ba..4a782e3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,6 +540,9 @@ importers: '@marsidev/react-turnstile': specifier: ^1.3.1 version: 1.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-collapsible': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -4391,6 +4394,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collapsible@1.1.11': resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==} peerDependencies: @@ -4435,6 +4451,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.14': resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==} peerDependencies: @@ -4605,6 +4630,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.7': resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} peerDependencies: @@ -4701,6 +4739,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.2.5': resolution: {integrity: sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==} peerDependencies: @@ -4802,6 +4849,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -19530,6 +19586,19 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -19570,6 +19639,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-context@1.1.3(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -19755,6 +19830,15 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) @@ -19870,6 +19954,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-slot@1.2.4(@types/react@19.1.8)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + '@radix-ui/react-switch@1.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -19980,6 +20071,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + use-sync-external-store: 1.5.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 -- cgit v1.2.3-70-g09d2