diff --git a/.env.example b/.env.example index a769121..fa78d44 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,4 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= S3_BUCKET= +VITE_APP_NAME=SocialAdonis diff --git a/app/controllers/admin_post_reports_controller.ts b/app/controllers/admin_post_reports_controller.ts new file mode 100644 index 0000000..1f20633 --- /dev/null +++ b/app/controllers/admin_post_reports_controller.ts @@ -0,0 +1,59 @@ +import { PageObject } from '@adonisjs/inertia/types' +import { inject } from '@adonisjs/core' +import AdminPostReportService from '#services/admin_post_report_service' +import { PaginatedResponse } from '#interfaces/pagination' +import { PostReportResponse } from '#interfaces/post' +import { adminUpdatePostReportValidator } from '#validators/post_report' +import PostReport from '#models/post_report' +import { errorsReducer } from '#utils/index' +import { errors } from '@vinejs/vine' +import type { HttpContext } from '@adonisjs/core/http' + +@inject() +export default class AdminPostReportsController { + constructor(private readonly service: AdminPostReportService) {} + + async index(ctx: HttpContext): Promise< + | string + | PageObject<{ + reports: PaginatedResponse + }> + > { + const currentUserId = ctx.auth.user?.id! + const page = ctx.request.qs().page || 1 + + const filters: Record<'reason' | 'status', string[] | null> = { + reason: ctx.request.qs().reason ? [ctx.request.qs().reason].flat() : null, + status: ctx.request.qs().status ? [ctx.request.qs().status].flat() : null, + } + + const reports = await this.service.index(currentUserId, filters, page) + + return ctx.inertia.render('admin/post_reports/index', { + reports, + }) + } + + async update(ctx: HttpContext) { + const reportId = ctx.request.params().id + const report = await PostReport.findOrFail(reportId) + + if (await ctx.bouncer.with('PostReportPolicy').denies('edit', report)) { + return ctx.response.forbidden('Only admin is able to take action on report status.') + } + + try { + const payload = ctx.request.body() + const data = await adminUpdatePostReportValidator.validate(payload) + report.status = data.status + await report.save() + return this.index(ctx) + } catch (error) { + if (error instanceof errors.E_VALIDATION_ERROR) { + const reducedErrors = errorsReducer(error.messages) + return ctx.response.badRequest(reducedErrors) + } + return ctx.response.badRequest() + } + } +} diff --git a/app/controllers/feed_controller.ts b/app/controllers/feed_controller.ts index 54656d2..566ca0f 100644 --- a/app/controllers/feed_controller.ts +++ b/app/controllers/feed_controller.ts @@ -21,6 +21,7 @@ export default class FeedController { const posts = await Post.query() .orderBy('updated_at', 'desc') + .withScopes((scope) => scope.visible()) .preload('user') .preload('reactions') .paginate(page, 10) diff --git a/app/interfaces/post.ts b/app/interfaces/post.ts index 681dcfb..7626fda 100644 --- a/app/interfaces/post.ts +++ b/app/interfaces/post.ts @@ -2,7 +2,7 @@ import { BaseEntity } from '#interfaces/base_entity' import { UUID } from 'node:crypto' import { UserResponse } from './user' import { AttachmentResponse } from 'app/interfaces/attachment' -import { PostReactionType } from '#enums/post' +import { PostReactionType, PostReportReason, PostReportStatus } from '#enums/post' export interface LinkMetadataJSONResponse { title: string @@ -30,3 +30,14 @@ export interface PostResponse extends BaseEntity { total: number } } + +export interface PostReportResponse extends BaseEntity { + id: UUID + reason: PostReportReason + status: PostReportStatus + description: string + userId: UUID + postId: UUID + post: PostResponse & { reportCount: number } + user: UserResponse +} diff --git a/app/middleware/auth_middleware.ts b/app/middleware/auth_middleware.ts index b2a662d..0309ea9 100644 --- a/app/middleware/auth_middleware.ts +++ b/app/middleware/auth_middleware.ts @@ -13,7 +13,8 @@ export default class AuthMiddleware { redirectTo = '/auth/sign-in' /** - * The URL to redirect to, when authentication fails + * + * The URL to redirect the admin to, when authentication fails */ adminRedirectTo = '/admin/auth/sign-in' @@ -26,7 +27,6 @@ export default class AuthMiddleware { ) { let guard: keyof Authenticators = 'web' if (ctx.route?.pattern.includes('admin')) guard = 'admin-web' - try { await ctx.auth.authenticateUsing([guard]) return next() diff --git a/app/models/post.ts b/app/models/post.ts index bcd98b6..0c7be83 100644 --- a/app/models/post.ts +++ b/app/models/post.ts @@ -8,6 +8,7 @@ import { column, computed, hasMany, + scope, } from '@adonisjs/lucid/orm' import User from '#models/user' import { extractFirstLink, sanitizePostContent } from '#utils/index' @@ -45,6 +46,10 @@ export default class Post extends BaseModel { @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null + static visible = scope((query) => { + query.where('status', PostStatus.PUBLISHED) + }) + @computed() get link(): string | null { return extractFirstLink(this.content) diff --git a/app/services/admin_post_report_service.ts b/app/services/admin_post_report_service.ts new file mode 100644 index 0000000..1ab6e1e --- /dev/null +++ b/app/services/admin_post_report_service.ts @@ -0,0 +1,72 @@ +import { PaginatedResponse } from '#interfaces/pagination' +import { PostReportResponse } from '#interfaces/post' +import PostReport from '#models/post_report' +import PostsService from '#services/posts_service' +import { UserService } from '#services/user_service' +import { inject } from '@adonisjs/core' +import { UUID } from 'node:crypto' + +@inject() +export default class AdminPostReportService { + constructor( + private readonly userService: UserService, + private readonly postService: PostsService + ) {} + + async index( + currentUserId: UUID, + filters: Record<'reason' | 'status', string[] | null>, + currentPage: number + ): Promise> { + let query = PostReport.query() + .orderBy('updated_at', 'desc') + .preload('post', (post) => { + post.preload('user') + post.withCount('reports', (q) => q.as('reportsCount')) + }) + .preload('user') + + if (filters.reason) { + query.whereIn('reason', filters.reason) + } + + if (filters.status) { + query.whereIn('status', filters.status) + } + + const result = await query.paginate(currentPage, 10) + + const { meta } = result.toJSON() + + const serialized = [] + for (const record of result) { + const resource = await this.serialize(currentUserId, record) + serialized.push(resource) + } + + return { + data: serialized, + meta, + } + } + + private async serialize(currentUserId: UUID, report: PostReport): Promise { + const user = await this.userService.serialize(report.user) + const post = await this.postService.serialize(currentUserId, report.post) + const data = report.toJSON() + + const resource: PostReportResponse = { + id: report.id, + postId: data.postId, + userId: data.userId, + reason: data.reason, + status: data.status, + description: data.description, + post: { ...post, reportCount: report.post.$extras.reportsCount }, + user, + updatedAt: data.updatedAt, + createdAt: data.createdAt, + } + return resource + } +} diff --git a/app/services/posts_service.ts b/app/services/posts_service.ts index 324e938..24d5fc9 100644 --- a/app/services/posts_service.ts +++ b/app/services/posts_service.ts @@ -56,6 +56,7 @@ export default class PostsService { */ async findOne(id: UUID): Promise { const result: Post[] | null = await Post.query() + .withScopes((scope) => scope.visible()) .where('id', id) .preload('user') .preload('reactions') @@ -72,6 +73,7 @@ export default class PostsService { ): Promise> { const result = await Post.query() .where('user_id', userId) + .withScopes((scope) => scope.visible()) .orderBy('updated_at', 'desc') .preload('user') .preload('reactions') diff --git a/app/validators/post_report.ts b/app/validators/post_report.ts index 2209ce4..3522762 100644 --- a/app/validators/post_report.ts +++ b/app/validators/post_report.ts @@ -1,4 +1,4 @@ -import { PostReportReason } from '#enums/post' +import { PostReportReason, PostReportStatus } from '#enums/post' import vine from '@vinejs/vine' /** @@ -10,3 +10,12 @@ export const postReportValidator = vine.compile( description: vine.string(), }) ) + +/** + * Validates the administrator post-report update action payload + */ +export const adminUpdatePostReportValidator = vine.compile( + vine.object({ + status: vine.enum([PostReportStatus.ACCEPTED, PostReportStatus.REJECTED]), + }) +) diff --git a/config/inertia.ts b/config/inertia.ts index 575510c..d3957c5 100644 --- a/config/inertia.ts +++ b/config/inertia.ts @@ -22,6 +22,7 @@ const inertiaConfig = defineConfig({ return null } }, + queryParams: (ctx) => ctx.request.qs(), errors: (ctx) => ctx.session?.flashMessages.get('errors'), }, diff --git a/database/factories/user_factory.ts b/database/factories/user_factory.ts index 08fae66..5b98c83 100644 --- a/database/factories/user_factory.ts +++ b/database/factories/user_factory.ts @@ -1,15 +1,17 @@ import factory from '@adonisjs/lucid/factories' -import User from '#models/user' +import User, { AccountRole } from '#models/user' import { PostFactory } from '#database/factories/post_factory' export const UserFactory = factory .define(User, async ({ faker }) => { return { + role: AccountRole.USER, name: faker.person.firstName(), surname: faker.person.lastName(), email: faker.internet.email(), password: faker.internet.password(), } }) + .state('admin', (user) => (user.role = AccountRole.ADMIN)) .relation('posts', () => PostFactory) .build() diff --git a/database/migrations/1732037163235_create_add_uniqueness_to_post_reports_and_delete_cascades_table.ts b/database/migrations/1732037163235_create_add_uniqueness_to_post_reports_and_delete_cascades_table.ts new file mode 100644 index 0000000..cb749a2 --- /dev/null +++ b/database/migrations/1732037163235_create_add_uniqueness_to_post_reports_and_delete_cascades_table.ts @@ -0,0 +1,21 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'post_reports' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.dropForeign('post_id'); + table.uuid('post_id').references('posts.id').notNullable().onDelete('CASCADE').alter() + table.unique(['user_id', 'post_id']) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropForeign('post_id'); + table.uuid('post_id').references('posts.id').notNullable().alter() + table.dropUnique(['user_id', 'post_id']) + }) + } +} diff --git a/inertia/app/admin_layout.tsx b/inertia/app/admin_layout.tsx index 132e6a6..98d2bc9 100644 --- a/inertia/app/admin_layout.tsx +++ b/inertia/app/admin_layout.tsx @@ -13,7 +13,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { <> -
{children}
+
{children}
) diff --git a/inertia/app/app.tsx b/inertia/app/app.tsx index b1c1a02..41da93b 100644 --- a/inertia/app/app.tsx +++ b/inertia/app/app.tsx @@ -8,11 +8,11 @@ import { resolvePageComponent } from '@adonisjs/inertia/helpers' import Layout from '@/app/layout' import AdminLayout from '@/app/admin_layout' -const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS' +const appName = import.meta.env.VITE_APP_NAME createInertiaApp({ progress: { color: '#5468FF' }, - title: (title: string) => `${title} - ${appName}`, + title: (title: string) => `${appName} ${!!title && '|'} ${title}`, resolve: async (name: string) => { const page: any = await resolvePageComponent( `../pages/${name}.tsx`, diff --git a/inertia/components/admin/generic/nav.tsx b/inertia/components/admin/generic/nav.tsx index d078f26..2239bea 100644 --- a/inertia/components/admin/generic/nav.tsx +++ b/inertia/components/admin/generic/nav.tsx @@ -12,8 +12,9 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import AdonisLogo from '@/components/svg/logo' -import { UserResponse } from '#interfaces/user' import { cn } from '@/lib/utils' +import { UserResponse } from '#interfaces/user' +import { PostReportStatus } from '#enums/post' export default function NavBar({ user }: { user: UserResponse | null }) { const LINKS: Record<'title' | 'link', string>[] = [ @@ -23,7 +24,8 @@ export default function NavBar({ user }: { user: UserResponse | null }) { }, { title: 'Reports', - link: '/admin/index', + // FIX-ME: izzy. + link: `/admin/posts/reports?status[]=${PostReportStatus.PENDING}`, }, ] @@ -37,6 +39,7 @@ export default function NavBar({ user }: { user: UserResponse | null }) { {title} diff --git a/inertia/components/posts/feed_list.tsx b/inertia/components/posts/feed_list.tsx index 434a422..9ef3b12 100644 --- a/inertia/components/posts/feed_list.tsx +++ b/inertia/components/posts/feed_list.tsx @@ -33,6 +33,7 @@ export default function FeedList({ `${url}${meta.nextPageUrl}`, {}, { + only: ['posts'], preserveState: true, preserveScroll: true, onSuccess: () => { diff --git a/inertia/components/posts/post_card.tsx b/inertia/components/posts/post_card.tsx index 17384d2..4bceb72 100644 --- a/inertia/components/posts/post_card.tsx +++ b/inertia/components/posts/post_card.tsx @@ -26,9 +26,9 @@ import { AttachmentResponse } from 'app/interfaces/attachment' import { formatDistanceToNow } from 'date-fns' import { PostReactionType } from '#enums/post' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover_card' -import type { UUID } from 'node:crypto' import { UserResponse } from '#interfaces/user' import { ReportPost } from '@/components/posts/report' +import type { UUID } from 'node:crypto' const userLink = (id: UUID) => `/users/${id}` const postLink = (id: UUID) => `/posts/${id}` @@ -196,9 +196,11 @@ function PostGallery({ attachments }: { attachments: AttachmentResponse[] }) { } function PostReaction({ + actions, post, currentUser, }: { + actions: boolean post: PostResponse currentUser: UserResponse | null }) { @@ -280,35 +282,44 @@ function PostReaction({ return (
- - - - - - {Object.entries(REACTIONS).map(([key, value]: [key: string, value: string]) => ( + {actions ? ( + + - ))} - - + + + {Object.entries(REACTIONS).map(([key, value]: [key: string, value: string]) => ( + + ))} + + + ) : ( + + )}

{countStatus}

) @@ -316,10 +327,10 @@ function PostReaction({ function PostActions({ post, - habilities, + abilities, }: { post: PostResponse - habilities: Partial> + abilities: Partial> }) { const actions: Record<'update' | 'delete' | 'report', () => ReactElement> = { update: () => ( @@ -372,7 +383,7 @@ function PostActions({ {Object.entries(actions).map(([action, Element], index) => { - if (habilities.includes(action as 'update' | 'delete' | 'report')) { + if (abilities.includes(action as 'update' | 'delete' | 'report')) { return ( )} @@ -450,7 +461,7 @@ export default function PostCard({
- +
) diff --git a/inertia/components/ui/command.tsx b/inertia/components/ui/command.tsx new file mode 100644 index 0000000..68e22e7 --- /dev/null +++ b/inertia/components/ui/command.tsx @@ -0,0 +1,141 @@ +import * as React from 'react' +import { type DialogProps } from '@radix-ui/react-dialog' +import { Command as CommandPrimitive } from 'cmdk' +import { Search } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { Dialog, DialogContent } from '@/components/ui/dialog' + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = 'CommandShortcut' + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/inertia/components/ui/multi_select.tsx b/inertia/components/ui/multi_select.tsx new file mode 100644 index 0000000..a3def5b --- /dev/null +++ b/inertia/components/ui/multi_select.tsx @@ -0,0 +1,342 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { CheckIcon, ChevronDown, XIcon, WandSparkles, X } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command' + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva( + 'm-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300', + { + variants: { + variant: { + default: 'border-foreground/10 text-foreground bg-card hover:bg-card/80', + secondary: + 'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + inverted: 'inverted', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string + /** The unique value associated with the option. */ + value: string + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }> + }[] + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void + + /** The default selected values when the component mounts. */ + defaultValue?: string[] + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string +} + +export const MultiSelect = React.forwardRef( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = 'Select options', + animation = 0, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = React.useState(defaultValue) + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) + const [isAnimating, setIsAnimating] = React.useState(false) + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + setIsPopoverOpen(true) + } else if (event.key === 'Backspace' && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues] + newSelectedValues.pop() + setSelectedValues(newSelectedValues) + onValueChange(newSelectedValues) + } + } + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option] + setSelectedValues(newSelectedValues) + onValueChange(newSelectedValues) + } + + const handleClear = () => { + setSelectedValues([]) + onValueChange([]) + } + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev) + } + + const clearExtraOptions = () => { + const newSelectedValues = selectedValues.slice(0, maxCount) + setSelectedValues(newSelectedValues) + onValueChange(newSelectedValues) + } + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear() + } else { + const allValues = options.map((option) => option.value) + setSelectedValues(allValues) + onValueChange(allValues) + } + } + + return ( + + + + + setIsPopoverOpen(false)} + > + + + + No results found. + + +
+ +
+ (Select All) +
+ {options.map((option) => { + const isSelected = selectedValues.includes(option.value) + return ( + toggleOption(option.value)} + className="cursor-pointer" + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ) + })} +
+ + +
+ {selectedValues.length > 0 && ( + <> + + Clear + +
+ + )} + setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full" + > + Close + +
+
+
+
+
+ {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
+ ) + } +) + +MultiSelect.displayName = 'MultiSelect' diff --git a/inertia/components/ui/pagination.tsx b/inertia/components/ui/pagination.tsx new file mode 100644 index 0000000..1a2eda6 --- /dev/null +++ b/inertia/components/ui/pagination.tsx @@ -0,0 +1,148 @@ +import * as React from 'react' +import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { ButtonProps, buttonVariants } from '@/components/ui/button' +import { MetaResponse } from '#interfaces/pagination' +import { InertiaLinkProps, Link } from '@inertiajs/react' + +const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( +