Feat: create user page, add user bio and avatar

This commit is contained in:
Aaron William Po
2023-11-05 21:54:09 -05:00
parent fcc43c305c
commit 7f9ddb40a1
26 changed files with 324 additions and 79 deletions

View File

@@ -5,7 +5,7 @@ import { Dispatch, FC, SetStateAction, useContext } from 'react';
import { Rating } from 'react-daisyui'; import { Rating } from 'react-daisyui';
import Link from 'next/link'; import Link from 'next/link';
import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult'; import CommentQueryResult from '@/services/schema/CommentSchema/CommentQueryResult';
import Image from 'next/image';
import { z } from 'zod'; import { z } from 'zod';
import CommentCardDropdown from './CommentCardDropdown'; import CommentCardDropdown from './CommentCardDropdown';
@@ -20,6 +20,24 @@ const CommentContentBody: FC<CommentContentBodyProps> = ({ comment, setInEditMod
return ( return (
<div className="card-body animate-in fade-in-10"> <div className="card-body animate-in fade-in-10">
<div className="flex space-x-5">
<div className="w-2/12 avatar display flex items-center justify-center">
<div className="w-28 h-28 rounded-full ring ring-primary">
{comment.postedBy.userAvatar ? (
<Image
src={comment.postedBy.userAvatar.path}
alt={comment.postedBy.userAvatar.alt}
width={200}
height={200}
/>
) : (
<div className="flex items-center justify-center w-full h-full text-2xl font-bold text-white bg-primary rounded-full">
{comment.postedBy.username[0]}
</div>
)}
</div>
</div>
<div className="w-10/12 space-y-2">
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<div> <div>
<p className="font-semibold sm:text-2xl"> <p className="font-semibold sm:text-2xl">
@@ -39,9 +57,10 @@ const CommentContentBody: FC<CommentContentBodyProps> = ({ comment, setInEditMod
</span> </span>
</div> </div>
{user && <CommentCardDropdown comment={comment} setInEditMode={setInEditMode} />} {user && (
<CommentCardDropdown comment={comment} setInEditMode={setInEditMode} />
)}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Rating value={comment.rating}> <Rating value={comment.rating}>
{Array.from({ length: 5 }).map((val, index) => ( {Array.from({ length: 5 }).map((val, index) => (
@@ -54,9 +73,13 @@ const CommentContentBody: FC<CommentContentBodyProps> = ({ comment, setInEditMod
/> />
))} ))}
</Rating> </Rating>
</div>
<div>
<p>{comment.content}</p> <p>{comment.content}</p>
</div> </div>
</div> </div>
</div>
</div>
); );
}; };

View File

@@ -20,6 +20,7 @@ const getBeerPosts = async (
const pageSize = parseInt(req.query.page_size, 10); const pageSize = parseInt(req.query.page_size, 10);
const beerPosts = await getAllBeerPosts({ pageNum, pageSize }); const beerPosts = await getAllBeerPosts({ pageNum, pageSize });
const beerPostCount = await DBClient.instance.beerPost.count(); const beerPostCount = await DBClient.instance.beerPost.count();
res.setHeader('X-Total-Count', beerPostCount); res.setHeader('X-Total-Count', beerPostCount);

View File

@@ -33,7 +33,7 @@ const createBreweryPost = async (
const [latitude, longitude] = geocoded.center; const [latitude, longitude] = geocoded.center;
const location = await DBClient.instance.location.create({ const location = await DBClient.instance.breweryLocation.create({
data: { data: {
address, address,
city, city,

View File

@@ -1,33 +1,69 @@
import useMediaQuery from '@/hooks/utilities/useMediaQuery';
import useTimeDistance from '@/hooks/utilities/useTimeDistance'; import useTimeDistance from '@/hooks/utilities/useTimeDistance';
import findUserById from '@/services/User/findUserById'; import findUserById from '@/services/User/findUserById';
import GetUserSchema from '@/services/User/schema/GetUserSchema'; import GetUserSchema from '@/services/User/schema/GetUserSchema';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import Head from 'next/head';
import { FC } from 'react'; import { FC } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import Image from 'next/image';
interface UserInfoPageProps { interface UserInfoPageProps {
user: z.infer<typeof GetUserSchema>; user: z.infer<typeof GetUserSchema>;
} }
const UserInfoPage: FC<UserInfoPageProps> = ({ user }) => { const UserHeader: FC<{ user: z.infer<typeof GetUserSchema> }> = ({ user }) => {
const timeDistance = useTimeDistance(new Date(user.createdAt)); const timeDistance = useTimeDistance(new Date(user.createdAt));
return ( return (
<article className="card flex flex-col justify-center bg-base-300">
<div className="card-body">
<header className="flex justify-between">
<div className="space-y-2">
<div> <div>
<h1> <h1 className="text-2xl font-bold lg:text-4xl">
{user.firstName} {user.lastName} {user.firstName} {user.lastName}
</h1> </h1>
<h2 className="italic">
<h3 className="italic">
joined{' '} joined{' '}
<time {timeDistance && (
<span
className="tooltip tooltip-bottom" className="tooltip tooltip-bottom"
data-tip={format(new Date(user.createdAt), 'MM/dd/yyyy')} data-tip={format(new Date(user.createdAt), 'MM/dd/yyyy')}
> >
{timeDistance} {`${timeDistance} ago`}
</time>{' '} </span>
ago )}
</h2> </h3>
</div> </div>
</div>
</header>
</div>
</article>
);
};
const UserInfoPage: FC<UserInfoPageProps> = ({ user }) => {
const isDesktop = useMediaQuery('(min-width: 1024px)');
return (
<>
<Head>
<title>{user ? `${user.firstName} ${user.lastName}` : 'User Info'}</title>
<meta name="description" content="User information" />
</Head>
<>
<main className="mb-12 mt-10 flex w-full items-center justify-center">
<Image src={user.userAvatar!.path} alt="avatar" width={200} height={200} />
<div className="w-11/12 space-y-3 xl:w-9/12 2xl:w-8/12">
<UserHeader user={user} />
{isDesktop ? <></> : <> </>}
</div>
</main>
</>
</>
); );
}; };

View File

@@ -0,0 +1,57 @@
/*
Warnings:
- You are about to drop the `Location` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "BreweryPost" DROP CONSTRAINT "BreweryPost_locationId_fkey";
-- DropForeignKey
ALTER TABLE "Location" DROP CONSTRAINT "Location_postedById_fkey";
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bio" TEXT;
-- DropTable
DROP TABLE "Location";
-- CreateTable
CREATE TABLE "UserAvatar" (
"id" TEXT NOT NULL,
"path" TEXT NOT NULL,
"alt" TEXT NOT NULL,
"caption" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3),
CONSTRAINT "UserAvatar_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BreweryLocation" (
"id" TEXT NOT NULL,
"city" TEXT NOT NULL,
"stateOrProvince" TEXT,
"country" TEXT,
"coordinates" DOUBLE PRECISION[],
"address" TEXT NOT NULL,
"postedById" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3),
CONSTRAINT "BreweryLocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserAvatar_userId_key" ON "UserAvatar"("userId");
-- AddForeignKey
ALTER TABLE "UserAvatar" ADD CONSTRAINT "UserAvatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BreweryLocation" ADD CONSTRAINT "BreweryLocation_postedById_fkey" FOREIGN KEY ("postedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BreweryPost" ADD CONSTRAINT "BreweryPost_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "BreweryLocation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -29,19 +29,32 @@ model User {
accountIsVerified Boolean @default(false) accountIsVerified Boolean @default(false)
dateOfBirth DateTime dateOfBirth DateTime
role Role @default(USER) role Role @default(USER)
bio String?
beerPosts BeerPost[] beerPosts BeerPost[]
beerStyles BeerStyle[] beerStyles BeerStyle[]
breweryPosts BreweryPost[] breweryPosts BreweryPost[]
beerComments BeerComment[] beerComments BeerComment[]
breweryComments BreweryComment[] breweryComments BreweryComment[]
BeerPostLikes BeerPostLike[] beerPostLikes BeerPostLike[]
BeerImage BeerImage[] beerImages BeerImage[]
BreweryImage BreweryImage[] breweryImages BreweryImage[]
BreweryPostLike BreweryPostLike[] breweryPostLikes BreweryPostLike[]
Location Location[] locations BreweryLocation[]
Glassware Glassware[] glasswares Glassware[]
BeerStyleLike BeerStyleLike[] beerStyleLikes BeerStyleLike[]
BeerStyleComment BeerStyleComment[] beerStyleComments BeerStyleComment[]
userAvatar UserAvatar?
}
model UserAvatar {
id String @id @default(cuid())
path String
alt String
caption String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
} }
model BeerPost { model BeerPost {
@@ -60,7 +73,7 @@ model BeerPost {
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
beerComments BeerComment[] beerComments BeerComment[]
beerImages BeerImage[] beerImages BeerImage[]
BeerPostLikes BeerPostLike[] beerPostLikes BeerPostLike[]
} }
model BeerPostLike { model BeerPostLike {
@@ -108,8 +121,8 @@ model BeerStyle {
abvRange Float[] abvRange Float[]
ibuRange Float[] ibuRange Float[]
beerPosts BeerPost[] beerPosts BeerPost[]
BeerStyleLike BeerStyleLike[] beerStyleLike BeerStyleLike[]
BeerStyleComment BeerStyleComment[] beerStyleComment BeerStyleComment[]
} }
model BeerStyleLike { model BeerStyleLike {
@@ -142,10 +155,10 @@ model Glassware {
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade) postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
postedById String postedById String
BeerStyle BeerStyle[] beerStyle BeerStyle[]
} }
model Location { model BreweryLocation {
id String @id @default(cuid()) id String @id @default(cuid())
city String city String
stateOrProvince String? stateOrProvince String?
@@ -154,7 +167,7 @@ model Location {
address String address String
postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade) postedBy User @relation(fields: [postedById], references: [id], onDelete: Cascade)
postedById String postedById String
BreweryPost BreweryPost? breweryPost BreweryPost?
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
} }
@@ -162,7 +175,7 @@ model Location {
model BreweryPost { model BreweryPost {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
location Location @relation(fields: [locationId], references: [id]) location BreweryLocation @relation(fields: [locationId], references: [id])
locationId String @unique locationId String @unique
beers BeerPost[] beers BeerPost[]
description String description String

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { hashPassword } from '../../../config/auth/passwordFns'; import { hashPassword } from '../../../config/auth/passwordFns';
import DBClient from '../../DBClient'; import DBClient from '../../DBClient';
import GetUserSchema from '../../../services/User/schema/GetUserSchema'; import GetUserSchema from '../../../services/User/schema/GetUserSchema';
import imageUrls from '../util/imageUrls';
const createAdminUser = async () => { const createAdminUser = async () => {
const hash = await hashPassword('Pas!3word'); const hash = await hashPassword('Pas!3word');
@@ -15,6 +16,14 @@ const createAdminUser = async () => {
dateOfBirth: new Date('1990-01-01'), dateOfBirth: new Date('1990-01-01'),
role: 'ADMIN', role: 'ADMIN',
hash, hash,
userAvatar: {
create: {
path: imageUrls[Math.floor(Math.random() * imageUrls.length)],
alt: 'Admin User',
caption: 'Admin User',
createdAt: new Date(),
},
},
}, },
select: { select: {
id: true, id: true,
@@ -27,6 +36,8 @@ const createAdminUser = async () => {
accountIsVerified: true, accountIsVerified: true,
updatedAt: true, updatedAt: true,
role: true, role: true,
bio: true,
userAvatar: true,
}, },
}); });

View File

@@ -1,13 +1,13 @@
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { Location, User } from '@prisma/client'; import { BreweryLocation, User } from '@prisma/client';
import DBClient from '../../DBClient'; import DBClient from '../../DBClient';
interface CreateNewBreweryPostsArgs { interface CreateNewBreweryPostsArgs {
numberOfPosts: number; numberOfPosts: number;
joinData: { joinData: {
users: User[]; users: User[];
locations: Location[]; locations: BreweryLocation[];
}; };
} }

View File

@@ -47,9 +47,9 @@ const createNewLocations = async ({
}); });
} }
await prisma.location.createMany({ data: locationData, skipDuplicates: true }); await prisma.breweryLocation.createMany({ data: locationData, skipDuplicates: true });
return prisma.location.findMany(); return prisma.breweryLocation.findMany();
}; };
export default createNewLocations; export default createNewLocations;

View File

@@ -0,0 +1,38 @@
import { User } from '@prisma/client';
import DBClient from '../../DBClient';
import imageUrls from '../util/imageUrls';
interface CreateNewUserAvatarsArgs {
joinData: { users: User[] };
}
interface UserAvatarData {
path: string;
alt: string;
caption: string;
userId: string;
createdAt: Date;
}
const createNewUserAvatars = async ({
joinData: { users },
}: CreateNewUserAvatarsArgs) => {
const userAvatars: UserAvatarData[] = [];
const path = imageUrls[Math.floor(Math.random() * imageUrls.length)];
users.forEach((user) => {
userAvatars.push({
path,
alt: `${user.firstName} ${user.lastName}`,
caption: `${user.firstName} ${user.lastName}`,
userId: user.id,
createdAt: new Date(),
});
});
await DBClient.instance.userAvatar.createMany({ data: userAvatars });
return DBClient.instance.userAvatar.findMany({
where: { user: { role: { not: 'ADMIN' } } },
});
};
export default createNewUserAvatars;

View File

@@ -71,7 +71,9 @@ const createNewUsers = async ({ numberOfUsers }: CreateNewUsersArgs) => {
} }
await prisma.user.createMany({ data, skipDuplicates: true }); await prisma.user.createMany({ data, skipDuplicates: true });
return prisma.user.findMany(); return prisma.user.findMany({
where: { role: { not: 'ADMIN' } },
});
}; };
export default createNewUsers; export default createNewUsers;

View File

@@ -18,6 +18,7 @@ import logger from '../../config/pino/logger';
import createAdminUser from './create/createAdminUser'; import createAdminUser from './create/createAdminUser';
import createNewBeerStyleComments from './create/createNewBeerStyleComments'; import createNewBeerStyleComments from './create/createNewBeerStyleComments';
import createNewBeerStyleLikes from './create/createNewBeerStyleLikes'; import createNewBeerStyleLikes from './create/createNewBeerStyleLikes';
import createNewUserAvatars from './create/createNewUserAvatars';
(async () => { (async () => {
try { try {
@@ -33,6 +34,9 @@ import createNewBeerStyleLikes from './create/createNewBeerStyleLikes';
const users = await createNewUsers({ numberOfUsers: 10000 }); const users = await createNewUsers({ numberOfUsers: 10000 });
logger.info('Users created successfully.'); logger.info('Users created successfully.');
const userAvatars = await createNewUserAvatars({ joinData: { users } });
logger.info('User avatars created successfully.');
const locations = await createNewLocations({ const locations = await createNewLocations({
numberOfLocations: 500, numberOfLocations: 500,
joinData: { users }, joinData: { users },
@@ -103,6 +107,7 @@ import createNewBeerStyleLikes from './create/createNewBeerStyleLikes';
logger.info('Database seeded successfully.'); logger.info('Database seeded successfully.');
logger.info({ logger.info({
numberOfUsers: users.length, numberOfUsers: users.length,
numberOfUserAvatars: userAvatars.length,
numberOfBreweryPosts: breweryPosts.length, numberOfBreweryPosts: breweryPosts.length,
numberOfBeerPosts: beerPosts.length, numberOfBeerPosts: beerPosts.length,
numberOfBeerStyles: beerStyles.length, numberOfBeerStyles: beerStyles.length,

View File

@@ -27,7 +27,7 @@ const createNewBeerComment = async ({
id: true, id: true,
content: true, content: true,
rating: true, rating: true,
postedBy: { select: { id: true, username: true } }, postedBy: { select: { id: true, username: true, userAvatar: true } },
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },

View File

@@ -22,7 +22,9 @@ const editBeerCommentById = async ({
rating: true, rating: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
postedBy: { select: { id: true, username: true, createdAt: true } }, postedBy: {
select: { id: true, username: true, createdAt: true, userAvatar: true },
},
}, },
}); });
}; };

View File

@@ -17,7 +17,9 @@ const findBeerCommentById = async ({
rating: true, rating: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
postedBy: { select: { id: true, username: true, createdAt: true } }, postedBy: {
select: { id: true, username: true, createdAt: true, userAvatar: true },
},
}, },
}); });
}; };

View File

@@ -24,7 +24,9 @@ const getAllBeerComments = async ({
rating: true, rating: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
postedBy: { select: { id: true, username: true, createdAt: true } }, postedBy: {
select: { id: true, username: true, createdAt: true, userAvatar: true },
},
}, },
}); });
}; };

View File

@@ -27,9 +27,11 @@ const createNewBeerStyleComment = async ({
id: true, id: true,
content: true, content: true,
rating: true, rating: true,
postedBy: { select: { id: true, username: true } },
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
postedBy: {
select: { id: true, username: true, createdAt: true, userAvatar: true },
},
}, },
}); });
}; };

View File

@@ -24,7 +24,9 @@ const getAllBeerStyleComments = async ({
rating: true, rating: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
postedBy: { select: { id: true, username: true, createdAt: true } }, postedBy: {
select: { id: true, username: true, createdAt: true, userAvatar: true },
},
}, },
}); });
}; };

View File

@@ -27,9 +27,11 @@ const createNewBreweryComment = async ({
id: true, id: true,
content: true, content: true,
rating: true, rating: true,
postedBy: { select: { id: true, username: true } },
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
postedBy: {
select: { id: true, username: true, createdAt: true, userAvatar: true },
},
}, },
}); });
}; };

View File

@@ -23,7 +23,9 @@ const getAllBreweryComments = async ({
rating: true, rating: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
postedBy: { select: { id: true, username: true, createdAt: true } }, postedBy: {
select: { id: true, username: true, createdAt: true, userAvatar: true },
},
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });

View File

@@ -33,6 +33,8 @@ const createNewUser = async ({
accountIsVerified: true, accountIsVerified: true,
updatedAt: true, updatedAt: true,
role: true, role: true,
userAvatar: true,
bio: true,
}, },
}); });

View File

@@ -17,6 +17,8 @@ const deleteUserById = async (id: string) => {
accountIsVerified: true, accountIsVerified: true,
updatedAt: true, updatedAt: true,
role: true, role: true,
userAvatar: true,
bio: true,
}, },
}); });

View File

@@ -17,6 +17,17 @@ const findUserById = async (id: string) => {
accountIsVerified: true, accountIsVerified: true,
updatedAt: true, updatedAt: true,
role: true, role: true,
userAvatar: {
select: {
path: true,
alt: true,
caption: true,
createdAt: true,
id: true,
updatedAt: true,
},
},
bio: true,
}, },
}); });

View File

@@ -11,6 +11,17 @@ const GetUserSchema = z.object({
dateOfBirth: z.coerce.date(), dateOfBirth: z.coerce.date(),
accountIsVerified: z.boolean(), accountIsVerified: z.boolean(),
role: z.enum(['USER', 'ADMIN']), role: z.enum(['USER', 'ADMIN']),
bio: z.string().nullable(),
userAvatar: z
.object({
id: z.string().cuid(),
path: z.string().url(),
alt: z.string(),
caption: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date().nullable(),
})
.nullable(),
}); });
export default GetUserSchema; export default GetUserSchema;

View File

@@ -17,6 +17,17 @@ const updateUserToBeConfirmedById = async (id: string) => {
updatedAt: true, updatedAt: true,
dateOfBirth: true, dateOfBirth: true,
role: true, role: true,
bio: true,
userAvatar: {
select: {
id: true,
path: true,
alt: true,
caption: true,
createdAt: true,
updatedAt: true,
},
},
}, },
}); });

View File

@@ -8,6 +8,14 @@ const CommentQueryResult = z.object({
postedBy: z.object({ postedBy: z.object({
id: z.string().cuid(), id: z.string().cuid(),
username: z.string().min(1).max(50), username: z.string().min(1).max(50),
userAvatar: z
.object({
id: z.string().cuid(),
path: z.string().url(),
alt: z.string().min(1).max(50),
caption: z.string().min(1).max(50),
})
.nullable(),
}), }),
updatedAt: z.coerce.date().nullable(), updatedAt: z.coerce.date().nullable(),
}); });