web: add support for setting user profile

This commit is contained in:
Abdullah Atta
2024-02-28 23:21:52 +05:00
committed by Abdullah Atta
parent fc31cc49f6
commit c28e81a2c8
9 changed files with 1703 additions and 389 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,7 @@
"platform": "^1.3.6",
"qclone": "^1.2.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.2",
"react-complex-tree": "^2.2.4",
"react-day-picker": "^8.9.1",
"react-dom": "18.2.0",
@@ -95,6 +96,7 @@
"@types/node-fetch": "^2.5.10",
"@types/platform": "^1.3.4",
"@types/react": "^18.2.39",
"@types/react-avatar-editor": "^13.0.2",
"@types/react-dom": "^18.2.17",
"@types/react-modal": "3.16.3",
"@types/tinycolor2": "^1.4.3",

View File

@@ -33,8 +33,13 @@ import { ConfirmDialogProps } from "../dialogs/confirm";
import { getFormattedDate } from "@notesnook/common";
import { downloadUpdate } from "../utils/updater";
import { ThemeMetadata } from "@notesnook/themes-server";
import { Color, Reminder, Tag } from "@notesnook/core";
import { AuthenticatorType } from "@notesnook/core/dist/api/user-manager";
import {
Color,
Profile,
Reminder,
Tag,
AuthenticatorType
} from "@notesnook/core";
import { createRoot } from "react-dom/client";
import { PasswordDialogProps } from "../dialogs/password-dialog";
@@ -465,6 +470,12 @@ export function showAttachmentsDialog() {
));
}
export function showEditProfileDialog(profile?: Profile) {
return showDialog("EditProfileDialog", (Dialog, perform) => (
<Dialog onClose={(res: boolean) => perform(res)} profile={profile} />
));
}
export function showSettings() {
return showDialog("SettingsDialog", (Dialog, perform) => (
<Dialog onClose={(res: boolean) => perform(res)} />

View File

@@ -35,7 +35,8 @@ import {
Login,
Circle,
Icon,
Reminders
Reminders,
User
} from "../icons";
import { AnimatedFlex } from "../animated";
import NavigationItem, { SortableNavigationItem } from "./navigation-item";
@@ -134,6 +135,7 @@ function NavigationMenu(props: NavigationMenuProps) {
const shortcuts = useAppStore((store) => store.shortcuts);
const refreshNavItems = useAppStore((store) => store.refreshNavItems);
const isLoggedIn = useUserStore((store) => store.isLoggedIn);
const profile = useUserStore((store) => store.profile);
const isMobile = useMobile();
const theme = useThemeStore((store) => store.colorScheme);
const toggleNightMode = useThemeStore((store) => store.toggleColorScheme);
@@ -463,8 +465,9 @@ function NavigationMenu(props: NavigationMenuProps) {
id={settings.id}
isTablet={isTablet}
key={settings.path}
title={settings.title}
icon={settings.icon}
title={profile?.fullName || settings.title}
icon={profile?.fullName ? User : settings.icon}
image={profile?.fullName ? profile?.profilePicture : undefined}
onClick={() => {
if (!isMobile && location === settings.path)
return toggleNavigationContainer();

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Button, Flex, FlexProps, Text } from "@theme-ui/components";
import { Button, Flex, FlexProps, Image, Text } from "@theme-ui/components";
import { useStore as useAppStore } from "../../stores/app-store";
import { Menu } from "../../hooks/use-menu";
import useMobile from "../../hooks/use-mobile";
@@ -29,7 +29,8 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
type NavigationItemProps = {
icon: Icon;
icon?: Icon;
image?: string;
color?: SchemeColors;
title: string;
isTablet?: boolean;
@@ -49,6 +50,7 @@ function NavigationItem(
) {
const {
icon: Icon,
image,
color,
title,
isLoading,
@@ -121,11 +123,15 @@ function NavigationItem(
if (onClick) onClick();
}}
>
<Icon
size={isTablet ? 16 : 15}
color={color || (selected ? "icon-selected" : "icon")}
rotate={isLoading}
/>
{image ? (
<Image src={image} sx={{ borderRadius: 50, size: 20 }} />
) : Icon ? (
<Icon
size={isTablet ? 16 : 15}
color={color || (selected ? "icon-selected" : "icon")}
rotate={isLoading}
/>
) : null}
{isShortcut && (
<Shortcut
size={8}

View File

@@ -0,0 +1,166 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Perform } from "../common/dialog-controller";
import Dialog from "../components/dialog";
import { Profile } from "@notesnook/core";
import Field from "../components/field";
import AvatarEditor from "react-avatar-editor";
import { Avatar, Button, Flex, Slider } from "@theme-ui/components";
import { User } from "../components/icons";
import { useRef, useState } from "react";
import { showFilePicker } from "../utils/file-picker";
import { db } from "../common/db";
import { useStore as useUserStore } from "../stores/user-store";
import { showToast } from "../utils/toast";
export type EditProfileDialogProps = {
onClose: Perform;
profile?: Profile;
};
export default function EditProfileDialog(props: EditProfileDialogProps) {
const { profile } = props;
const [profilePicture, setProfilePicture] = useState<
File | string | undefined
>(profile?.profilePicture);
const profileRef = useRef<AvatarEditor>(null);
const [fullName, setFullName] = useState<string | undefined>(
profile?.fullName
);
const [scale, setScale] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [clearProfilePicture, setClearProfilePicture] = useState(false);
return (
<Dialog
isOpen={true}
title={"Edit profile"}
description="Your profile data is stored 100% end-to-end encrypted."
onClose={() => props.onClose(false)}
positiveButton={{
loading: isLoading,
disabled: isLoading,
text: "Save",
onClick: async () => {
setIsLoading(true);
try {
await db.user.setProfile({
fullName,
profilePicture: profileRef.current
? profileRef.current
.getImageScaledToCanvas()
.toDataURL("image/jpeg", 1)
: clearProfilePicture
? undefined
: profile?.profilePicture
});
await useUserStore.getState().refreshUser();
showToast("success", "Profile updated!");
props.onClose(true);
} catch (e) {
console.error(e);
showToast("error", (e as Error).message);
} finally {
setIsLoading(false);
}
}
}}
width={400}
negativeButton={{ text: "Cancel", onClick: () => props.onClose(false) }}
>
<Flex sx={{ gap: 2, mt: 2 }}>
<Flex sx={{ flexDirection: "column" }}>
{profilePicture ? (
<AvatarEditor
ref={profileRef}
image={profilePicture}
width={150}
height={150}
border={0}
color={[255, 255, 255]}
borderRadius={100}
scale={scale}
style={{
width: 150,
height: 150
}}
/>
) : (
<Flex
variant="columnCenter"
sx={{
bg: "shade",
mr: 2,
size: 150,
borderRadius: 200,
alignSelf: "center"
}}
>
<User size={60} />
</Flex>
)}
<Flex sx={{ gap: 1, alignItems: "center", mt: 2 }}>
<Button
sx={{ flex: 1 }}
variant="secondary"
onClick={async () =>
setProfilePicture(
await showFilePicker({ acceptedFileTypes: "image/*" })
)
}
>
{profilePicture ? "Change" : "Set picture"}
</Button>
{profilePicture ? (
<Button
sx={{ flex: 1 }}
variant="secondary"
onClick={async () => {
if (profile?.profilePicture) setClearProfilePicture(true);
setProfilePicture(undefined);
setScale(1);
}}
>
{profile?.profilePicture ? "Clear" : "Reset"}
</Button>
) : null}
</Flex>
{profilePicture ? (
<Slider
max={5}
min={1}
step={0.1}
value={scale}
sx={{ color: "accent" }}
onChange={(e) => setScale(e.target.valueAsNumber)}
/>
) : null}
</Flex>
<Field
label="Full name"
maxLength={200}
value={fullName}
autoFocus
onChange={(e) => setFullName(e.target.value)}
/>
</Flex>
</Dialog>
);
}

View File

@@ -53,6 +53,7 @@ const EmailChangeDialog = React.lazy(() => import("./email-change-dialog"));
const AddTagsDialog = React.lazy(() => import("./add-tags-dialog"));
const ThemeDetailsDialog = React.lazy(() => import("./theme-details-dialog"));
const CreateColorDialog = React.lazy(() => import("./create-color-dialog"));
const EditProfileDialog = React.lazy(() => import("./edit-profile-dialog"));
export const Dialogs = {
AddNotebookDialog,
@@ -81,5 +82,6 @@ export const Dialogs = {
AddTagsDialog,
SettingsDialog,
ThemeDetailsDialog,
CreateColorDialog
CreateColorDialog,
EditProfileDialog
};

View File

@@ -17,17 +17,19 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Flex, Text } from "@theme-ui/components";
import { User } from "../../../components/icons";
import { Button, Flex, Image, Text } from "@theme-ui/components";
import { Edit, User } from "../../../components/icons";
import { useStore as useUserStore } from "../../../stores/user-store";
import { getObjectIdTimestamp } from "@notesnook/core/dist/utils/object-id";
import { getFormattedDate } from "@notesnook/common";
import { SUBSCRIPTION_STATUS } from "../../../common/constants";
import dayjs from "dayjs";
import { useMemo } from "react";
import { showEditProfileDialog } from "../../../common/dialog-controller";
export function UserProfile() {
const user = useUserStore((store) => store.user);
const profile = useUserStore((store) => store.profile);
const {
isTrial,
@@ -88,42 +90,82 @@ export function UserProfile() {
sx={{
borderRadius: "default",
alignItems: "center",
justifyContent: "space-between",
bg: "var(--background-secondary)",
p: 2,
mb: 4
}}
>
<Flex
variant="columnCenter"
sx={{
bg: "shade",
mr: 2,
size: 60,
borderRadius: 80
}}
>
<User size={30} />
</Flex>
<Flex sx={{ flexDirection: "column" }}>
<Text
variant="subBody"
<Flex sx={{ alignItems: "center" }}>
<Flex
variant="columnCenter"
sx={{
color: "accent"
bg: "shade",
mr: 2,
size: 60,
borderRadius: 80,
overflow: "hidden"
}}
>
{remainingDays > 0 && (isPro || isProCancelled)
? `PRO`
: remainingDays > 0 && isTrial
? "TRIAL"
: isBeta
? "BETA TESTER"
: "BASIC"}
</Text>
<Text variant={"title"}>{user.email}</Text>
<Text variant={"subBody"}>
Member since {getFormattedDate(getObjectIdTimestamp(user.id), "date")}
</Text>
{profile?.profilePicture ? (
<Image
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
src={profile.profilePicture}
/>
) : (
<User size={30} />
)}
</Flex>
<Flex sx={{ flexDirection: "column" }}>
<Text
variant="subBody"
sx={{
color: "accent"
}}
>
{remainingDays > 0 && (isPro || isProCancelled)
? `PRO`
: remainingDays > 0 && isTrial
? "TRIAL"
: isBeta
? "BETA TESTER"
: "BASIC"}
</Text>
{profile?.fullName ? (
<>
<Text variant={"title"}>{profile?.fullName}</Text>
<Text variant={"subBody"}>
{user.email} Member since{" "}
{getFormattedDate(getObjectIdTimestamp(user.id), "date")}
</Text>
</>
) : (
<>
<Text variant={"title"}>{user.email}</Text>
<Text variant={"subBody"}>
Member since
{getFormattedDate(getObjectIdTimestamp(user.id), "date")}
</Text>
</>
)}
</Flex>
</Flex>
<Button
variant="icon"
sx={{
borderRadius: 50,
p: 0,
m: 0,
width: 30,
height: 30,
alignSelf: "end"
}}
title="Edit profile"
onClick={() => showEditProfileDialog(profile)}
>
<Edit size={18} />
</Button>
</Flex>
);
}

View File

@@ -31,7 +31,7 @@ import { hashNavigate } from "../navigation";
import { isUserPremium } from "../hooks/use-is-user-premium";
import { SUBSCRIPTION_STATUS } from "../common/constants";
import { ANALYTICS_EVENTS, trackEvent } from "../utils/analytics";
import { User } from "@notesnook/core/dist/api/user-manager";
import { AuthenticatorType, Profile, User } from "@notesnook/core";
class UserStore extends BaseStore<UserStore> {
isLoggedIn?: boolean;
@@ -39,6 +39,7 @@ class UserStore extends BaseStore<UserStore> {
isSigningIn = false;
user?: User = undefined;
profile?: Profile;
counter = 0;
init = () => {
@@ -47,32 +48,35 @@ class UserStore extends BaseStore<UserStore> {
window.location.replace("/sessionexpired");
});
db.user.getUser().then(async (user) => {
db.user.getUser().then((user) => {
if (!user) {
this.set((state) => {
state.isLoggedIn = false;
});
this.set({ isLoggedIn: false });
return;
}
this.set((state) => {
state.user = user;
state.isLoggedIn = true;
this.set({
user,
isLoggedIn: true
});
if (Config.get("sessionExpired")) EV.publish(EVENTS.userSessionExpired);
});
db.user.getProfile().then((profile) => this.set({ profile }));
if (Config.get("sessionExpired")) return;
return db.user.fetchUser().then(async (user) => {
if (!user) return false;
EV.remove(EVENTS.userSubscriptionUpdated, EVENTS.userEmailConfirmed);
const profile = await db.user.getProfile();
this.set((state) => {
state.user = user;
state.isLoggedIn = true;
this.set({
profile,
user,
isLoggedIn: true
});
EV.remove(EVENTS.userSubscriptionUpdated, EVENTS.userEmailConfirmed);
EV.subscribe(EVENTS.userSubscriptionUpdated, (subscription) => {
const wasUserPremium = isUserPremium();
this.set((state) => {
@@ -107,18 +111,29 @@ class UserStore extends BaseStore<UserStore> {
refreshUser = async () => {
return db.user.fetchUser().then(async (user) => {
this.set((state) => (state.user = user));
const profile = await db.user.getProfile();
this.set({ user, profile });
});
};
login = async (form, skipInit = false, sessionExpired = false) => {
login = async (
form:
| { email: string }
| { email: string; password: string }
| { code: string; method: AuthenticatorType },
skipInit = false,
sessionExpired = false
) => {
this.set((state) => (state.isLoggingIn = true));
const { email, password, code, method } = form;
try {
if (code) {
if ("email" in form && !("password" in form)) {
return await db.user.authenticateEmail(form.email);
} else if ("code" in form) {
const { code, method } = form;
return await db.user.authenticateMultiFactorCode(code, method);
} else if (password) {
} else if ("password" in form) {
const { email, password } = form;
await db.user.authenticatePassword(
email,
password,
@@ -129,15 +144,13 @@ class UserStore extends BaseStore<UserStore> {
if (skipInit) return true;
return this.init();
} else if (email) {
return await db.user.authenticateEmail(email);
}
} finally {
this.set((state) => (state.isLoggingIn = false));
}
};
signup = (form) => {
signup = (form: { email: string; password: string }) => {
this.set((state) => (state.isSigningIn = true));
return db.user
.signup(form.email.toLowerCase(), form.password)