From 1d52657ca70522d58196f7370a123c4ea2ccf547 Mon Sep 17 00:00:00 2001 From: Maria Adriana <97130795+mariadriana-deemaze@users.noreply.github.com> Date: Wed, 13 Nov 2024 16:51:18 +0000 Subject: [PATCH] [SOA-21] User profile (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat ๐ŸŽธ (be): prepare service and controllers for the users settings * styles ๐Ÿ’… : add setting page and apply layout tweaks * feat ๐ŸŽธ (wip): progress * feat ๐ŸŽธ : upload screen functionalities, and update user response shared props * fix โœ… (fe): correct cast * fix โœ… : improve readibility * refactor โœจ : improve routing middlewares * refactor โœจ : improve auth validator and other nits * styles ๐Ÿ’… : improvements on user profile display * fix โœ… (be): delete associations on post delete * test ๐Ÿงช : add minimal browser tests for user profile settings * fix โœ… : type correction * fix โœ… (be): review upload external key handling logic --- app/controllers/feed_controller.ts | 17 +- app/controllers/posts_controller.ts | 12 +- app/controllers/users_controller.ts | 63 ++++- app/interfaces/attachment.ts | 2 + app/interfaces/user.ts | 9 +- app/middleware/auth_middleware.ts | 17 +- app/middleware/guest_middleware.ts | 24 +- app/models/attachment.ts | 2 + app/models/post.ts | 6 + app/services/attachment_service.ts | 47 ++++ app/services/auth_service.ts | 7 - app/services/post_reaction_service.ts | 95 ++++---- app/services/posts_service.ts | 6 +- app/services/user_service.ts | 137 +++++++++++ app/utils/index.ts | 5 + app/validators/auth.ts | 8 +- app/validators/user.ts | 39 ++++ config/inertia.ts | 13 +- ...41_create_update_attachment_types_table.ts | 10 + inertia/app/layout.tsx | 1 - inertia/components/posts/feed-list.tsx | 7 +- inertia/components/posts/post-card.tsx | 50 ++-- inertia/components/ui/hover-card.tsx | 10 +- inertia/components/users/nav.tsx | 17 +- inertia/pages/users/settings.tsx | 220 ++++++++++++++++++ inertia/pages/users/show.tsx | 114 +++++---- start/routes.ts | 15 +- tests/browser/pages/user-feed.spec.ts | 4 +- tests/browser/user/settings.spec.ts | 81 +++++++ 29 files changed, 835 insertions(+), 203 deletions(-) create mode 100644 app/services/user_service.ts create mode 100644 app/validators/user.ts create mode 100644 database/migrations/1731434151341_create_update_attachment_types_table.ts create mode 100644 inertia/pages/users/settings.tsx create mode 100644 tests/browser/user/settings.spec.ts diff --git a/app/controllers/feed_controller.ts b/app/controllers/feed_controller.ts index 732adfa..54656d2 100644 --- a/app/controllers/feed_controller.ts +++ b/app/controllers/feed_controller.ts @@ -4,15 +4,19 @@ import PostsService from '#services/posts_service' import { PostResponse } from 'app/interfaces/post' import { PageObject } from '@adonisjs/inertia/types' import { PaginatedResponse } from 'app/interfaces/pagination' +import { UserService } from '#services/user_service' import type { HttpContext } from '@adonisjs/core/http' @inject() export default class FeedController { - constructor(private readonly postsService: PostsService) { } + constructor( + private readonly postsService: PostsService, + private readonly userService: UserService + ) {} async index( ctx: HttpContext ): Promise }>> { - const currentUserId = ctx.auth.user?.id!; + const currentUserId = ctx.auth.user?.id! const page = ctx.request.qs().page || 1 const posts = await Post.query() @@ -36,4 +40,13 @@ export default class FeedController { }, }) } + + async show(ctx: HttpContext) { + const currentUserId = ctx.auth.user?.id! + const profileId = ctx.params.id + const page = ctx.request.qs().page || 1 + const posts = await this.postsService.findMany(currentUserId, profileId, { page }) + const profile = await this.userService.findOne(profileId) + return ctx.inertia.render('users/show', { posts, profile }) + } } diff --git a/app/controllers/posts_controller.ts b/app/controllers/posts_controller.ts index 3311702..c641b0d 100644 --- a/app/controllers/posts_controller.ts +++ b/app/controllers/posts_controller.ts @@ -10,17 +10,15 @@ import { PageObject } from '@adonisjs/inertia/types' @inject() export default class PostsController { - constructor(private service: service) { } + constructor(private service: service) {} async show(ctx: HttpContext): Promise< | string - | PageObject< - | { + | PageObject<{ post: PostResponse | null - } - > + }> > { - const currentUserId = ctx.auth.user?.id!; + const currentUserId = ctx.auth.user?.id! const post = await this.service.findOne(ctx.params.id) if (!post) { return ctx.inertia.render('errors/not_found', { @@ -64,7 +62,7 @@ export default class PostsController { } async update(ctx: HttpContext) { - const currentUserId = ctx.auth.user?.id!; + const currentUserId = ctx.auth.user?.id! const post = await this.service.findOne(ctx.params.id) if (!post) { return ctx.inertia.render('errors/not_found', { diff --git a/app/controllers/users_controller.ts b/app/controllers/users_controller.ts index 4f601a5..2fd3377 100644 --- a/app/controllers/users_controller.ts +++ b/app/controllers/users_controller.ts @@ -1,18 +1,57 @@ -import User from '#models/user' -import PostsService from '#services/posts_service' import { inject } from '@adonisjs/core' -import type { HttpContext } from '@adonisjs/core/http' +import { HttpContext } from '@adonisjs/core/http' +import { updateUserValidator } from '#validators/user' +import { errors } from '@vinejs/vine' +import { errorsReducer } from '#utils/index' +import { UserService } from '#services/user_service' +import { UserResponse } from '#interfaces/user' +import { PageObject } from '@adonisjs/inertia/types' @inject() export default class UsersController { - constructor(public readonly service: PostsService) { } - - async show(ctx: HttpContext) { - const currentUserId = ctx.auth.user?.id!; - const profileId = ctx.params.id - const page = ctx.request.qs().page || 1 - const posts = await this.service.findMany(currentUserId, profileId, { page }) - const profile = await User.find(profileId) - return ctx.inertia.render('users/show', { posts, profile }) + constructor(private readonly service: UserService) {} + + async show(ctx: HttpContext): Promise< + | string + | PageObject<{ + user: UserResponse + }> + > { + const user = await this.service.serialize(ctx.auth.user!) + return ctx.inertia.render('users/settings', { user }) + } + + async update(ctx: HttpContext) { + const user = ctx.auth.user! + + try { + const data = await ctx.request.validateUsing(updateUserValidator, { + meta: { + userId: user.id, + }, + }) + + await this.service.update(user, { + name: data.name, + surname: data.surname, + username: data.username, + email: data.email, + }) + + await this.service.storeAttachments(ctx) + + return ctx.inertia.render('users/settings') + } catch (error) { + if (error instanceof errors.E_VALIDATION_ERROR) { + const reducedErrors = errorsReducer(error.messages) + ctx.session.flash('errors', reducedErrors) + } + + return ctx.response.redirect().back() + } + } + + async delete() { + // TODO: Implement. } } diff --git a/app/interfaces/attachment.ts b/app/interfaces/attachment.ts index e6ec3bb..d182c8f 100644 --- a/app/interfaces/attachment.ts +++ b/app/interfaces/attachment.ts @@ -1,3 +1,4 @@ +import { AttachmentType } from '#models/attachment' import { UUID } from 'crypto' export interface AttachmentMetadataJSON { @@ -10,5 +11,6 @@ export interface AttachmentMetadataJSON { export interface AttachmentResponse { id: UUID link: string + type: AttachmentType metadata: AttachmentMetadataJSON } diff --git a/app/interfaces/user.ts b/app/interfaces/user.ts index 5bd071b..ace9cbd 100644 --- a/app/interfaces/user.ts +++ b/app/interfaces/user.ts @@ -1,3 +1,4 @@ +import { AttachmentResponse } from '#interfaces/attachment' import { AccountRole } from '#models/user' import { BaseEntity } from 'app/interfaces/base-entity' import { UUID } from 'crypto' @@ -5,8 +6,12 @@ import { UUID } from 'crypto' export interface UserResponse extends BaseEntity { id: UUID role: AccountRole - name: string - surname: string + name: string | null + surname: string | null username: string email: string + attachments: { + cover: AttachmentResponse | null + avatar: AttachmentResponse | null + } } diff --git a/app/middleware/auth_middleware.ts b/app/middleware/auth_middleware.ts index 9a470d3..f20009f 100644 --- a/app/middleware/auth_middleware.ts +++ b/app/middleware/auth_middleware.ts @@ -1,6 +1,5 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' -import type { Authenticators } from '@adonisjs/auth/types' /** * Auth middleware is used authenticate HTTP requests and deny @@ -12,14 +11,12 @@ export default class AuthMiddleware { */ redirectTo = '/auth/sign-in' - async handle( - ctx: HttpContext, - next: NextFn, - options: { - guards?: (keyof Authenticators)[] - } = {} - ) { - await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo }) - return next() + async handle(ctx: HttpContext, next: NextFn) { + try { + await ctx.auth.authenticate() + return next() + } catch (error) { + return ctx.response.redirect().toPath(this.redirectTo) + } } } diff --git a/app/middleware/guest_middleware.ts b/app/middleware/guest_middleware.ts index 2ac75c3..d884b40 100644 --- a/app/middleware/guest_middleware.ts +++ b/app/middleware/guest_middleware.ts @@ -1,29 +1,23 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' -import type { Authenticators } from '@adonisjs/auth/types' /** - * Guest middleware is used to deny access to routes that should + * Guest middleware is used to deny access to routes that can * be accessed by unauthenticated users. * * For example, the login page should not be accessible if the user * is already logged-in */ export default class GuestMiddleware { - /** - * The URL to redirect to when user is logged-in - */ - redirectTo = '/feed' + async handle(ctx: HttpContext, next: NextFn) { + let user = null - async handle( - ctx: HttpContext, - next: NextFn, - options: { guards?: (keyof Authenticators)[] } = {} - ) { - for (let guard of options.guards || [ctx.auth.defaultGuard]) { - if (await ctx.auth.use(guard).check()) { - return ctx.response.redirect(this.redirectTo, true) - } + try { + user = await ctx.auth.authenticate() + } catch (error) {} + + if (!!user && ctx.route?.pattern.includes('auth')) { + return ctx.response.redirect().back() } return next() diff --git a/app/models/attachment.ts b/app/models/attachment.ts index dc70504..ad5d71e 100644 --- a/app/models/attachment.ts +++ b/app/models/attachment.ts @@ -12,6 +12,8 @@ export enum AttachmentType { AUDIO = 'Audio', DOCUMENT = 'Document', VIDEO = 'Video', + AVATAR = 'Avatar', + COVER = 'Cover', } export enum AttachmentModel { diff --git a/app/models/post.ts b/app/models/post.ts index 35336c1..127b59c 100644 --- a/app/models/post.ts +++ b/app/models/post.ts @@ -1,6 +1,7 @@ import { DateTime } from 'luxon' import { BaseModel, + beforeDelete, beforeSave, beforeUpdate, belongsTo, @@ -46,4 +47,9 @@ export default class Post extends BaseModel { static sanitizeContent(post: Post) { post.content = sanitizePostContent(post.content) } + + @beforeDelete() + public static async deleteAssociations(post: Post) { + await post.related('reactions').query().delete() + } } diff --git a/app/services/attachment_service.ts b/app/services/attachment_service.ts index 60c26a5..68dd728 100644 --- a/app/services/attachment_service.ts +++ b/app/services/attachment_service.ts @@ -35,6 +35,7 @@ export default class AttachmentService { resources.images.push({ id: attachment.id, link, + type: attachment.type, metadata: attachment.metadata, }) } @@ -42,6 +43,18 @@ export default class AttachmentService { return resources } + /** + * Polymorphic find of many attachments to a specified model. + * Returns the records. + */ + async findManyRaw(model: AttachmentModel, model_id: string) { + const attachments = await Attachment.findManyBy({ + model, + model_id, + }) + return attachments + } + /** * Polymorphic find of many attachments to a specified model. */ @@ -86,6 +99,40 @@ export default class AttachmentService { // NOTE: Videos could be handed over here to a different provider. } + async storeOne(model: AttachmentModel, modelId: UUID, type: AttachmentType, file: MultipartFile) { + const extension = file.extname || file.subtype || file.headers['content-type'] + + const attachment = new Attachment() + attachment.model = model + attachment.type = type + attachment.model_id = modelId + attachment.metadata = new MetadataJSON({ + filename: file.clientName, + size: file.size, + mimetype: file.headers['content-type'], + extension, + }) + + let key = this.generateS3Key(type, extension) + await file.moveToDisk(key) + attachment.external_key = key + await attachment.save() + } + + async getPresignedLink(externalKey: string) { + return this.disk.getSignedUrl(externalKey) + } + + /** + * Updates file in given key. + */ + async update(key: string, file: MultipartFile) { + await file.moveToDisk(key) + } + + /** + * Generates a key in disk + */ private generateS3Key(type: AttachmentType, extension: string) { return `uploads/${type}/${cuid()}.${extension}` } diff --git a/app/services/auth_service.ts b/app/services/auth_service.ts index b9cbf6a..da7d2cf 100644 --- a/app/services/auth_service.ts +++ b/app/services/auth_service.ts @@ -11,13 +11,6 @@ export default class AuthService { try { const payload = await request.validateUsing(createAuthValidator) - const existant = await User.query().where('email', payload.email).first() - if (existant) { - session.flash('errors', { - email: 'An user with the provided email already exists.', - }) - return response.redirect().back() - } const user = new User() Object.assign(user, payload) diff --git a/app/services/post_reaction_service.ts b/app/services/post_reaction_service.ts index df62b83..066813f 100644 --- a/app/services/post_reaction_service.ts +++ b/app/services/post_reaction_service.ts @@ -1,49 +1,46 @@ -import { PostReactionType } from '#enums/post'; -import PostReaction from '#models/post_reaction' -import type { UUID } from 'crypto' - -export default class PostReactionService { - constructor() { } - - async show(userId: UUID, postId: UUID): Promise { - return PostReaction.findBy({ - userId, - postId, - }) - } - - async create(userId: UUID, postId: UUID, type: PostReactionType): Promise<[boolean, PostReaction]> { - const existant = await this.show( - userId, - postId, - ) - - let resource: PostReaction; - if (existant) { - resource = await this.update(existant, type) - } else { - resource = await PostReaction.create({ - userId, - postId, - type - }) - } - return [!!existant, resource] - - } - - async update(reaction: PostReaction, type: PostReactionType): Promise { - reaction.type = type; - await reaction.save() - return reaction - } - - async destroy(userId: UUID, postId: UUID): Promise { - const reaction = await this.show( - userId, - postId, - ) - if (!reaction) return - await reaction.delete() - } -} +import { PostReactionType } from '#enums/post' +import PostReaction from '#models/post_reaction' +import type { UUID } from 'crypto' + +export default class PostReactionService { + constructor() {} + + async show(userId: UUID, postId: UUID): Promise { + return PostReaction.findBy({ + userId, + postId, + }) + } + + async create( + userId: UUID, + postId: UUID, + type: PostReactionType + ): Promise<[boolean, PostReaction]> { + const existant = await this.show(userId, postId) + + let resource: PostReaction + if (existant) { + resource = await this.update(existant, type) + } else { + resource = await PostReaction.create({ + userId, + postId, + type, + }) + } + return [!!existant, resource] + } + + async update(reaction: PostReaction, type: PostReactionType): Promise { + reaction.type = type + await reaction.save() + return reaction + } + + async destroy(userId: UUID, postId: UUID): Promise { + const reaction = await this.show(userId, postId) + if (!reaction) return + await reaction.delete() + } +} diff --git a/app/services/posts_service.ts b/app/services/posts_service.ts index 7d032b6..9bf34b3 100644 --- a/app/services/posts_service.ts +++ b/app/services/posts_service.ts @@ -10,12 +10,15 @@ import PostReaction from '#models/post_reaction' import { ModelObject } from '@adonisjs/lucid/types/model' import type { HttpContext } from '@adonisjs/core/http' import type { UUID } from 'crypto' +import { UserService } from '#services/user_service' export default class PostsService { + private readonly userService: UserService private readonly linkService: LinkParserService private readonly attachmentService: AttachmentService constructor() { + this.userService = new UserService() this.linkService = new LinkParserService() this.attachmentService = new AttachmentService() } @@ -132,6 +135,7 @@ export default class PostsService { */ async serialize(currentUserId: UUID, post: Post): Promise { const data = post.toJSON() as ModelObject & { reactions: PostReaction[] } + const user = await this.userService.serialize(post.user) const attachments = await this.attachmentService.findMany(AttachmentModel.POST, post.id) const link = await this.linkService.show(post.link) @@ -154,7 +158,7 @@ export default class PostsService { const resource: PostResponse = { id: data.id, content: data.content, - user: data.user, + user, link, attachments, reactions: { diff --git a/app/services/user_service.ts b/app/services/user_service.ts new file mode 100644 index 0000000..e342189 --- /dev/null +++ b/app/services/user_service.ts @@ -0,0 +1,137 @@ +import { AttachmentResponse } from '#interfaces/attachment' +import { UserResponse } from '#interfaces/user' +import { AttachmentModel, AttachmentType } from '#models/attachment' +import User from '#models/user' +import AttachmentService from '#services/attachment_service' +import { HttpContext } from '@adonisjs/core/http' +import { UUID } from 'crypto' + +export class UserService { + private readonly attachmentService: AttachmentService + + constructor() { + this.attachmentService = new AttachmentService() + } + + async findOne(id: UUID): Promise { + const user = await User.find(id) + if (!user) return null + const resource = await this.serialize(user) + return resource + } + + async update( + user: User, + payload: { + username: string + email: string + name: string | null + surname: string | null + } + ) { + Object.assign(user, payload) + await user.save() + } + + async storeAttachments(ctx: HttpContext) { + const currentUserId = ctx.auth.user?.id! + + const avatar = ctx.request.file('avatar', { + size: '2mb', + extnames: ['jpeg', 'jpg', 'png'], + }) + + const cover = ctx.request.file('cover', { + size: '2mb', + extnames: ['jpeg', 'jpg', 'png'], + }) + + const keys = await this.attachmentService + .findManyRaw(AttachmentModel.USER, currentUserId) + .then((result) => { + return result.reduce( + (acc, next) => { + const type = next.type as AttachmentType.AVATAR | AttachmentType.COVER + acc[type] = next.external_key + return acc + }, + { [AttachmentType.AVATAR]: null, [AttachmentType.COVER]: null } as Record< + 'Avatar' | 'Cover', + string | null + > + ) + }) + + if (avatar) { + if (keys.Avatar) { + await this.attachmentService.update(keys.Avatar, avatar) + } else { + await this.attachmentService.storeOne( + AttachmentModel.USER, + currentUserId, + AttachmentType.AVATAR, + avatar + ) + } + } + + if (cover) { + if (keys.Cover) { + await this.attachmentService.update(keys.Cover, cover) + } else { + await this.attachmentService.storeOne( + AttachmentModel.USER, + currentUserId, + AttachmentType.COVER, + cover + ) + } + } + } + + async deleteAttachments(id: UUID): Promise { + return this.attachmentService.deleteMany(AttachmentModel.USER, id) + } + + async serialize(user: User): Promise { + const data = user.toJSON() + + const attachments: Record<'avatar' | 'cover', AttachmentResponse | null> = { + avatar: null, + cover: null, + } + + const attached = await this.attachmentService.findManyRaw(AttachmentModel.USER, user.id) + + for (const attachment of attached) { + const link = await this.attachmentService.getPresignedLink(attachment.external_key) + let item: AttachmentResponse = { + id: attachment.id, + type: attachment.type, + link, + metadata: attachment.metadata, + } + + if (attachment.type === AttachmentType.AVATAR) { + attachments.avatar = item + } + if (attachment.type === AttachmentType.COVER) { + attachments.cover = item + } + } + + const resource: UserResponse = { + id: data.id, + role: data.role, + name: data.name, + surname: data.surname, + username: data.username, + email: data.email, + attachments, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + } + + return resource + } +} diff --git a/app/utils/index.ts b/app/utils/index.ts index 8b8849b..f8f844f 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -32,3 +32,8 @@ export function sanitizePostContent(content: string): string { // @ts-ignore return content.replace(/[&<>]/g, (m) => map[m]) } + +export const REGEX = { + ALPHA_STRING: /^[A-z]+$/, + ALPHANUMERIC_STRING: /^[A-z0-9]+$/, +} diff --git a/app/validators/auth.ts b/app/validators/auth.ts index 702ff0b..91b9d0f 100644 --- a/app/validators/auth.ts +++ b/app/validators/auth.ts @@ -6,7 +6,13 @@ import vine, { SimpleMessagesProvider } from '@vinejs/vine' export const createAuthValidator = vine.compile( vine.object({ name: vine.string(), - email: vine.string().email(), + email: vine + .string() + .unique(async (db, value) => { + const user = await db.from('users').where('email', value).first() + return !user + }) + .email(), password: vine.string().minLength(8).maxLength(32).confirmed({ confirmationField: 'passwordConfirmation', }), diff --git a/app/validators/user.ts b/app/validators/user.ts new file mode 100644 index 0000000..965a54e --- /dev/null +++ b/app/validators/user.ts @@ -0,0 +1,39 @@ +import { REGEX } from '#utils/index' +import vine from '@vinejs/vine' + +/** + * Validates the user update action payload + */ +export const updateUserValidator = vine.compile( + vine.object({ + username: vine + .string() + .trim() + .regex(REGEX.ALPHANUMERIC_STRING) + .unique(async (db, value, field) => { + const user = await db + .from('users') + .whereNot('id', field.meta.userId) + .where('username', value) + .first() + return !user + }) + .minLength(1) + .maxLength(50), + email: vine + .string() + .unique(async (db, value, field) => { + const user = await db + .from('users') + .whereNot('id', field.meta.userId) + .where('email', value) + .first() + return !user + }) + .email(), + name: vine.string().regex(REGEX.ALPHA_STRING).minLength(1).maxLength(50).nullable(), + surname: vine.string().regex(REGEX.ALPHA_STRING).minLength(1).maxLength(50).nullable(), + avatar: vine.file().nullable(), + cover: vine.file().nullable(), + }) +) diff --git a/config/inertia.ts b/config/inertia.ts index 3ad2461..24d6cdf 100644 --- a/config/inertia.ts +++ b/config/inertia.ts @@ -1,4 +1,5 @@ -import User from '#models/user' +import { UserResponse } from '#interfaces/user' +import { UserService } from '#services/user_service' import { defineConfig } from '@adonisjs/inertia' import type { InferSharedProps } from '@adonisjs/inertia/types' @@ -12,7 +13,15 @@ const inertiaConfig = defineConfig({ * Data that should be shared with all rendered pages */ sharedData: { - user: async (ctx): Promise => ctx?.auth?.user || null, + user: async (ctx): Promise => { + if (ctx?.auth?.user) { + const service = new UserService() // TODO: Figure out if there's a better way to go around this, and if that brings any performance penalty. + const user = await service.serialize(ctx?.auth?.user) + return user + } else { + return null + } + }, errors: (ctx) => ctx.session?.flashMessages.get('errors'), }, diff --git a/database/migrations/1731434151341_create_update_attachment_types_table.ts b/database/migrations/1731434151341_create_update_attachment_types_table.ts new file mode 100644 index 0000000..23b7387 --- /dev/null +++ b/database/migrations/1731434151341_create_update_attachment_types_table.ts @@ -0,0 +1,10 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + async up() { + this.schema.raw(`ALTER TYPE "attachment_type" ADD VALUE IF NOT EXISTS 'Avatar'`) + this.schema.raw(`ALTER TYPE "attachment_type" ADD VALUE IF NOT EXISTS 'Cover'`) + } + + async down() {} +} diff --git a/inertia/app/layout.tsx b/inertia/app/layout.tsx index c914111..b07d4a5 100644 --- a/inertia/app/layout.tsx +++ b/inertia/app/layout.tsx @@ -9,7 +9,6 @@ export default function Layout({ children }: { children: ReactNode }) { const { props: { user }, } = usePage() - return ( <> diff --git a/inertia/components/posts/feed-list.tsx b/inertia/components/posts/feed-list.tsx index fb593a4..6e4e582 100644 --- a/inertia/components/posts/feed-list.tsx +++ b/inertia/components/posts/feed-list.tsx @@ -3,8 +3,9 @@ import { router } from '@inertiajs/react' import { useToast } from '@/components/ui/use-toast' import PostCard from '@/components/posts/post-card' import { useIntersectionObserver } from '@/hooks/use-intersection-observer' +import { PostResponse } from '#interfaces/post' +import { UserResponse } from '#interfaces/user' import { Loader2 } from 'lucide-react' -import { PostResponse } from 'app/interfaces/post' export default function FeedList({ url, @@ -16,9 +17,7 @@ export default function FeedList({ meta: any data: PostResponse[] } - currentUser: { - [x: string]: any - } | null + currentUser: UserResponse | null }) { const [allPosts, setAllPosts] = useState(posts?.data) const [meta, setMeta] = useState(posts.meta) diff --git a/inertia/components/posts/post-card.tsx b/inertia/components/posts/post-card.tsx index 74b81b2..cfb7c3c 100644 --- a/inertia/components/posts/post-card.tsx +++ b/inertia/components/posts/post-card.tsx @@ -28,22 +28,29 @@ import { formatDistanceToNow } from 'date-fns' import { PostReactionType } from '#enums/post' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import type { UUID } from 'crypto' +import { UserResponse } from '#interfaces/user' const userLink = (id: UUID) => `/users/${id}` const postLink = (id: UUID) => `/posts/${id}` -function PostContentParser({ post }: { post: PostResponse }) { - const content = useMemo(() => { - if (post.link) { - return post.content.replace( - post.link.link, - `${post.link.link}` +function PostContentParser({ + content, + preview, +}: { + content: string + preview: PostResponse['link'] +}) { + const parsed = useMemo(() => { + if (preview) { + return content.replace( + preview.link, + `${preview.link}` ) } - return post.content - }, [post]) + return content + }, [content]) return ( -
+
) } @@ -84,7 +91,7 @@ function PostImage({ image }: { image: AttachmentResponse }) { return ( <>
@@ -193,9 +200,7 @@ function PostReaction({ currentUser, }: { post: PostResponse - currentUser: { - [x: string]: any - } | null + currentUser: UserResponse | null }) { const [isSubmitting, setIsSubmitting] = useState(false) const [reaction, setReaction] = useState<{ type: PostReactionType | null; count: number }>({ @@ -316,9 +321,7 @@ export default function PostCard({ redirect = false, }: { post: PostResponse - user: { - [x: string]: any - } | null + user: UserResponse | null actions?: boolean redirect?: boolean }) { @@ -326,15 +329,18 @@ export default function PostCard({ return (
-
+
-
+
- + {post.user.name ? post.user.name[0] : '-'}
-

+

@{post.user.username}

@@ -400,10 +406,10 @@ export default function PostCard({ {redirect ? ( - + ) : ( - + )} {post.link && } diff --git a/inertia/components/ui/hover-card.tsx b/inertia/components/ui/hover-card.tsx index 8e37442..902b9ef 100644 --- a/inertia/components/ui/hover-card.tsx +++ b/inertia/components/ui/hover-card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import * as HoverCardPrimitive from "@radix-ui/react-hover-card" -import { cn } from "@/lib/utils" +import * as React from 'react' +import * as HoverCardPrimitive from '@radix-ui/react-hover-card' +import { cn } from '@/lib/utils' const HoverCard = HoverCardPrimitive.Root @@ -9,13 +9,13 @@ const HoverCardTrigger = HoverCardPrimitive.Trigger const HoverCardContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( [] = [ { title: 'Home', @@ -25,10 +25,6 @@ export default function UserNavBar({ user }: { user: User | null }) { title: 'Feed', link: '/feed', }, - /* { - title: 'Groups', - link: '/groups', - }, */ ] return ( @@ -53,7 +49,10 @@ export default function UserNavBar({ user }: { user: User | null }) { @@ -76,7 +75,7 @@ export default function UserNavBar({ user }: { user: User | null }) { โ‡งโŒ˜P - Settings + Settings โŒ˜S diff --git a/inertia/pages/users/settings.tsx b/inertia/pages/users/settings.tsx new file mode 100644 index 0000000..c847911 --- /dev/null +++ b/inertia/pages/users/settings.tsx @@ -0,0 +1,220 @@ +import UsersController from '#controllers/users_controller' +import { UserResponse } from '#interfaces/user' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { InferPageProps } from '@adonisjs/inertia/types' +import { Head, useForm, usePage } from '@inertiajs/react' +import { Upload } from 'lucide-react' +import { ChangeEvent, useEffect, useRef, useState } from 'react' + +export default function UserSettings({ + user, +}: InferPageProps & { user: UserResponse }) { + const [avatarPreview, setAvatarPreview] = useState( + () => user.attachments.avatar?.link || undefined + ) + const [coverPreview, setCoverPreview] = useState(() => user.attachments.cover?.link || undefined) + + if (!user) return <> + + const { props } = usePage() + + const { toast } = useToast() + + const { data, setData, patch, processing } = useForm<{ + name: string + surname: string + username: string + email: string + avatar: File | null + cover: File | null + }>({ + name: user.name ?? '', + surname: user.surname ?? '', + username: user.username, + email: user.email, + avatar: null, + cover: null, + }) + + const uploadAvatar = useRef(null) + const uploadCover = useRef(null) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + patch(`/users/${user?.id}`, { + preserveState: true, + preserveScroll: true, + onSuccess: () => { + toast({ title: 'Success!' }) + }, + }) + } + + function onAvatarChange(e: ChangeEvent) { + if (!e.target.files || !e.target.files[0]) return + setData('avatar', e.target.files[0]) + + const fileReader = new FileReader() + fileReader.onload = () => { + const { result } = fileReader + if (!result) return + setAvatarPreview(String(result)) + } + fileReader.readAsDataURL(e.target.files[0]) + } + + function onCoverChange(e: ChangeEvent) { + if (!e.target.files || !e.target.files[0]) return + setData('cover', e.target.files[0]) + + const fileReader = new FileReader() + fileReader.onload = () => { + const { result } = fileReader + if (!result) return + setCoverPreview(String(result)) + } + fileReader.readAsDataURL(e.target.files[0]) + } + + useEffect(() => { + if (props?.errors && Object.entries(props.errors).length) { + toast({ title: 'Error updating profile details.' }) + } + }, [props?.errors]) + + return ( + <> + +
+
+
+
+ {coverPreview && ( + + )} +
+ +
+
+ + + {user.name ? user.name[0] : '-'} + + + + +
+
+
+ + +
+
+ + + + Account profile + Update your account profile details + + +
+
+ + setData('name', e.target.value)} + required + /> + {props?.errors?.name && ( +

{props?.errors?.name}

+ )} +
+
+ + setData('surname', e.target.value)} + /> + {props?.errors?.surname && ( +

{props?.errors?.surname}

+ )} +
+
+ + setData('email', e.target.value)} + required + /> + {props?.errors?.email && ( +

{props?.errors?.email}

+ )} +
+
+ + setData('username', e.target.value)} + /> + {props?.errors?.username && ( +

{props?.errors?.username}

+ )} +
+
+ +
+
+
+
+ + ) +} diff --git a/inertia/pages/users/show.tsx b/inertia/pages/users/show.tsx index 71493af..bb4bf2b 100644 --- a/inertia/pages/users/show.tsx +++ b/inertia/pages/users/show.tsx @@ -1,4 +1,4 @@ -import UsersController from '#controllers/users_controller' +import FeedController from '#controllers/feed_controller' import FeedList from '@/components/posts/feed-list' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Card, CardContent } from '@/components/ui/card' @@ -7,62 +7,80 @@ import { lightFormat } from 'date-fns' import { Head } from '@inertiajs/react' import { CalendarHeart, FilePen } from 'lucide-react' import { CreatePost } from '@/components/posts/create' +import { UserResponse } from '#interfaces/user' -export default function Show({ user, posts, profile }: InferPageProps) { +function UserCard({ user, totalPosts }: { user: UserResponse; totalPosts: number }) { + return ( + + +
+
+ + + {user.name ? user.name[0] : '-'} + +
+
+

{user.name}

+

+ @{user.username} +

+
+
+ +
+
+
+ +

+ Total posts + {totalPosts} +

+
+
+ +
+
+ +

+ Joined on + + {' '} + {lightFormat(new Date(user.createdAt), 'yyyy-MM-dd')} + +

+
+
+
+
+
+ ) +} + +export default function Show({ user, posts, profile }: InferPageProps) { if (!posts || !profile) return <>Loading.. return ( <> +
+
+
+ +
+
+ +
+
+
-
- {/* TODO: Make sticky on mobile scroll, as a nav. */} - - -
-
- - - {profile.name ? profile.name[0] : '-'} - -
-
-

{profile.name}

-

- @{profile.username} -

-
-
- -
-
-
- -

- Total posts - {posts?.meta?.total} -

-
-
- -
-
- -

- Joined on - - {' '} - {lightFormat(new Date(profile.createdAt), 'yyyy-MM-dd')} - -

-
-
-
-
-
+
+
- {user?.id === profile.id && (
diff --git a/start/routes.ts b/start/routes.ts index b8066a0..ed890d0 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -18,10 +18,15 @@ import PostReactionsController from '#controllers/post_reactions_controller' /** * - * GUEST/PUBLIC + * PUBLIC * **/ -router.on('/').renderInertia('home') // TODO: Contextualize `ctx.auth.authenticate` via middleware. +router + .group(() => { + router.on('/').renderInertia('home') + router.get('/feed', [FeedController, 'index']) + }) + .use(middleware.guest()) /** * @@ -46,8 +51,10 @@ router router .group(() => { router.delete('/auth/sign-out', [AuthController, 'destroy']) - router.get('/feed', [FeedController, 'index']) - router.get('/users/:id', [UsersController, 'show']) // TODO: Make public, and contextualize `ctx.auth.authenticate` via middleware. + router.get('/users/:id', [FeedController, 'show']) // TODO: Make public, and contextualize `ctx.auth.authenticate` via middleware. + router.get('/users/:id/settings', [UsersController, 'show']) + router.patch('/users/:id', [UsersController, 'update']) + router.delete('/users/:id', [UsersController, 'delete']) router.post('/posts', [PostsController, 'create']) router.get('/posts/:id', [PostsController, 'show']) router.patch('/posts/:id', [PostsController, 'update']) diff --git a/tests/browser/pages/user-feed.spec.ts b/tests/browser/pages/user-feed.spec.ts index 3f031a3..d387572 100644 --- a/tests/browser/pages/user-feed.spec.ts +++ b/tests/browser/pages/user-feed.spec.ts @@ -18,7 +18,7 @@ test.group('Acessing user profile feed', (group) => { const user = await UserFactory.with('posts', 2).create() await browserContext.loginAs(user) const page = await visit(`/users/${user.id}`) - const locator = page.locator('.user-profile-card-total-posts') + const locator = page.getByText('Total posts').first() await page.assertText(locator, `Total posts ${user.posts.length}`) }) @@ -30,7 +30,7 @@ test.group('Acessing user profile feed', (group) => { const otherUser = await UserFactory.with('posts', 8).create() await browserContext.loginAs(user) const page = await visit(`/users/${otherUser.id}`) - const locator = page.locator('.user-profile-card-total-posts') + const locator = page.getByText('Total posts').first() await page.assertText(locator, `Total posts ${otherUser.posts.length}`) }) }) diff --git a/tests/browser/user/settings.spec.ts b/tests/browser/user/settings.spec.ts new file mode 100644 index 0000000..a27d685 --- /dev/null +++ b/tests/browser/user/settings.spec.ts @@ -0,0 +1,81 @@ +import UsersController from '#controllers/users_controller'; +import { UserFactory } from '#database/factories/user_factory' +import User from '#models/user'; +import testUtils from '@adonisjs/core/services/test_utils' +import { InferPageProps, SharedProps } from '@adonisjs/inertia/types'; +import { faker } from '@faker-js/faker'; +import { test } from '@japa/runner' + +test.group('User settings', (group) => { + + let user: User | null = null; + let url = '/users/:id/settings'; + + group.each.setup(async () => { + await testUtils.db().truncate() + user = await UserFactory.create() + url = url.replace(':id', user.id) + }) + + test('Sucessfully updates profile', async ({ visit, browserContext, assert }) => { + const authUser = user!; + await browserContext.loginAs(authUser) + const page = await visit(url) + + const data = { + name: faker.person.firstName(), + surname: faker.person.lastName(), + email: faker.internet.email(), + username: faker.internet.userName(), + } + + await page.locator('input#name').fill(data.name) + await page.locator('input#surname').fill(data.surname) + await page.locator('input#email').fill(data.email) + await page.locator('input#username').fill(data.username) + + const responsePromise = page.waitForResponse(url) + await page.getByRole('button', { name: 'Update' }).click() + const response = await responsePromise; + const json: { props: InferPageProps & SharedProps } = await response.json() + assert.equal(data.name, json.props.user?.name) + assert.equal(data.surname, json.props.user?.surname) + assert.equal(data.username, json.props.user?.username) + assert.equal(data.email, json.props.user?.email) + assert.isUndefined(json.props?.errors) + }) + + test('Fails to update profile', async ({ visit, browserContext, assert }) => { + const authUser = user!; + await browserContext.loginAs(authUser) + const page = await visit(url) + + const data = { + name: faker.person.firstName() + '1', + surname: faker.person.lastName() + '1', + email: faker.internet.email(), + username: faker.internet.userName() + ' ' + '1', + } + + await page.locator('input#name').fill(data.name) + await page.locator('input#surname').fill(data.surname) + await page.locator('input#email').fill(data.email) + await page.locator('input#username').fill(data.username) + + const responsePromise = page.waitForResponse(url) + await page.getByRole('button', { name: 'Update' }).click() + const response = await responsePromise; + const json: { props: InferPageProps & SharedProps } = await response.json() + + assert.notEqual(data.name, json.props.user?.name) + assert.notEqual(data.surname, json.props.user?.surname) + assert.notEqual(data.username, json.props.user?.username) + assert.notEqual(data.email, json.props.user?.email) + assert.isObject(json.props?.errors) + assert.containsSubset(json.props?.errors, { + username: 'The username field format is invalid', + name: 'The name field format is invalid', + surname: 'The surname field format is invalid' + }) + }) +})