Skip to content

Commit

Permalink
Account profile info editing (#53)
Browse files Browse the repository at this point in the history
* design: account details panel

* working profile edit panel

* logout all button + account details refinements
  • Loading branch information
hingobway authored Jun 20, 2024
1 parent dbe9da4 commit 9520aaa
Show file tree
Hide file tree
Showing 15 changed files with 421 additions and 98 deletions.
4 changes: 3 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@trpc/react-query": "11.0.0-rc.396",
"@trpc/server": "11.0.0-rc.396",
"dayjs": "^1.11.11",
"fast-deep-equal": "^3.1.3",
"gql.tada": "^1.7.5",
"graphql": "^16.8.1",
"graphql-request": "^7.0.1",
Expand All @@ -34,7 +35,8 @@
"react": "^18",
"react-dom": "^18",
"recoil": "^0.7.7",
"sst": "ion"
"sst": "ion",
"zod": "^3.23.8"
},
"devDependencies": {
"@0no-co/graphqlsp": "^1.12.5",
Expand Down
6 changes: 6 additions & 0 deletions client/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 0 additions & 17 deletions client/src/app/_components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,3 @@ const Shell = ({ children }: Children) => {
);
};
export default Shell;

// header={{ height: 60 }}
// <AppShell.Header className="bg-dgreen border-emerald-900">
// <Group h="100%" px="md">

// <IconTrees size={30} className="text-dwhite" />
// </Group>
// </AppShell.Header>

