Skip to content

Commit

Permalink
Rebuild Profile Page (#329)
Browse files Browse the repository at this point in the history
* refactor: unify avatar and personal info editing into 1 form

* feat: remove commented code

* feat: remove useless dev deps in api

* refactor: change css naming duration
  • Loading branch information
fruneen authored Dec 13, 2024
1 parent 1de733f commit 850f558
Show file tree
Hide file tree
Showing 30 changed files with 599 additions and 902 deletions.
3 changes: 1 addition & 2 deletions template/apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"@aws-sdk/lib-storage": "3.540.0",
"@aws-sdk/s3-request-presigner": "3.540.0",
"@koa/cors": "5.0.0",
"@koa/multer": "3.0.2",
"@koa/router": "13.0.0",
"@paralect/node-mongo": "3.2.0",
"@socket.io/redis-adapter": "8.1.0",
Expand All @@ -37,6 +36,7 @@
"ioredis": "5.3.2",
"jsonwebtoken": "9.0.0",
"koa": "2.14.1",
"koa-body": "6.0.1",
"koa-bodyparser": "4.3.0",
"koa-compose": "4.1.0",
"koa-helmet": "6.1.0",
Expand Down Expand Up @@ -70,7 +70,6 @@
"@types/koa-mount": "4.0.2",
"@types/koa-ratelimit": "5.0.0",
"@types/koa__cors": "3.3.1",
"@types/koa__multer": "2.0.4",
"@types/koa__router": "12.0.0",
"@types/lodash": "4.14.191",
"@types/module-alias": "2.0.1",
Expand Down
12 changes: 7 additions & 5 deletions template/apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import 'dotenv/config';

import cors from '@koa/cors';
import http from 'http';
import bodyParser from 'koa-bodyparser';
import { koaBody } from 'koa-body';

import helmet from 'koa-helmet';
import koaLogger from 'koa-logger';
import qs from 'koa-qs';
Expand All @@ -34,10 +35,11 @@ const initKoa = () => {
app.use(helmet());
qs(app);
app.use(
bodyParser({
enableTypes: ['json', 'form', 'text'],
onerror: (err: Error, ctx) => {
const errText: string = err.stack || err.toString();
koaBody({
multipart: true,
onError: (error, ctx) => {
const errText: string = error.stack || error.toString();

logger.warn(`Unable to parse request body. ${errText}`);
ctx.throw(422, 'Unable to parse request JSON.');
},
Expand Down
3 changes: 2 additions & 1 deletion template/apps/api/src/middlewares/validate.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const formatError = (zodError: ZodError): ValidationErrors => {

const validate = (schema: ZodSchema) => async (ctx: AppKoaContext, next: Next) => {
const result = await schema.safeParseAsync({
...(ctx.request.body as object),
...ctx.request.body,
...ctx.request.files,
...ctx.query,
...ctx.params,
});
Expand Down
4 changes: 1 addition & 3 deletions template/apps/api/src/resources/account/account.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@ import { routeUtil } from 'utils';
import forgotPassword from './actions/forgot-password';
import get from './actions/get';
import google from './actions/google';
import removeAvatar from './actions/remove-avatar';
import resendEmail from './actions/resend-email';
import resetPassword from './actions/reset-password';
import shadowLogin from './actions/shadow-login';
import signIn from './actions/sign-in';
import signOut from './actions/sign-out';
import signUp from './actions/sign-up';
import update from './actions/update';
import uploadAvatar from './actions/upload-avatar';
import verifyEmail from './actions/verify-email';
import verifyResetToken from './actions/verify-reset-token';

Expand All @@ -27,7 +25,7 @@ const publicRoutes = routeUtil.getRoutes([
google,
]);

const privateRoutes = routeUtil.getRoutes([get, update, uploadAvatar, removeAvatar]);
const privateRoutes = routeUtil.getRoutes([get, update]);

const adminRoutes = routeUtil.getRoutes([shadowLogin]);

Expand Down
25 changes: 25 additions & 0 deletions template/apps/api/src/resources/account/account.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { cloudStorageService } from 'services';

import { BackendFile, User } from 'types';

export const removeAvatar = async (user: User) => {
if (user.avatarUrl) {
const fileKey = cloudStorageService.getFileKey(user.avatarUrl);

await cloudStorageService.deleteObject(fileKey);
}
};

export const uploadAvatar = async (user: User, file: BackendFile, customFileName?: string): Promise<string> => {
await removeAvatar(user);

const fileName = customFileName || `${user._id}-${Date.now()}-${file.originalFilename}`;

const { location: avatarUrl } = await cloudStorageService.uploadPublic(`avatars/${fileName}`, file);

if (!avatarUrl) {
throw new Error("An error occurred while uploading the user's avatar");
}

return avatarUrl;
};
30 changes: 0 additions & 30 deletions template/apps/api/src/resources/account/actions/remove-avatar.ts

This file was deleted.

21 changes: 17 additions & 4 deletions template/apps/api/src/resources/account/actions/update.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import _ from 'lodash';

import { accountUtils } from 'resources/account';
import { userService } from 'resources/user';

import { validateMiddleware } from 'middlewares';
import { securityUtil } from 'utils';

import { updateUserSchema } from 'schemas';
import { AppKoaContext, AppRouter, Next, UpdateUserParams } from 'types';
import { AppKoaContext, AppRouter, Next, UpdateUserParamsBackend, User } from 'types';

interface ValidatedData extends UpdateUserParams {
interface ValidatedData extends UpdateUserParamsBackend {
passwordHash?: string | null;
}

Expand All @@ -32,11 +33,23 @@ async function validator(ctx: AppKoaContext<ValidatedData>, next: Next) {
}

async function handler(ctx: AppKoaContext<ValidatedData>) {
const { avatar } = ctx.validatedData;
const { user } = ctx.state;

const updatedUser = await userService.updateOne({ _id: user._id }, () => _.pickBy(ctx.validatedData));
const nonEmptyValues = _.pickBy(ctx.validatedData, (value) => !_.isUndefined(value));
const updateData: Partial<User> = _.omit(nonEmptyValues, 'avatar');

ctx.body = userService.getPublic(updatedUser);
if (avatar === '') {
await accountUtils.removeAvatar(user);

updateData.avatarUrl = null;
}

if (avatar && typeof avatar !== 'string') {
updateData.avatarUrl = await accountUtils.uploadAvatar(user, avatar);
}

ctx.body = await userService.updateOne({ _id: user._id }, () => updateData).then(userService.getPublic);
}

export default (router: AppRouter) => {
Expand Down
37 changes: 0 additions & 37 deletions template/apps/api/src/resources/account/actions/upload-avatar.ts

This file was deleted.

3 changes: 2 additions & 1 deletion template/apps/api/src/resources/account/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import accountRoutes from './account.routes';
import * as accountUtils from './account.utils';

export { accountRoutes };
export { accountRoutes, accountUtils };
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import {
import { PutObjectCommandInput } from '@aws-sdk/client-s3/dist-types/commands/PutObjectCommand';
import { Upload } from '@aws-sdk/lib-storage';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type { File } from '@koa/multer';
import fs from 'fs/promises';

import { caseUtil } from 'utils';

import config from 'config';

import { ToCamelCase } from 'types';
import { BackendFile, ToCamelCase } from 'types';

import * as helpers from './cloud-storage.helper';

Expand All @@ -26,19 +26,20 @@ const client = new S3Client({
region: 'us-east-1', // To successfully create a new bucket, this SDK requires the region to be us-east-1
endpoint: config.CLOUD_STORAGE_ENDPOINT,
credentials: {
accessKeyId: config.CLOUD_STORAGE_ACCESS_KEY_ID ?? '',
secretAccessKey: config.CLOUD_STORAGE_SECRET_ACCESS_KEY ?? '',
accessKeyId: config.CLOUD_STORAGE_ACCESS_KEY_ID as string,
secretAccessKey: config.CLOUD_STORAGE_SECRET_ACCESS_KEY as string,
},
});

const bucket = config.CLOUD_STORAGE_BUCKET;

type UploadOutput = ToCamelCase<CompleteMultipartUploadCommandOutput>;

const upload = async (fileName: string, file: File): Promise<UploadOutput> => {
const upload = async (fileName: string, file: BackendFile): Promise<UploadOutput> => {
const params: PutObjectCommandInput = {
Bucket: bucket,
ContentType: file.mimetype,
Body: file.buffer,
ContentType: file.mimetype as string,
Body: await fs.readFile(file.filepath as string),
Key: fileName,
ACL: 'private',
};
Expand All @@ -51,11 +52,11 @@ const upload = async (fileName: string, file: File): Promise<UploadOutput> => {
return multipartUpload.done().then((value) => caseUtil.toCamelCase<UploadOutput>(value));
};

const uploadPublic = async (fileName: string, file: File): Promise<UploadOutput> => {
const uploadPublic = async (fileName: string, file: BackendFile): Promise<UploadOutput> => {
const params: PutObjectCommandInput = {
Bucket: bucket,
ContentType: file.mimetype,
Body: file.buffer,
ContentType: file.mimetype as string,
Body: await fs.readFile(file.filepath as string),
Key: fileName,
ACL: 'public-read',
};
Expand Down
2 changes: 1 addition & 1 deletion template/apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
12 changes: 12 additions & 0 deletions template/apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,16 @@ module.exports = {
},
pageExtensions: ['page.tsx', 'api.ts'],
transpilePackages: ['app-constants', 'schemas', 'types'],
i18n: {
locales: ['en-US'],
defaultLocale: 'en-US',
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.digitaloceanspaces.com',
},
],
},
};
3 changes: 2 additions & 1 deletion template/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"dotenv-flow": "4.1.0",
"lodash": "4.17.21",
"mixpanel-browser": "2.53.0",
"next": "14.2.5",
"next": "14.2.10",
"object-to-formdata": "4.5.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "7.52.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ const MenuToggle = forwardRef<HTMLButtonElement>((props, ref) => {
if (!account) return null;

return (
<UnstyledButton ref={ref} {...props}>
<Avatar src={account.avatarUrl} color={primaryColor} radius="xl">
<UnstyledButton ref={ref} aria-label="Menu Toggle" {...props}>
<Avatar src={account.avatarUrl} color={primaryColor} radius="xl" alt="Avatar">
{account.firstName.charAt(0)}
{account.lastName.charAt(0)}
</Avatar>
</UnstyledButton>
);
});

MenuToggle.displayName = 'MenuToggle';

export default MenuToggle;
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
.root {
--dropzone-size: 100px;
--dropzone-hover-color: var(--mantine-color-gray-5);

padding: 0;
background-color: transparent;

border: none;
border-radius: 50%;

@mixin hover {
.addIcon {
color: var(--dropzone-hover-color);
}

.browseButton {
border-color: var(--dropzone-hover-color);
}

.editOverlay {
opacity: 1;
}
}
}

.browseButton {
width: var(--dropzone-size);
height: var(--dropzone-size);

border: 1px dashed var(--mantine-primary-color-filled);
border-radius: 50%;

cursor: pointer;
overflow: hidden;
transition: all 0.2s ease-in-out;
}

.imageExists {
border-color: transparent !important;
}

.addIcon {
color: var(--mantine-primary-color-filled);

transition: all 0.2s ease-in-out;
}

.pencilIcon {
color: var(--dropzone-hover-color);

transition: all 0.2s ease-in-out;
}

.editOverlay {
border-radius: 50%;
background-color: alpha(var(--mantine-color-black), 0.5);

opacity: 0;
transition: all 0.3s ease-in-out;
}
Loading

0 comments on commit 850f558

Please sign in to comment.