mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
web: add support for setting user profile
This commit is contained in:
1723
apps/web/package-lock.json
generated
1723
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -476,6 +481,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)} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
166
apps/web/src/dialogs/edit-profile-dialog.tsx
Normal file
166
apps/web/src/dialogs/edit-profile-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ const BackupPasswordDialog = React.lazy(
|
||||
() => import("./backup-password-dialog")
|
||||
);
|
||||
const CreateColorDialog = React.lazy(() => import("./create-color-dialog"));
|
||||
const EditProfileDialog = React.lazy(() => import("./edit-profile-dialog"));
|
||||
|
||||
export const Dialogs = {
|
||||
AddNotebookDialog,
|
||||
@@ -85,5 +86,6 @@ export const Dialogs = {
|
||||
SettingsDialog,
|
||||
ThemeDetailsDialog,
|
||||
BackupPasswordDialog,
|
||||
CreateColorDialog
|
||||
CreateColorDialog,
|
||||
EditProfileDialog
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user