// <Burger
// opened={opened}
// onClick={toggle}
// hiddenFrom="sm"
// size="sm"
// color={opened ? 'white' : 'black'}
// className="fixed left-3 top-3 z-[1000]"
// />
15 changes: 2 additions & 13 deletions client/src/app/_components/nav/NavAccount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,15 @@ function NavAccountButton({
return (
<button
className="relative flex w-full flex-row items-center gap-2 overflow-hidden rounded-lg bg-emerald-700/80 px-4 py-3 hover:bg-emerald-700 data-[nu]:px-6"
// disabled={isLoading}
data-nu={/* !isLoading && */ !user || null}
data-nu={!user || null}
{...props}
>
{/* {!isLoading ? ( */}
<>
{/* button content with/without a user */}
{user ? (
<>
<IconUser size={20} />
<div className="flex-1 text-left">{user.name}</div>
<div className="flex-1 truncate text-left">{user.name}</div>

{isOpen ? <IconCircleChevronUp /> : <IconCircleChevronLeft />}
</>
Expand All @@ -107,15 +105,6 @@ function NavAccountButton({
</>
)}
</>
{/* ) : (
// skeleton
<>
<div className="size-4 animate-pulse rounded-full bg-emerald-600/50"></div>
<div className="h-4 w-2/3 flex-shrink animate-pulse rounded-full bg-emerald-600/50"></div>
<div className="flex-1"></div>
<div className="size-6 animate-pulse rounded-full bg-emerald-600/50"></div>
</>
)} */}
</button>
);
}
5 changes: 5 additions & 0 deletions client/src/app/_ctx/user/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { AUTH_EXPIRES_AFTER_DAYS } from '@/CONSTANTS';
import { CookieOpts } from '@/util/cookies';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';

const daysToSeconds = (a: number) => a * 60 * 60 * 24;
Expand All @@ -19,3 +20,7 @@ export async function logout() {
export async function removeStoredToken() {
logout();
}

export async function invalidateUser() {
await revalidatePath('/', 'layout');
}
15 changes: 13 additions & 2 deletions client/src/app/_ctx/user/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { createHash } from 'node:crypto';
import { cache } from 'react';
import { cookies } from 'next/headers';

import { type ResultOf } from '@graphql-typed-document-node/core';

import { type Children } from '@/util/propTypes';
import { type CookieOpts } from '@/util/cookies';
import { graph, graphql } from '@/query/graphql';

import { UserCtxProvider } from './context';
import { cache } from 'react';

const GET_USER_FROM_AUTH = graphql(`
query UserFromAuth {
Expand All @@ -21,7 +23,7 @@ const GET_USER_FROM_AUTH = graphql(`
`);
export type AuthUser = NonNullable<
ResultOf<typeof GET_USER_FROM_AUTH>['userFromAuth']
>;
> & { avatarUrl?: string };

export async function getUser() {
// get stored auth token
Expand Down Expand Up @@ -51,6 +53,15 @@ export async function UserProvider({ children }: Children) {
removeToken = true;
}

// insert avatar url
if (user) {
const hash = createHash('sha256')
.update(user.email.trim().toLowerCase())
.digest('hex');
(user as AuthUser).avatarUrl =
`https://gravatar.com/avatar/${hash}?d=mp&s=256`;
}

return (
<>
<UserCtxProvider user={user} removeToken={removeToken}>
Expand Down
210 changes: 195 additions & 15 deletions client/src/app/account/_components/AccountDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,207 @@
'use client';

import { useEffect, useTransition, type ReactNode } from 'react';

import { ActionIcon, Button, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { IconEdit, IconRestore } from '@tabler/icons-react';

import { invalidateUserData, useUserData } from '../_ctx/userData';
import { useUser } from '@/app/_ctx/user/context';
import Credentials from './Credentials';
import NewPasskey from './NewPasskey';
import InviteUser from './InviteUser';
import { graphAuth, graphError, graphql } from '@/query/graphql';
import { notifications } from '@mantine/notifications';
import { invalidateUser } from '@/app/_ctx/user/actions';
import fdeq from 'fast-deep-equal';
import { z } from 'zod';

const AccountDetails = () => {
const user = useUser();
const initialUser = useUser();

const formInit = {
name: initialUser?.name ?? '',
firstName: initialUser?.firstName ?? '',
email: initialUser?.email ?? '',
} satisfies Partial<typeof user>;

const form = useForm({
mode: 'controlled',
initialValues: formInit,
validate: zodResolver(
z.object(
Object.keys(formInit).reduce(
(o, k) => ({
...o,
[k]: z.string().min(1, { message: 'Field cannot be blank.' }),
}),
{},
),
),
),
transformValues: (v) => ({
name: v.name.trim(),
firstName: v.firstName.trim(),
email: v.email.trim(),
}),
});

// load dynamic user state and detect changes
const user = useUserData();
useEffect(() => {
if (!user) return;

const nu = Object.keys(user).reduce(
(o, k) =>
!(k in formInit)
? { ...o }
: { ...o, [k]: user[k as keyof typeof user] ?? '' },
{} as typeof formInit,
);
console.log('reval', user, nu);
form.setInitialValues(nu);
if (fdeq(nu, form.getValues())) form.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);

const [isLoading, loading] = useTransition();
function handleSubmit(values: typeof formInit) {
loading(async () => {
if (!user) return;
const f = await graphAuth(
graphql(`
mutation UserUpdate(
$id: ID!
$name: String
$firstName: String
$email: String
) {
userUpdate(
id: $id
name: $name
firstName: $firstName
email: $email
) {
id
}
}
`),
{
id: user.id,
...values,
},
).catch((err) => {
notifications.show({
color: 'red',
title: 'Error',
message: graphError((err as any).response),
});
return false;
});
if (!f) return;

//revalidate
invalidateUserData();
await invalidateUser();
});
}

return (
<>
<div className="container flex-1 rounded-lg bg-slate-100">
<h2 className="p-6 text-center text-2xl">
{`Welcome back${user?.firstName ? `, ${user.firstName}` : ''}!`}
</h2>

<div className="mx-auto flex max-w-screen-lg flex-col gap-4 p-6">
<Credentials />
<NewPasskey />
<hr className="t" />
<InviteUser />
<form onSubmit={form.onSubmit(handleSubmit)}>
<div className="relative flex flex-col gap-2 rounded-md border border-slate-200 p-4 shadow-sm">
{/* title */}
<div className="flex flex-row items-center justify-between">
<h3 className="text-lg">Edit your profile</h3>
<div className="flex flex-row items-center gap-2">
<ActionIcon
variant="subtle"
disabled={!form.isDirty()}
className="data-[disabled]:invisible"
onClick={() => form.reset()}
>
<IconRestore className="h-5" />
</ActionIcon>
<Button
type="submit"
size="compact-md"
disabled={!form.isDirty()}
loading={isLoading}
>
Save
</Button>
</div>
</div>

<div className=" flex flex-col gap-4 rounded-lg sm:flex-row sm:items-center">
{/* avatar view */}
<div className="relative flex flex-col items-center rounded-xl p-4 sm:max-w-56">
<a
href="https://gravatar.com/profile"
target="_blank"
rel="noopener noreferrer"
className="group relative -m-2 rounded-full border border-slate-500 border-opacity-0 p-2 text-slate-500 hover:border-opacity-100"
>
<div
className="size-36 rounded-full bg-slate-300 bg-cover bg-center"
style={{ backgroundImage: `url(${initialUser?.avatarUrl})` }}
/>
<IconEdit className="invisible absolute right-0 top-0 size-6 p-1 group-hover:visible" />
</a>
<div className="flex max-w-full flex-col items-center p-2 text-sm">
<div className="max-w-full truncate font-bold">
{initialUser?.name}
</div>
<div className="max-w-full truncate">{initialUser?.email}</div>
</div>
</div>
{/* text fields */}
<div className="flex flex-1 flex-col gap-2 p-2">
<TextInput
label="name"
placeholder="Enter name"
classNames={{
label: 'capitalize',
}}
{...form.getInputProps('name')}
/>
<TextInput
label="first name"
placeholder="Enter first name"
rightSectionWidth={80}
rightSection={
<Button
size="compact-xs"
variant="light"
classNames={{
root: 'mx-2 rounded-full uppercase',
}}
onClick={(e) => {
e.preventDefault();
const nv = form.getValues().name.trim().split(' ')?.[0];
if (nv) form.setValues({ firstName: nv });
}}
>
generate
</Button>
}
classNames={{
label: 'capitalize',
section: 'data-[position=right]:w-auto',
}}
{...form.getInputProps('firstName')}
/>
<TextInput
label="email"
placeholder="Enter email"
type="email"
classNames={{
label: 'capitalize',
}}
{...form.getInputProps('email')}
/>
</div>
</div>
</div>
</div>
</form>
</>
);
};
Expand Down
15 changes: 15 additions & 0 deletions client/src/app/account/_components/AccountTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import { useUser } from '@/app/_ctx/user/context';

export default function AccountTitle() {
const user = useUser();

return (
<>
<h2 className="p-6 text-center text-2xl">
{`Welcome back${user?.firstName ? `, ${user.firstName}` : ''}!`}
</h2>
</>
);
}
Loading

0 comments on commit 9520aaa

Please sign in to comment.