mirror of
https://github.com/unslothai/unsloth
synced 2026-04-21 13:37:39 +00:00
Studio: Local profile customization in settings and sync sidebar identity (#5088)
* studio: add local profile customization in settings * studio: add local profile settings and sync sidebar identity * fix: adjust profile card margin * fix: move helper modules to utils and use single-letter avatar fallback * fix: keep profile icon visible on sidebar collapse * fix: sidebar account trigger labeling and profile reset prefs
This commit is contained in:
parent
9954781d30
commit
9c8a079d97
14 changed files with 382 additions and 10 deletions
|
|
@ -55,6 +55,7 @@ import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
|
|||
import { motion } from "motion/react";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
import {
|
||||
|
|
@ -185,6 +186,7 @@ export function AppSidebar() {
|
|||
const effectiveRunsOpen = isStudioRoute || runsOpen;
|
||||
|
||||
const isRecipesRoute = pathname.startsWith("/data-recipes");
|
||||
const { displayTitle, avatarDataUrl } = useEffectiveProfile();
|
||||
|
||||
const { items: chatItems } = useChatSidebarItems();
|
||||
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
||||
|
|
@ -495,22 +497,26 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border">
|
||||
<SidebarFooter className="border-t border-sidebar-border group-data-[collapsible=icon]:border-t-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
aria-label={`${displayTitle} account menu`}
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:overflow-visible group-data-[collapsible=icon]:hover:bg-transparent group-data-[collapsible=icon]:data-[state=open]:bg-transparent"
|
||||
>
|
||||
<img
|
||||
src="/Sloth emojis/sloth rounded.png"
|
||||
alt="Unsloth"
|
||||
className="size-8 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="shrink-0">
|
||||
<UserAvatar
|
||||
name={displayTitle}
|
||||
imageUrl={avatarDataUrl}
|
||||
size="sm"
|
||||
className="!size-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">Unsloth</span>
|
||||
<span className="truncate text-sm font-semibold">{displayTitle}</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">Train</span>
|
||||
</div>
|
||||
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export { LoginPage } from "./login-page";
|
|||
export { ChangePasswordPage } from "./change-password-page";
|
||||
export { authFetch, refreshSession } from "./api";
|
||||
export {
|
||||
getAuthToken,
|
||||
getPostAuthRoute,
|
||||
hasAuthToken,
|
||||
hasRefreshToken,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { toastError, toastSuccess } from "@/shared/toast";
|
||||
import { Camera } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { decodeJwtSubject } from "../utils/jwt-subject";
|
||||
import { resizeImageFileToDataUrl } from "../utils/resize-image-file";
|
||||
import { useUserProfileStore } from "../stores/user-profile-store";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
|
||||
const PROFILE_STORAGE_KEY = "unsloth_user_profile";
|
||||
|
||||
function readPersistedProfile(): { displayName: string; avatarDataUrl: string | null } | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PROFILE_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
|
||||
// Zustand persist shape: { state: {...}, version }
|
||||
const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed;
|
||||
if (!maybeState || typeof maybeState !== "object") return null;
|
||||
const state = maybeState as { displayName?: unknown; avatarDataUrl?: unknown };
|
||||
|
||||
return {
|
||||
displayName: typeof state.displayName === "string" ? state.displayName : "",
|
||||
avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfilePersonalizationPanel() {
|
||||
const displayName = useUserProfileStore((s) => s.displayName);
|
||||
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
|
||||
const setDisplayName = useUserProfileStore((s) => s.setDisplayName);
|
||||
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
|
||||
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [draftName, setDraftName] = useState(displayName);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sessionSub = decodeJwtSubject(getAuthToken()) ?? "";
|
||||
const previewName = draftName.trim() || sessionSub || "Unsloth";
|
||||
const hasNameChanges = useMemo(
|
||||
() => draftName.trim() !== displayName.trim(),
|
||||
[draftName, displayName],
|
||||
);
|
||||
|
||||
const saveName = () => {
|
||||
const trimmed = draftName.trim();
|
||||
if (trimmed !== draftName) setDraftName(trimmed);
|
||||
if (trimmed !== displayName) {
|
||||
setDisplayName(trimmed);
|
||||
const persisted = readPersistedProfile();
|
||||
if (persisted && persisted.displayName === trimmed) {
|
||||
toastSuccess("Profile name saved");
|
||||
} else {
|
||||
toastError(
|
||||
"Could not persist profile name",
|
||||
"Name updated for this session, but may not persist after reload.",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onPickFile = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setImageError(null);
|
||||
try {
|
||||
const dataUrl = await resizeImageFileToDataUrl(file);
|
||||
setAvatarDataUrl(dataUrl);
|
||||
const persisted = readPersistedProfile();
|
||||
if (persisted && persisted.avatarDataUrl === dataUrl) {
|
||||
toastSuccess("Profile photo updated");
|
||||
} else {
|
||||
toastError(
|
||||
"Could not persist profile photo",
|
||||
"Photo updated for this session, but may not persist after reload.",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Could not use this image.";
|
||||
setImageError(message);
|
||||
toastError("Could not update profile photo", message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
|
||||
<div className="relative">
|
||||
<UserAvatar
|
||||
name={previewName}
|
||||
imageUrl={avatarDataUrl}
|
||||
size="lg"
|
||||
className="size-[124px] text-[3.15rem]"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void onPickFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label="Change profile picture"
|
||||
>
|
||||
<Camera className="size-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-[560px] flex-col gap-2">
|
||||
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
|
||||
Display name
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="profile-display-name"
|
||||
type="text"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
placeholder={sessionSub || "Unsloth"}
|
||||
className="h-10 min-w-0 flex-1 rounded-lg text-sm"
|
||||
/>
|
||||
<Button type="button" size="sm" className="h-10 px-5" onClick={saveName} disabled={!hasNameChanges}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{imageError ? (
|
||||
<p className="w-full text-xs text-destructive" role="alert">
|
||||
{imageError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { avatarBgStyle, initialsFromName } from "../utils/avatar-initials";
|
||||
|
||||
type UserAvatarProps = {
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
size: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SIZE: Record<"sm" | "md" | "lg", string> = {
|
||||
sm: "size-9 text-xs",
|
||||
md: "size-11 text-sm",
|
||||
/** ~10% larger than `size-24` / `text-2xl` for the edit-profile dialog. */
|
||||
lg: "size-[106px] text-[1.65rem]",
|
||||
};
|
||||
|
||||
export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps) {
|
||||
const label = initialsFromName(name);
|
||||
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<span className={cn("relative inline-flex shrink-0 overflow-hidden rounded-full", SIZE[size], className)}>
|
||||
<img src={imageUrl} alt="" className="size-full object-cover" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
style={avatarBgStyle()}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white",
|
||||
SIZE[size],
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { decodeJwtSubject } from "../utils/jwt-subject";
|
||||
import { useUserProfileStore } from "../stores/user-profile-store";
|
||||
|
||||
export function useEffectiveProfile() {
|
||||
const displayName = useUserProfileStore((s) => s.displayName);
|
||||
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
|
||||
|
||||
const sessionSub = decodeJwtSubject(getAuthToken());
|
||||
const dn = displayName.trim();
|
||||
return {
|
||||
sessionSub,
|
||||
displayTitle: dn || "Unsloth",
|
||||
avatarDataUrl,
|
||||
};
|
||||
}
|
||||
6
studio/frontend/src/features/profile/index.ts
Normal file
6
studio/frontend/src/features/profile/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { ProfilePersonalizationPanel } from "./components/profile-personalization-panel";
|
||||
export { UserAvatar } from "./components/user-avatar";
|
||||
export { useEffectiveProfile } from "./hooks/use-effective-profile";
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface UserProfileState {
|
||||
displayName: string;
|
||||
avatarDataUrl: string | null;
|
||||
setDisplayName: (displayName: string) => void;
|
||||
setAvatarDataUrl: (avatarDataUrl: string | null) => void;
|
||||
}
|
||||
|
||||
export const useUserProfileStore = create<UserProfileState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
displayName: "",
|
||||
avatarDataUrl: null,
|
||||
setDisplayName: (displayName) => set({ displayName }),
|
||||
setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }),
|
||||
}),
|
||||
{ name: "unsloth_user_profile" },
|
||||
),
|
||||
);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export function initialsFromName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "?";
|
||||
return trimmed[0]!.toUpperCase();
|
||||
}
|
||||
|
||||
/** Default blue background for avatar fallback (readable white text). */
|
||||
export function avatarBgStyle(): { backgroundColor: string } {
|
||||
return { backgroundColor: "hsl(217 58% 48%)" };
|
||||
}
|
||||
21
studio/frontend/src/features/profile/utils/jwt-subject.ts
Normal file
21
studio/frontend/src/features/profile/utils/jwt-subject.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
/**
|
||||
* Read the JWT `sub` claim for display purposes only (not verified).
|
||||
*/
|
||||
export function decodeJwtSubject(token: string | null): string | null {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1];
|
||||
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const json = atob(padded);
|
||||
const parsed = JSON.parse(json) as { sub?: unknown };
|
||||
return typeof parsed.sub === "string" ? parsed.sub : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
const MAX_EDGE = 256;
|
||||
const MAX_BYTES = 380_000;
|
||||
|
||||
function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Could not load image"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale and re-encode as JPEG so localStorage stays within reasonable size.
|
||||
*/
|
||||
export async function resizeImageFileToDataUrl(file: File): Promise<string> {
|
||||
const img = await loadImage(file);
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
if (!w || !h) throw new Error("Invalid image dimensions");
|
||||
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale));
|
||||
const ch = Math.max(1, Math.round(h * scale));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not available");
|
||||
ctx.drawImage(img, 0, 0, cw, ch);
|
||||
|
||||
let quality = 0.88;
|
||||
let dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||
while (dataUrl.length > MAX_BYTES * 1.35 && quality > 0.45) {
|
||||
quality -= 0.08;
|
||||
dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||
}
|
||||
if (dataUrl.length > MAX_BYTES * 1.35) {
|
||||
throw new Error("Image is still too large after compression. Try a smaller file.");
|
||||
}
|
||||
return dataUrl;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
PaintBrush02Icon,
|
||||
Settings02Icon,
|
||||
SparklesIcon,
|
||||
UserIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
|
|
@ -24,6 +25,7 @@ import { ApiKeysTab } from "./tabs/api-keys-tab";
|
|||
import { AppearanceTab } from "./tabs/appearance-tab";
|
||||
import { ChatTab } from "./tabs/chat-tab";
|
||||
import { GeneralTab } from "./tabs/general-tab";
|
||||
import { ProfileTab } from "./tabs/profile-tab";
|
||||
|
||||
interface TabDef {
|
||||
id: SettingsTab;
|
||||
|
|
@ -33,6 +35,7 @@ interface TabDef {
|
|||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "general", label: "General", icon: Settings02Icon },
|
||||
{ id: "profile", label: "Profile", icon: UserIcon },
|
||||
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
|
||||
{ id: "chat", label: "Chat", icon: Message01Icon },
|
||||
{ id: "api-keys", label: "API Keys", icon: Key01Icon },
|
||||
|
|
@ -43,6 +46,8 @@ function renderTab(tab: SettingsTab) {
|
|||
switch (tab) {
|
||||
case "general":
|
||||
return <GeneralTab />;
|
||||
case "profile":
|
||||
return <ProfileTab />;
|
||||
case "appearance":
|
||||
return <AppearanceTab />;
|
||||
case "chat":
|
||||
|
|
@ -131,7 +136,7 @@ export function SettingsDialog() {
|
|||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6 pr-12">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6">
|
||||
{renderTab(activeTab)}
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { create } from "zustand";
|
|||
|
||||
export type SettingsTab =
|
||||
| "general"
|
||||
| "profile"
|
||||
| "appearance"
|
||||
| "chat"
|
||||
| "api-keys"
|
||||
|
|
@ -28,7 +29,7 @@ function loadInitialTab(): SettingsTab {
|
|||
} catch {
|
||||
return "general";
|
||||
}
|
||||
const valid: SettingsTab[] = ["general", "appearance", "chat", "api-keys", "about"];
|
||||
const valid: SettingsTab[] = ["general", "profile", "appearance", "chat", "api-keys", "about"];
|
||||
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ const PREFS_KEYS: string[] = [
|
|||
"unsloth_training_config_v1",
|
||||
"unsloth_prev_max_steps",
|
||||
"unsloth_prev_save_steps",
|
||||
// Profile personalization
|
||||
"unsloth_user_profile",
|
||||
// Guided tour flags
|
||||
"tour:studio:v1",
|
||||
];
|
||||
|
|
|
|||
19
studio/frontend/src/features/settings/tabs/profile-tab.tsx
Normal file
19
studio/frontend/src/features/settings/tabs/profile-tab.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { ProfilePersonalizationPanel } from "@/features/profile";
|
||||
|
||||
export function ProfileTab() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Profile</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Update how your profile appears in Studio.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ProfilePersonalizationPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue