Skip to content

Commit

Permalink
[SOA-42] Pin posts (#44)
Browse files Browse the repository at this point in the history
* feat 🎸 (be): add pinned to model and create migration

* feat 🎸 (be): add route, service action and controller for handling post pin

* refactor ✨ : move post pin action to own service

* test 🧪 (unit): add service unit tests for pin and count

* styles 💅 : add pin action on the UI

* fix ✅ : temporarly disable CSRF

* feat 🎸 : handle errors with toast

* test 🧪 (browser): add tests for post/show pin actions

* styles 💅 : improve load states

* styles 💅 : re-styling improvements
  • Loading branch information
mariadriana-deemaze authored Nov 28, 2024
1 parent 305a7ad commit d18dff2
Show file tree
Hide file tree
Showing 17 changed files with 387 additions and 38 deletions.
31 changes: 31 additions & 0 deletions app/controllers/post_pins_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { inject } from '@adonisjs/core'
import policy from '#policies/posts_policy'
import PostPinService from '#services/post_pin_service'
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'

@inject()
export default class PostPinsController {
constructor(private readonly service: PostPinService) {}

async update(ctx: HttpContext) {
const userId = ctx.auth.user?.id!
const postId = ctx.params.id
const post = await Post.findOrFail(postId)

if (await ctx.bouncer.with(policy).denies('edit', post)) {
return ctx.response.forbidden({ message: 'Not the author of this post.' })
}

const pin = !post.pinned
const count = await this.service.count(userId)

if (count >= 2 && pin) {
return ctx.response.conflict({ message: 'Exceeded max amount of pinned posts.' })
}

const pinned = await this.service.pin(post, pin)

return ctx.response.ok({ pinned })
}
}
1 change: 1 addition & 0 deletions app/interfaces/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface LinkResponse {
export interface PostResponse extends BaseEntity {
id: UUID
content: string
pinned: boolean
mentions: Record<string, UserResponse>
status: PostStatus
attachments: {
Expand Down
3 changes: 3 additions & 0 deletions app/models/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export default class Post extends BaseModel {
@column()
declare status: PostStatus

@column()
declare pinned: boolean

@hasMany(() => PostReaction)
declare reactions: HasMany<typeof PostReaction>

Expand Down
26 changes: 26 additions & 0 deletions app/services/post_pin_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Post from '#models/post'
import db from '@adonisjs/lucid/services/db'
import type { UUID } from 'node:crypto'

export default class PostPinService {
/**
* Returns amount of posts pinned by a user.
*/
async count(userId: UUID): Promise<number> {
const query = (await db
.from('posts')
.where('user_id', userId)
.andWhere('pinned', true)
.count('*')) as [{ count: string }]
return +query[0].count
}

/**
* Handles the action of pinning a post
*/
async pin(post: Post, pin: boolean): Promise<boolean> {
post.pinned = pin
await post.save()
return pin
}
}
6 changes: 4 additions & 2 deletions app/services/posts_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import { PaginatedResponse } from 'app/interfaces/pagination'
import { PostReactionType } from '#enums/post'
import PostReaction from '#models/post_reaction'
import { ModelObject } from '@adonisjs/lucid/types/model'
import type { HttpContext } from '@adonisjs/core/http'
import type { UUID } from 'node:crypto'
import { UserService } from '#services/user_service'
import { UserResponse } from '#interfaces/user'
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
import type { UUID } from 'node:crypto'

export default class PostsService {
private readonly userService: UserService
Expand Down Expand Up @@ -78,6 +78,7 @@ export default class PostsService {
const result = await Post.query()
.where('user_id', userId)
.withScopes((scope) => scope.visible())
.orderBy('pinned', 'desc')
.orderBy('updated_at', 'desc')
.preload('user')
.preload('reactions')
Expand Down Expand Up @@ -185,6 +186,7 @@ export default class PostsService {
status: data.status,
user,
link,
pinned: data.pinned,
attachments,
reactions: {
reacted:
Expand Down
5 changes: 3 additions & 2 deletions config/shield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ const shieldConfig = defineConfig({
* to learn more
*/
csrf: {
enabled: true,
// enabled: true,
enabled: false,
exceptRoutes: [],
enableXsrfCookie: true,
// enableXsrfCookie: true,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},

Expand Down
3 changes: 3 additions & 0 deletions database/factories/post_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ export const PostFactory = factory
.define(Post, async ({ faker }) => {
return {
content: faker.lorem.paragraph(),
pinned: faker.datatype.boolean(),
}
})
.state('pinned', (post) => (post.pinned = true))
.state('unpinned', (post) => (post.pinned = false))
.relation('user', () => User)
.relation('reactions', () => PostReactionFactory)
.relation('reports', () => PostReportFactory)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
protected tableName = 'posts'

async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('pinned').defaultTo(false)
})
}

async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('pinned')
})
}
}
48 changes: 48 additions & 0 deletions inertia/components/generic/scroll_top.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowUpFromDot } from 'lucide-react'

export default function ScrollTop() {
const topRef = useRef<HTMLButtonElement>(null)

function scrollTop() {
if (!window) return
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}

useEffect(() => {
window.addEventListener('scroll', (event) => {
// @ts-ignore
const scroll = event.srcElement.scrollingElement.scrollTop
const classList = topRef.current?.classList

const inClasses = ['fade-in', 'animate-in', 'spin-in-90']
const outClasses = ['fade-out', 'animate-out', 'spin-out-90']

if (scroll > 1000) {
outClasses.map((className) => classList?.remove(className))
inClasses.map((className) => classList?.add(className))
} else {
inClasses.map((className) => classList?.remove(className))
outClasses.map((className) => classList?.add(className))
}
})
}, [])

return window ? (
<Button
ref={topRef}
onClick={scrollTop}
size="icon"
className="fixed right-4 bottom-4 z-10 duration-1000"
type="button"
>
<ArrowUpFromDot size={15} />
</Button>
) : (
<></>
)
}
26 changes: 17 additions & 9 deletions inertia/components/generic/user_avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { UserResponse } from '#interfaces/user'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { cn } from '@/lib/utils'
import { useState } from 'react'

export const UserAvatar = ({ user, className }: { user: UserResponse; className?: string }) => (
<Avatar className={cn('h-6 w-6', className)}>
<AvatarImage
src={user?.attachments ? user?.attachments?.avatar?.link : '#'}
alt={`${user.fullname}'s avatar image`}
/>
<AvatarFallback>{user.fullname[0] || '-'}</AvatarFallback>
</Avatar>
)
export const UserAvatar = ({ user, className }: { user: UserResponse; className?: string }) => {
const [avatarLoadState, setAvatarLoadState] = useState<'loading' | 'loaded'>('loading')
return (
<Avatar className={cn('h-6 w-6', className)}>
<AvatarImage
onLoad={() => setAvatarLoadState('loaded')}
src={user?.attachments ? user?.attachments?.avatar?.link : '#'}
alt={`${user.fullname}'s avatar image`}
className={cn(
avatarLoadState === 'loaded' ? 'block animate-in fade-in duration-1000' : 'hidden'
)}
/>
<AvatarFallback>{user.fullname[0] || '-'}</AvatarFallback>
</Avatar>
)
}
94 changes: 77 additions & 17 deletions inertia/components/posts/post_card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactElement, useMemo, useState } from 'react'
import { Dispatch, ReactElement, SetStateAction, useMemo, useState } from 'react'
import { Link } from '@inertiajs/react'
import {
ArrowLeft,
Expand All @@ -10,6 +10,7 @@ import {
Pencil,
Trash2,
Flag,
Pin,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Expand All @@ -30,6 +31,10 @@ import { formatDistanceToNow } from 'date-fns'
import { PostReactionType } from '#enums/post'
import { UserResponse } from '#interfaces/user'
import { route } from '@izzyjs/route/client'
import { useToast } from '@/components/ui/use_toast'
import { cn } from '@/lib/utils'

type PostActions = 'update' | 'delete' | 'report' | 'pin'

// NOTE: Would it be better to move this logic to the BE?
function PostContentParser({
Expand Down Expand Up @@ -342,47 +347,91 @@ function PostReaction({

function PostActions({
post,
setPostState,
abilities,
}: {
post: PostResponse
abilities: Partial<Array<'update' | 'delete' | 'report'>>
setPostState: Dispatch<SetStateAction<PostResponse>>
abilities: Partial<Array<PostActions>>
}) {
const actions: Record<'update' | 'delete' | 'report', () => ReactElement> = {
const { toast } = useToast()

async function updatePin() {
const request = await fetch(
route('posts_pins.update', {
params: {
id: post.id,
},
}).path,
{
method: 'post',
headers: {
'content-type': 'application/json',
},
}
)

if (request.ok) {
const { pinned } = await request.json()
setPostState((prevState) => {
return { ...prevState, pinned }
})
} else {
const { message } = await request.json()
toast({ title: 'Unable to pin post', description: message })
}
}

const actions: Record<PostActions, () => ReactElement> = {
update: () => (
<UpdatePost
post={post}
trigger={
<div className="flex flex-row gap-3 items-center w-full hover:cursor-pointer">
<Button className="update-post-trigger" variant="ghost" size="sm-icon">
<Button type="button" className="update-post-trigger" variant="ghost" size="sm-icon">
<Pencil size={15} />
</Button>
<p className="font-normal text-xs text-current">Update</p>
</div>
}
/>
),
delete: () => (
<DeletePost
report: () => (
<ReportPost
post={post}
trigger={
<div className="flex flex-row gap-3 items-center w-full hover:cursor-pointer">
<Button className="delete-post-trigger" variant="ghost" size="sm-icon">
<Trash2 size={15} />
<Button type="button" className="report-post-trigger" variant="ghost" size="sm-icon">
<Flag size={15} />
</Button>
<p className="font-normal text-xs text-current">Delete</p>
<p className="font-normal text-xs text-current">Report</p>
</div>
}
/>
),
report: () => (
<ReportPost
pin: () => (
<div
onClick={updatePin}
className="flex flex-row gap-3 items-center w-full hover:cursor-pointer"
>
<Button type="button" className="pin-post-trigger" variant="ghost" size="sm-icon">
<Pin
className={cn('text-black', post.pinned ? 'fill-slate-400' : 'fill-white')}
size={15}
/>
</Button>
<p className="font-normal text-xs text-current">Pin</p>
</div>
),
delete: () => (
<DeletePost
post={post}
trigger={
<div className="flex flex-row gap-3 items-center w-full hover:cursor-pointer">
<Button className="report-post-trigger" variant="ghost" size="sm-icon">
<Flag size={15} />
<Button type="button" className="delete-post-trigger" variant="ghost" size="sm-icon">
<Trash2 size={15} />
</Button>
<p className="font-normal text-xs text-current">Report</p>
<p className="font-normal text-xs text-current">Delete</p>
</div>
}
/>
Expand Down Expand Up @@ -427,9 +476,11 @@ export default function PostCard({
actions?: boolean
redirect?: boolean
}) {
const [postState, setPostState] = useState<PostResponse>(post)

return (
<article className="flex flex-col w-full border pt-6 px-6 bg-white rounded-sm">
<div className="flex flex-row pb-3 justify-between border-b border-b-gray-200">
<div className="relative flex flex-row pb-3 justify-between border-b border-b-gray-200">
<Link
href={
route('users.show', {
Expand All @@ -453,11 +504,20 @@ export default function PostCard({
</div>
</Link>

{postState.pinned && (
<div id="pinned-post-icon" className="absolute -top-4 -left-4 -rotate-45">
<Pin className="text-blue-400 fill-slate-300" size={12} />
<div className="absolute left-[2px] top-[12px] h-[1px] w-2 bg-blue-400" />
<div className="absolute left-[4px] top-[14px] h-[1px] w-1 bg-blue-400" />
</div>
)}

{/* // TODO: Review on how to best manage this, be wise. Explore habilities viewing send from the BE. */}
{actions && (
<PostActions
post={post}
abilities={post.user.id !== user?.id ? ['report'] : ['update', 'delete']}
post={postState}
setPostState={setPostState}
abilities={post.user.id !== user?.id ? ['report'] : ['update', 'delete', 'pin']}
/>
)}
</div>
Expand Down
Loading

0 comments on commit d18dff2

Please sign in to comment.