Browse Source

Redesigned Admin Dashboard Reports/Moderation

Daniel Supernault 2 years ago
parent
commit
c6cc6327d3

+ 598 - 0
app/Http/Controllers/Admin/AdminReportController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
 use Cache;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Redis;
 use App\Services\AccountService;
 use App\Services\StatusService;
@@ -24,6 +25,13 @@ use Illuminate\Validation\Rule;
 use App\Services\StoryService;
 use App\Services\ModLogService;
 use App\Jobs\DeletePipeline\DeleteAccountPipeline;
+use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
+use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
+use App\Jobs\StatusPipeline\StatusDelete;
+use App\Http\Resources\AdminReport;
+use App\Http\Resources\AdminSpamReport;
+use App\Services\PublicTimelineService;
+use App\Services\NetworkTimelineService;
 
 trait AdminReportController
 {
@@ -74,6 +82,9 @@ trait AdminReportController
 	public function showReport(Request $request, $id)
 	{
 		$report = Report::with('status')->findOrFail($id);
+		if($request->has('ref') && $request->input('ref') == 'email') {
+			return redirect('/i/admin/reports?tab=report&id=' . $report->id);
+		}
 		return view('admin.reports.show', compact('report'));
 	}
 
@@ -200,6 +211,9 @@ trait AdminReportController
 	{
 		$appeal = AccountInterstitial::whereType('post.autospam')
 			->findOrFail($id);
+		if($request->has('ref') && $request->input('ref') == 'email') {
+			return redirect('/i/admin/reports?tab=autospam&id=' . $appeal->id);
+		}
 		$meta = json_decode($appeal->meta);
 		return view('admin.reports.show_spam', compact('appeal', 'meta'));
 	}
@@ -601,4 +615,588 @@ trait AdminReportController
     	Redis::del('email:manual-ignored');
     	return [200];
     }
+
+    public function reportsStats(Request $request)
+    {
+    	$stats = [
+    		'total' => Report::count(),
+    		'open' => Report::whereNull('admin_seen')->count(),
+    		'closed' => Report::whereNotNull('admin_seen')->count(),
+    		'autospam' => AccountInterstitial::whereType('post.autospam')->count(),
+    		'autospam_open' => AccountInterstitial::whereType('post.autospam')->whereNull(['appeal_handled_at'])->count(),
+    		'appeals' => AccountInterstitial::whereNotNull('appeal_requested_at')->whereNull('appeal_handled_at')->count(),
+    		'email_verification_requests' => Redis::scard('email:manual')
+    	];
+    	return $stats;
+    }
+
+    public function reportsApiAll(Request $request)
+    {
+    	$filter = $request->input('filter') == 'closed' ? 'closed' : 'open';
+
+    	$reports = AdminReport::collection(
+    		Report::orderBy('id','desc')
+			->when($filter, function($q, $filter) {
+				return $filter == 'open' ?
+				$q->whereNull('admin_seen') :
+				$q->whereNotNull('admin_seen');
+			})
+			->groupBy(['object_id', 'object_type'])
+			->cursorPaginate(6)
+			->withQueryString()
+		);
+
+		return $reports;
+    }
+
+    public function reportsApiGet(Request $request, $id)
+    {
+    	$report = Report::findOrFail($id);
+    	return new AdminReport($report);
+    }
+
+    public function reportsApiHandle(Request $request)
+    {
+    	$this->validate($request, [
+    		'object_id' => 'required',
+    		'object_type' => 'required',
+    		'id' => 'required',
+    		'action' => 'required|in:ignore,nsfw,unlist,private,delete',
+    		'action_type' => 'required|in:post,profile'
+    	]);
+
+    	$report = Report::whereObjectId($request->input('object_id'))->findOrFail($request->input('id'));
+
+    	if($request->input('action_type') === 'profile') {
+    		return $this->reportsHandleProfileAction($report, $request->input('action'));
+    	} else if($request->input('action_type') === 'post') {
+    		return $this->reportsHandleStatusAction($report, $request->input('action'));
+    	}
+
+    	return $report;
+    }
+
+    protected function reportsHandleProfileAction($report, $action)
+    {
+    	switch($action) {
+    		case 'ignore':
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'nsfw':
+    			if($report->object_type === 'App\Profile') {
+    				$profile = Profile::find($report->object_id);
+    			} else if($report->object_type === 'App\Status') {
+    				$status = Status::find($report->object_id);
+    				if(!$status) {
+    					return [200];
+    				}
+    				$profile = Profile::find($status->profile_id);
+    			}
+
+    			if(!$profile) {
+    				return;
+    			}
+
+    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
+
+    			$profile->cw = true;
+    			$profile->save();
+
+    			foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
+    				$status->is_nsfw = true;
+    				$status->save();
+    				StatusService::del($status->id);
+    				PublicTimelineService::rem($status->id);
+    			}
+
+				ModLogService::boot()
+					->objectUid($profile->id)
+					->objectId($profile->id)
+					->objectType('App\Profile::class')
+					->user(request()->user())
+					->action('admin.user.moderate')
+					->metadata([
+	                    'action' => 'cw',
+	                    'message' => 'Success!'
+                	])
+					->accessLevel('admin')
+					->save();
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'nsfw' => true,
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'unlist':
+    			if($report->object_type === 'App\Profile') {
+    				$profile = Profile::find($report->object_id);
+    			} else if($report->object_type === 'App\Status') {
+    				$status = Status::find($report->object_id);
+    				if(!$status) {
+    					return [200];
+    				}
+    				$profile = Profile::find($status->profile_id);
+    			}
+
+    			if(!$profile) {
+    				return;
+    			}
+
+    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
+
+    			$profile->unlisted = true;
+    			$profile->save();
+
+    			foreach(Status::whereProfileId($profile->id)->whereScope('public')->cursor() as $status) {
+					$status->scope = 'unlisted';
+					$status->visibility = 'unlisted';
+					$status->save();
+					StatusService::del($status->id);
+					PublicTimelineService::rem($status->id);
+    			}
+
+				ModLogService::boot()
+					->objectUid($profile->id)
+					->objectId($profile->id)
+					->objectType('App\Profile::class')
+					->user(request()->user())
+					->action('admin.user.moderate')
+					->metadata([
+	                    'action' => 'unlisted',
+	                    'message' => 'Success!'
+                	])
+					->accessLevel('admin')
+					->save();
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'private':
+    			if($report->object_type === 'App\Profile') {
+    				$profile = Profile::find($report->object_id);
+    			} else if($report->object_type === 'App\Status') {
+    				$status = Status::find($report->object_id);
+    				if(!$status) {
+    					return [200];
+    				}
+    				$profile = Profile::find($status->profile_id);
+    			}
+
+    			if(!$profile) {
+    				return;
+    			}
+
+    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot moderate an admin account.');
+
+    			$profile->unlisted = true;
+    			$profile->save();
+
+    			foreach(Status::whereProfileId($profile->id)->cursor() as $status) {
+					$status->scope = 'private';
+					$status->visibility = 'private';
+					$status->save();
+					StatusService::del($status->id);
+					PublicTimelineService::rem($status->id);
+    			}
+
+				ModLogService::boot()
+					->objectUid($profile->id)
+					->objectId($profile->id)
+					->objectType('App\Profile::class')
+					->user(request()->user())
+					->action('admin.user.moderate')
+					->metadata([
+	                    'action' => 'private',
+	                    'message' => 'Success!'
+                	])
+					->accessLevel('admin')
+					->save();
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'delete':
+				if(config('pixelfed.account_deletion') == false) {
+					abort(404);
+				}
+
+    			if($report->object_type === 'App\Profile') {
+    				$profile = Profile::find($report->object_id);
+    			} else if($report->object_type === 'App\Status') {
+    				$status = Status::find($report->object_id);
+    				if(!$status) {
+    					return [200];
+    				}
+    				$profile = Profile::find($status->profile_id);
+    			}
+
+    			if(!$profile) {
+    				return;
+    			}
+
+    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account.');
+
+				$ts = now()->addMonth();
+
+    			if($profile->user_id) {
+	    			$user = $profile->user;
+					abort_if($user->is_admin, 403, 'You cannot delete admin accounts.');
+					$user->status = 'delete';
+					$user->delete_after = $ts;
+					$user->save();
+    			}
+
+				$profile->status = 'delete';
+				$profile->delete_after = $ts;
+				$profile->save();
+
+				ModLogService::boot()
+					->objectUid($profile->id)
+					->objectId($profile->id)
+					->objectType('App\Profile::class')
+					->user(request()->user())
+					->action('admin.user.delete')
+					->accessLevel('admin')
+					->save();
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+
+    			if($profile->user_id) {
+    				DB::table('oauth_access_tokens')->whereUserId($user->id)->delete();
+    				DB::table('oauth_auth_codes')->whereUserId($user->id)->delete();
+					$user->email = $user->id;
+					$user->password = '';
+					$user->status = 'delete';
+					$user->save();
+					$profile->status = 'delete';
+					$profile->delete_after = now()->addMonth();
+					$profile->save();
+    				AccountService::del($profile->id);
+    				DeleteAccountPipeline::dispatch($user)->onQueue('high');
+    			} else {
+    				$profile->status = 'delete';
+					$profile->delete_after = now()->addMonth();
+					$profile->save();
+    				AccountService::del($profile->id);
+    				DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('high');
+    			}
+    			return [200];
+    		break;
+    	}
+    }
+
+    protected function reportsHandleStatusAction($report, $action)
+    {
+    	switch($action) {
+    		case 'ignore':
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'nsfw':
+    			$status = Status::find($report->object_id);
+
+    			if(!$status) {
+    				return [200];
+    			}
+
+				abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
+    			$status->is_nsfw = true;
+    			$status->save();
+    			StatusService::del($status->id);
+
+				ModLogService::boot()
+					->objectUid($status->profile_id)
+					->objectId($status->profile_id)
+					->objectType('App\Status::class')
+					->user(request()->user())
+					->action('admin.status.moderate')
+					->metadata([
+	                    'action' => 'cw',
+	                    'message' => 'Success!'
+                	])
+					->accessLevel('admin')
+					->save();
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'nsfw' => true,
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'private':
+    			$status = Status::find($report->object_id);
+
+    			if(!$status) {
+    				return [200];
+    			}
+
+				abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
+
+    			$status->scope = 'private';
+    			$status->visibility = 'private';
+    			$status->save();
+    			StatusService::del($status->id);
+				PublicTimelineService::rem($status->id);
+
+				ModLogService::boot()
+					->objectUid($status->profile_id)
+					->objectId($status->profile_id)
+					->objectType('App\Status::class')
+					->user(request()->user())
+					->action('admin.status.moderate')
+					->metadata([
+	                    'action' => 'private',
+	                    'message' => 'Success!'
+                	])
+					->accessLevel('admin')
+					->save();
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'unlist':
+    			$status = Status::find($report->object_id);
+
+    			if(!$status) {
+    				return [200];
+    			}
+
+				abort_if($status->profile->user && $status->profile->user->is_admin, 400, 'Cannot moderate an admin account post.');
+
+    			if($status->scope === 'public') {
+	    			$status->scope = 'unlisted';
+	    			$status->visibility = 'unlisted';
+	    			$status->save();
+	    			StatusService::del($status->id);
+    				PublicTimelineService::rem($status->id);
+    			}
+
+				ModLogService::boot()
+					->objectUid($status->profile_id)
+					->objectId($status->profile_id)
+					->objectType('App\Status::class')
+					->user(request()->user())
+					->action('admin.status.moderate')
+					->metadata([
+	                    'action' => 'unlist',
+	                    'message' => 'Success!'
+                	])
+					->accessLevel('admin')
+					->save();
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+    			return [200];
+    		break;
+
+    		case 'delete':
+    			$status = Status::find($report->object_id);
+
+    			if(!$status) {
+    				return [200];
+    			}
+
+    			$profile = $status->profile;
+
+    			abort_if($profile->user && $profile->user->is_admin, 400, 'Cannot delete an admin account post.');
+
+    			StatusService::del($status->id);
+
+    			if($profile->user_id != null && $profile->domain == null) {
+    				PublicTimelineService::del($status->id);
+    				StatusDelete::dispatch($status)->onQueue('high');
+    			} else {
+    				NetworkTimelineService::del($status->id);
+    				DeleteRemoteStatusPipeline::dispatch($status)->onQueue('high');
+    			}
+
+    			Report::whereObjectId($report->object_id)
+    				->whereObjectType($report->object_type)
+    				->update([
+    					'admin_seen' => now()
+    				]);
+
+    			return [200];
+    		break;
+    	}
+    }
+
+    public function reportsApiSpamAll(Request $request)
+    {
+    	$tab = $request->input('tab', 'home');
+
+		$appeals = AdminSpamReport::collection(
+			AccountInterstitial::orderBy('id', 'desc')
+			->whereType('post.autospam')
+			->whereNull('appeal_handled_at')
+			->cursorPaginate(6)
+			->withQueryString()
+		);
+
+		return $appeals;
+    }
+
+    public function reportsApiSpamHandle(Request $request)
+    {
+    	$this->validate($request, [
+    		'id' => 'required',
+    		'action' => 'required|in:mark-read,mark-not-spam,mark-all-read,mark-all-not-spam,delete-profile',
+    	]);
+
+    	$action = $request->input('action');
+
+		abort_if(
+			$action === 'delete-profile' &&
+			!config('pixelfed.account_deletion'),
+			404,
+			"Cannot delete profile, account_deletion is disabled.\n\n Set `ACCOUNT_DELETION=true` in .env and re-cache config."
+		);
+
+    	$report = AccountInterstitial::with('user')
+    		->whereType('post.autospam')
+    		->whereNull('appeal_handled_at')
+    		->findOrFail($request->input('id'));
+
+    	$this->reportsHandleSpamAction($report, $action);
+    	Cache::forget('admin-dash:reports:spam-count');
+    	Cache::forget('pf:bouncer_v0:exemption_by_pid:' . $report->user->profile_id);
+		Cache::forget('pf:bouncer_v0:recent_by_pid:' . $report->user->profile_id);
+		PublicTimelineService::warmCache(true, 400);
+    	return [$action, $report];
+    }
+
+    public function reportsHandleSpamAction($appeal, $action)
+    {
+    	$meta = json_decode($appeal->meta);
+
+		if($action == 'mark-read') {
+			$appeal->is_spam = true;
+			$appeal->appeal_handled_at = now();
+			$appeal->save();
+		}
+
+		if($action == 'mark-not-spam') {
+			$status = $appeal->status;
+			$status->is_nsfw = $meta->is_nsfw;
+			$status->scope = 'public';
+			$status->visibility = 'public';
+			$status->save();
+
+			$appeal->is_spam = false;
+			$appeal->appeal_handled_at = now();
+			$appeal->save();
+
+			StatusService::del($status->id);
+		}
+
+		if($action == 'mark-all-read') {
+			AccountInterstitial::whereType('post.autospam')
+				->whereItemType('App\Status')
+				->whereNull('appeal_handled_at')
+				->whereUserId($appeal->user_id)
+				->update([
+					'appeal_handled_at' => now(),
+					'is_spam' => true
+				]);
+		}
+
+		if($action == 'mark-all-not-spam') {
+			AccountInterstitial::whereType('post.autospam')
+				->whereItemType('App\Status')
+				->whereUserId($appeal->user_id)
+				->get()
+				->each(function($report) use($meta) {
+					$report->is_spam = false;
+					$report->appeal_handled_at = now();
+					$report->save();
+					$status = Status::find($report->item_id);
+					if($status) {
+						$status->is_nsfw = $meta->is_nsfw;
+						$status->scope = 'public';
+						$status->visibility = 'public';
+						$status->save();
+						StatusService::del($status->id);
+					}
+				});
+		}
+
+		if($action == 'delete-profile') {
+			$user = User::findOrFail($appeal->user_id);
+			$profile = $user->profile;
+
+			if($user->is_admin == true) {
+				$mid = request()->user()->id;
+				abort_if($user->id < $mid, 403, 'You cannot delete an admin account.');
+			}
+
+			$ts = now()->addMonth();
+			$user->status = 'delete';
+			$profile->status = 'delete';
+			$user->delete_after = $ts;
+			$profile->delete_after = $ts;
+			$user->save();
+			$profile->save();
+
+			$appeal->appeal_handled_at = now();
+			$appeal->save();
+
+			ModLogService::boot()
+				->objectUid($user->id)
+				->objectId($user->id)
+				->objectType('App\User::class')
+				->user(request()->user())
+				->action('admin.user.delete')
+				->accessLevel('admin')
+				->save();
+
+			Cache::forget('profiles:private');
+			DeleteAccountPipeline::dispatch($user);
+		}
+    }
+
+    public function reportsApiSpamGet(Request $request, $id)
+    {
+    	$report = AccountInterstitial::findOrFail($id);
+    	return new AdminSpamReport($report);
+    }
 }

+ 38 - 0
app/Http/Resources/AdminReport.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+use App\Services\AccountService;
+use App\Services\StatusService;
+
+class AdminReport extends JsonResource
+{
+    /**
+     * Transform the resource into an array.
+     *
+     * @return array<string, mixed>
+     */
+    public function toArray(Request $request): array
+    {
+    	$res = [
+    		'id' => $this->id,
+    		'reporter' => AccountService::get($this->profile_id, true),
+    		'type' => $this->type,
+    		'object_id' => (string) $this->object_id,
+    		'object_type' => $this->object_type,
+    		'reported' => AccountService::get($this->reported_profile_id, true),
+    		'status' => null,
+    		'reporter_message' => $this->message,
+    		'admin_seen_at' => $this->admin_seen,
+    		'created_at' => $this->created_at,
+    	];
+
+    	if($this->object_id && $this->object_type === 'App\Status') {
+    		$res['status'] = StatusService::get($this->object_id, false);
+    	}
+
+        return $res;
+    }
+}

+ 33 - 0
app/Http/Resources/AdminSpamReport.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+use App\Services\AccountService;
+use App\Services\StatusService;
+
+class AdminSpamReport extends JsonResource
+{
+	/**
+	 * Transform the resource into an array.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public function toArray(Request $request): array
+	{
+		$res = [
+			'id' => $this->id,
+			'type' => $this->type,
+			'status' => null,
+			'read_at' => $this->read_at,
+			'created_at' => $this->created_at,
+		];
+
+		if($this->item_id && $this->item_type === 'App\Status') {
+			$res['status'] = StatusService::get($this->item_id, false);
+		}
+
+		return $res;
+	}
+}

+ 937 - 0
resources/assets/components/admin/AdminReports.vue

@@ -0,0 +1,937 @@
+<template>
+<div>
+    <div class="header bg-primary pb-3 mt-n4">
+        <div class="container-fluid">
+            <div class="header-body">
+                <div class="row align-items-center py-4">
+                    <div class="col-lg-6 col-7">
+                        <p class="display-1 text-white d-inline-block mb-0">Moderation</p>
+                    </div>
+                </div>
+                <div class="row">
+                	<div class="col-12 col-sm-6 col-lg-3">
+                        <div class="mb-3">
+                            <h5 class="text-light text-uppercase mb-0">Active Reports</h5>
+                            <span
+                            	class="text-white h2 font-weight-bold mb-0 human-size"
+                            	data-toggle="tooltip"
+                            	data-placement="bottom"
+                            	:title="stats.open + ' open reports'">
+                            	{{ prettyCount(stats.open) }}
+                            </span>
+                        </div>
+                    </div>
+
+                    <div class="col-12 col-sm-6 col-lg-3">
+                        <div class="mb-3">
+                            <h5 class="text-light text-uppercase mb-0">Active Spam Detections</h5>
+                            <span
+                            	class="text-white h2 font-weight-bold mb-0 human-size"
+                            	data-toggle="tooltip"
+                            	data-placement="bottom"
+                            	:title="stats.autospam_open + ' open spam detections'"
+                            	>{{ prettyCount(stats.autospam_open) }}</span>
+                        </div>
+                    </div>
+
+                    <div class="col-12 col-sm-6 col-lg-3">
+                        <div class="mb-3">
+                            <h5 class="text-light text-uppercase mb-0">Total Reports</h5>
+                            <span
+                            	class="text-white h2 font-weight-bold mb-0 human-size"
+                            	data-toggle="tooltip"
+                            	data-placement="bottom"
+                            	:title="stats.total + ' total reports'"
+                            	>{{ prettyCount(stats.total) }}
+                            </span>
+                        </div>
+                    </div>
+
+                    <div class="col-12 col-sm-6 col-lg-3">
+                        <div class="mb-3">
+                            <h5 class="text-light text-uppercase mb-0">Total Spam Detections</h5>
+                            <span
+                            	class="text-white h2 font-weight-bold mb-0 human-size"
+                            	data-toggle="tooltip"
+                            	data-placement="bottom"
+                            	:title="stats.autospam + ' total spam detections'">
+                            	{{ prettyCount(stats.autospam) }}
+                            </span>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div v-if="!loaded" class="my-5 text-center">
+        <b-spinner />
+    </div>
+
+    <div v-else class="m-n2 m-lg-4">
+        <div class="container-fluid mt-4">
+            <div class="row mb-3 justify-content-between">
+                <div class="col-12">
+                    <ul class="nav nav-pills">
+                        <li class="nav-item">
+                            <a
+                            	:class="['nav-link d-flex align-items-center', { active: tabIndex == 0}]"
+                            	href="#"
+                            	@click.prevent="toggleTab(0)">
+
+                            	<span>Open Reports</span>
+                            	<span
+                            		v-if="stats.open"
+                            		class="badge badge-sm badge-floating badge-danger border-white ml-2"
+                            		style="background-color: red;color:white;font-size:11px;">
+                            		{{prettyCount(stats.open)}}
+                            	</span>
+                            </a>
+                        </li>
+                        <li class="nav-item">
+                            <a
+                            	:class="['nav-link d-flex align-items-center', { active: tabIndex == 2}]"
+                            	href="#"
+                            	@click.prevent="toggleTab(2)">
+
+                            	<span>Spam Detections</span>
+                            	<span
+                            		v-if="stats.autospam_open"
+                            		class="badge badge-sm badge-floating badge-danger border-white ml-2"
+                            		style="background-color: red;color:white;font-size:11px;">
+                            		{{prettyCount(stats.autospam_open)}}
+                            	</span>
+                            </a>
+                        </li>
+                        <li class="d-none d-md-block nav-item">
+                            <a
+                            	:class="['nav-link d-flex align-items-center', { active: tabIndex == 1}]"
+                            	href="#"
+                            	@click.prevent="toggleTab(1)">
+                            	<span>Closed Reports</span>
+                            	<span
+                            		v-if="stats.autospam_open"
+                            		class="badge badge-sm badge-floating badge-secondary border-white ml-2"
+                            		style="font-size:11px;">
+	                            	{{prettyCount(stats.closed)}}
+	                            </span>
+                            </a>
+                        </li>
+                        <li class="d-none d-md-block nav-item">
+                        	<a
+                        		href="/i/admin/reports/email-verifications"
+                        		class="nav-link d-flex align-items-center">
+                        		<span>Email Verification Requests</span>
+                        		<span
+                            		v-if="stats.email_verification_requests"
+                            		class="badge badge-sm badge-floating badge-secondary border-white ml-2"
+                            		style="font-size:11px;">
+	                            	{{prettyCount(stats.email_verification_requests)}}
+	                            </span>
+                        	</a>
+                        </li>
+                        <li class="d-none d-md-block nav-item">
+                        	<a
+                        		href="/i/admin/reports/appeals"
+                        		class="nav-link d-flex align-items-center">
+                        		<span>Appeal Requests</span>
+                        		<span
+                            		v-if="stats.appeals"
+                            		class="badge badge-sm badge-floating badge-secondary border-white ml-2"
+                            		style="font-size:11px;">
+                        			{{ prettyCount(stats.appeals) }}
+                            	</span>
+                        	</a>
+                        </li>
+                    </ul>
+                </div>
+            </div>
+
+            <div v-if="[0, 1].includes(this.tabIndex)" class="table-responsive rounded">
+                <table v-if="reports && reports.length" class="table table-dark">
+                    <thead class="thead-dark">
+                        <tr>
+                        	<th scope="col">ID</th>
+                        	<th scope="col">Report</th>
+                        	<th scope="col">Reported Account</th>
+                        	<th scope="col">Reported By</th>
+                            <th scope="col">Created</th>
+                            <th scope="col">View Report</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr v-for="(report, idx) in reports">
+                            <td class="font-weight-bold text-monospace text-muted align-middle">
+                                <a href="#" @click.prevent="viewReport(report)">
+                                    {{ report.id }}
+                                </a>
+                            </td>
+                            <td class="align-middle">
+                            	<p class="text-capitalize font-weight-bold mb-0" v-html="reportLabel(report)"></p>
+                            </td>
+							<td class="align-middle">
+                            	<a v-if="report.reported && report.reported.id" :href="`/i/web/profile/${report.reported.id}`" target="_blank" class="text-white">
+	                            	<div class="d-flex align-items-center" style="gap:0.61rem;">
+	                            		<img
+	                            			:src="report.reported.avatar"
+	                            			width="30"
+	                            			height="30"
+	                            			style="object-fit: cover;border-radius:30px;"
+	                            			onerror="this.src='/storage/avatars/default.png';this.error=null;">
+
+	                            		<div class="d-flex flex-column">
+	                            			<p class="font-weight-bold mb-0" style="font-size: 14px;">@{{report.reported.username}}</p>
+	                            			<div class="d-flex small text-muted mb-0" style="gap: 0.5rem;">
+	                            				<span>{{report.reported.followers_count}} Followers</span>
+	                            				<span>·</span>
+	                            				<span>Joined {{ timeAgo(report.reported.created_at) }}</span>
+	                            			</div>
+	                            		</div>
+	                            	</div>
+	                            </a>
+                            </td>
+                            <td class="align-middle">
+                            	<a :href="`/i/web/profile/${report.reporter.id}`" target="_blank" class="text-white">
+	                            	<div class="d-flex align-items-center" style="gap:0.61rem;">
+	                            		<img
+	                            			:src="report.reporter.avatar"
+	                            			width="30"
+	                            			height="30"
+	                            			style="object-fit: cover;border-radius:30px;"
+	                            			onerror="this.src='/storage/avatars/default.png';this.error=null;">
+
+	                            		<div class="d-flex flex-column">
+	                            			<p class="font-weight-bold mb-0" style="font-size: 14px;">@{{report.reporter.username}}</p>
+	                            			<div class="d-flex small text-muted mb-0" style="gap: 0.5rem;">
+	                            				<span>{{report.reporter.followers_count}} Followers</span>
+	                            				<span>·</span>
+	                            				<span>Joined {{ timeAgo(report.reporter.created_at) }}</span>
+	                            			</div>
+	                            		</div>
+	                            	</div>
+	                            </a>
+                            </td>
+                            <td class="font-weight-bold align-middle">{{ timeAgo(report.created_at) }}</td>
+                            <td class="align-middle"><a href="#" class="btn btn-primary btn-sm" @click.prevent="viewReport(report)">View</a></td>
+                        </tr>
+                    </tbody>
+                </table>
+
+                <div v-else>
+                	<div class="card card-body p-5">
+                		<div class="d-flex justify-content-between align-items-center flex-column">
+                			<p class="mt-3 mb-0"><i class="far fa-check-circle fa-5x text-success"></i></p>
+                			<p class="lead">{{ tabIndex === 0 ? 'No Active Reports Found!' : 'No Closed Reports Found!' }}</p>
+                		</div>
+                	</div>
+                </div>
+            </div>
+
+            <div v-if="[0, 1].includes(this.tabIndex) && reports.length && (pagination.prev || pagination.next)" class="d-flex align-items-center justify-content-center">
+              	<button
+                    class="btn btn-primary rounded-pill"
+                    :disabled="!pagination.prev"
+                    @click="paginate('prev')">
+                    Prev
+                </button>
+                <button
+                    class="btn btn-primary rounded-pill"
+                    :disabled="!pagination.next"
+                    @click="paginate('next')">
+                    Next
+                </button>
+            </div>
+
+            <div v-if="this.tabIndex === 2" class="table-responsive rounded">
+            	<template v-if="autospamLoaded">
+	                <table v-if="autospam && autospam.length" class="table table-dark">
+	                    <thead class="thead-dark">
+	                        <tr>
+	                        	<th scope="col">ID</th>
+	                        	<th scope="col">Report</th>
+	                        	<th scope="col">Reported Account</th>
+	                            <th scope="col">Created</th>
+	                            <th scope="col">View Report</th>
+	                        </tr>
+	                    </thead>
+	                    <tbody>
+	                        <tr v-for="(report, idx) in autospam">
+	                            <td class="font-weight-bold text-monospace text-muted align-middle">
+	                                <a href="#" @click.prevent="viewSpamReport(report)">
+	                                    {{ report.id }}
+	                                </a>
+	                            </td>
+	                            <td class="align-middle">
+	                            	<p class="text-capitalize font-weight-bold mb-0">Spam Post</p>
+	                            </td>
+								<td class="align-middle">
+	                            	<a v-if="report.status && report.status.account" :href="`/i/web/profile/${report.status.account.id}`" target="_blank" class="text-white">
+		                            	<div class="d-flex align-items-center" style="gap:0.61rem;">
+		                            		<img
+		                            			:src="report.status.account.avatar"
+		                            			width="30"
+		                            			height="30"
+		                            			style="object-fit: cover;border-radius:30px;"
+		                            			onerror="this.src='/storage/avatars/default.png';this.error=null;">
+
+		                            		<div class="d-flex flex-column">
+		                            			<p class="font-weight-bold mb-0" style="font-size: 14px;">@{{report.status.account.username}}</p>
+		                            			<div class="d-flex small text-muted mb-0" style="gap: 0.5rem;">
+		                            				<span>{{report.status.account.followers_count}} Followers</span>
+		                            				<span>·</span>
+		                            				<span>Joined {{ timeAgo(report.status.account.created_at) }}</span>
+		                            			</div>
+		                            		</div>
+		                            	</div>
+		                            </a>
+	                            </td>
+	                            <td class="font-weight-bold align-middle">{{ timeAgo(report.created_at) }}</td>
+	                            <td class="align-middle"><a href="#" class="btn btn-primary btn-sm" @click.prevent="viewSpamReport(report)">View</a></td>
+	                        </tr>
+	                    </tbody>
+	                </table>
+
+	                <div v-else>
+	                	<div class="card card-body p-5">
+	                		<div class="d-flex justify-content-between align-items-center flex-column">
+	                			<p class="mt-3 mb-0"><i class="far fa-check-circle fa-5x text-success"></i></p>
+	                			<p class="lead">No Spam Reports Found!</p>
+	                		</div>
+	                	</div>
+	                </div>
+            	</template>
+
+            	<div v-else class="d-flex align-items-center justify-content-center" style="min-height: 300px;">
+            		<b-spinner />
+            	</div>
+            </div>
+
+            <div v-if="this.tabIndex === 2 && autospamLoaded && autospam && autospam.length" class="d-flex align-items-center justify-content-center">
+              	<button
+                    class="btn btn-primary rounded-pill"
+                    :disabled="!autospamPagination.prev"
+                    @click="autospamPaginate('prev')">
+                    Prev
+                </button>
+                <button
+                    class="btn btn-primary rounded-pill"
+                    :disabled="!autospamPagination.next"
+                    @click="autospamPaginate('next')">
+                    Next
+                </button>
+            </div>
+        </div>
+    </div>
+
+    <b-modal v-model="showReportModal" :title="tabIndex === 0 ? 'View Report' : 'Viewing Closed Report'" :ok-only="true" ok-title="Close" ok-variant="outline-primary">
+    	<div v-if="viewingReportLoading" class="d-flex align-items-center justify-content-center">
+    		<b-spinner />
+    	</div>
+
+    	<template v-else>
+	    	<div v-if="viewingReport" class="list-group">
+	            <div class="list-group-item d-flex align-items-center justify-content-between">
+	                <div class="text-muted small">Type</div>
+	                <div class="font-weight-bold text-capitalize" v-html="reportLabel(viewingReport)"></div>
+	            </div>
+	            <div v-if="viewingReport.admin_seen_at" class="list-group-item d-flex align-items-center justify-content-between">
+	                <div class="text-muted small">Report Closed</div>
+	                <div class="font-weight-bold text-capitalize">{{ formatDate(viewingReport.admin_seen_at) }}</div>
+	            </div>
+	            <div v-if="viewingReport.reporter_message" class="list-group-item d-flex flex-column" style="gap:10px;">
+	                <div class="text-muted small">Message</div>
+	                <p class="mb-0 read-more" style="font-size: 12px;overflow-y: hidden;">{{ viewingReport.reporter_message }}</p>
+	            </div>
+	    	</div>
+
+	    	<div class="list-group list-group-horizontal mt-3">
+		    	<div v-if="viewingReport && viewingReport.reported" class="list-group-item d-flex align-items-center justify-content-between flex-column flex-grow-1" style="gap:0.4rem;">
+	                <div class="text-muted small font-weight-bold mt-n1">Reported Account</div>
+
+					<a v-if="viewingReport.reported && viewingReport.reported.id" :href="`/i/web/profile/${viewingReport.reported.id}`" target="_blank" class="text-primary">
+		            	<div class="d-flex align-items-center" style="gap:0.61rem;">
+		            		<img
+		            			:src="viewingReport.reported.avatar"
+		            			width="30"
+		            			height="30"
+		            			style="object-fit: cover;border-radius:30px;"
+		            			onerror="this.src='/storage/avatars/default.png';this.error=null;">
+
+		            		<div class="d-flex flex-column">
+		            			<p class="font-weight-bold mb-0 text-break" style="font-size: 12px;max-width: 140px;line-height: 16px;" :class="[ viewingReport.reported.is_admin ? 'text-danger': '']">@{{viewingReport.reported.acct}}</p>
+		            			<div class="d-flex text-muted mb-0" style="font-size: 10px;gap: 0.5rem;">
+		            				<span>{{viewingReport.reported.followers_count}} Followers</span>
+		            				<span>·</span>
+		            				<span>Joined {{ timeAgo(viewingReport.reported.created_at) }}</span>
+		            			</div>
+		            		</div>
+		            	</div>
+		            </a>
+		    	</div>
+
+				<div v-if="viewingReport && viewingReport.reporter" class="list-group-item d-flex align-items-center justify-content-between flex-column flex-grow-1" style="gap:0.4rem;">
+	                <div class="text-muted small font-weight-bold mt-n1">Reporter Account</div>
+
+					<a v-if="viewingReport.reporter && viewingReport.reporter.id" :href="`/i/web/profile/${viewingReport.reporter.id}`" target="_blank" class="text-primary">
+		            	<div class="d-flex align-items-center" style="gap:0.61rem;">
+		            		<img
+		            			:src="viewingReport.reporter.avatar"
+		            			width="30"
+		            			height="30"
+		            			style="object-fit: cover;border-radius:30px;"
+		            			onerror="this.src='/storage/avatars/default.png';this.error=null;">
+
+		            		<div class="d-flex flex-column">
+		            			<p class="font-weight-bold mb-0 text-break" style="font-size: 12px;max-width: 140px;line-height: 16px;">@{{viewingReport.reporter.acct}}</p>
+		            			<div class="d-flex text-muted mb-0" style="font-size: 10px;gap: 0.5rem;">
+		            				<span>{{viewingReport.reporter.followers_count}} Followers</span>
+		            				<span>·</span>
+		            				<span>Joined {{ timeAgo(viewingReport.reporter.created_at) }}</span>
+		            			</div>
+		            		</div>
+		            	</div>
+		            </a>
+		    	</div>
+	    	</div>
+
+			<div v-if="viewingReport && viewingReport.object_type === 'App\\Status' && viewingReport.status" class="list-group mt-3">
+				<div v-if="viewingReport && viewingReport.status && viewingReport.status.media_attachments.length" class="list-group-item d-flex flex-column flex-grow-1" style="gap:0.4rem;">
+					<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
+						<div>Reported Post</div>
+						<a class="font-weight-bold" :href="viewingReport.status.url" target="_blank">View</a>
+					</div>
+
+					<img
+						v-if="viewingReport.status.media_attachments[0].type === 'image'"
+						:src="viewingReport.status.media_attachments[0].url"
+						height="140"
+						class="rounded"
+						style="object-fit: cover;"
+						onerror="this.src='/storage/no-preview.png';this.error=null;" />
+
+					<video
+						v-else-if="viewingReport.status.media_attachments[0].type === 'video'"
+						height="140"
+						controls
+						:src="viewingReport.status.media_attachments[0].url"
+						onerror="this.src='/storage/no-preview.png';this.onerror=null;"
+						></video>
+				</div>
+
+				<div v-if="viewingReport && viewingReport.status" class="list-group-item d-flex flex-column flex-grow-1" style="gap:0.4rem;">
+					<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
+						<div>Reported Post Caption</div>
+						<a class="font-weight-bold" :href="viewingReport.status.url" target="_blank">View</a>
+					</div>
+					<p class="mb-0 read-more" style="font-size:12px;overflow-y: hidden">{{ viewingReport.status.content_text }}</p>
+				</div>
+			</div>
+
+	    	<div v-if="viewingReport && viewingReport.admin_seen_at === null" class="mt-4">
+	    		<div v-if="viewingReport && viewingReport.object_type === 'App\\Profile'">
+		    		<button class="btn btn-dark btn-block rounded-pill" @click="handleAction('profile', 'ignore')">Ignore Report</button>
+		    		<hr v-if="viewingReport.reported && viewingReport.reported.id && !viewingReport.reported.is_admin" class="mt-3 mb-1">
+		    		<div
+		    			v-if="viewingReport.reported && viewingReport.reported.id && !viewingReport.reported.is_admin"
+		    			class="d-flex flex-row mt-2"
+		    			style="gap:0.3rem;">
+		    			<button
+		    				class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0"
+		    				@click="handleAction('profile', 'nsfw')">
+		    				Mark all Posts NSFW
+		    			</button>
+		    			<button
+		    				class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0"
+		    				@click="handleAction('profile', 'unlist')">
+		    				Unlist all Posts
+		    			</button>
+		    		</div>
+		    		<button
+		    			v-if="viewingReport.reported && viewingReport.reported.id && !viewingReport.reported.is_admin"
+		    			class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-2"
+		    			@click="handleAction('profile', 'delete')">
+		    			Delete Profile
+		    		</button>
+	    		</div>
+
+	    		<div v-if="viewingReport && viewingReport.object_type === 'App\\Status'">
+		    		<button class="btn btn-dark btn-block rounded-pill" @click="handleAction('post', 'ignore')">Ignore Report</button>
+		    		<hr v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin" class="mt-3 mb-1">
+	    			<div
+	    				v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin"
+	    				class="d-flex flex-row mt-2"
+	    				style="gap:0.3rem;">
+		    			<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'nsfw')">Mark Post NSFW</button>
+		    			<button v-if="viewingReport.status.visibility === 'public'" class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'unlist')">Unlist Post</button>
+		    			<button v-else-if="viewingReport.status.visibility === 'unlisted'" class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'private')">Make Post Private</button>
+	    			</div>
+	    			<div
+	    				v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin"
+	    				class="d-flex flex-row mt-2"
+	    				style="gap:0.3rem;">
+		    			<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'nsfw')">Make all NSFW</button>
+		    			<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'unlist')">Make all Unlisted</button>
+		    			<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'private')">Make all Private</button>
+		    		</div>
+		    		<div v-if="viewingReport && viewingReport.reported && !viewingReport.reported.is_admin">
+						<hr class="my-2">
+						<div class="d-flex flex-row mt-2" style="gap:0.3rem;">
+							<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('post', 'delete')">Delete Post</button>
+							<button class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0" @click="handleAction('profile', 'delete')">Delete Account</button>
+						</div>
+		    		</div>
+	    		</div>
+	    	</div>
+	    </template>
+    </b-modal>
+
+    <b-modal v-model="showSpamReportModal" title="Potential Spam Post Detected" :ok-only="true" ok-title="Close" ok-variant="outline-primary">
+    	<div v-if="viewingSpamReportLoading" class="d-flex align-items-center justify-content-center">
+    		<b-spinner />
+    	</div>
+
+    	<template v-else>
+	    	<div class="list-group list-group-horizontal mt-3">
+		    	<div v-if="viewingSpamReport && viewingSpamReport.status && viewingSpamReport.status.account" class="list-group-item d-flex align-items-center justify-content-between flex-column flex-grow-1" style="gap:0.4rem;">
+	                <div class="text-muted small font-weight-bold mt-n1">Reported Account</div>
+
+					<a v-if="viewingSpamReport.status.account && viewingSpamReport.status.account.id" :href="`/i/web/profile/${viewingSpamReport.status.account.id}`" target="_blank" class="text-primary">
+		            	<div class="d-flex align-items-center" style="gap:0.61rem;">
+		            		<img
+		            			:src="viewingSpamReport.status.account.avatar"
+		            			width="30"
+		            			height="30"
+		            			style="object-fit: cover;border-radius:30px;"
+		            			onerror="this.src='/storage/avatars/default.png';this.error=null;">
+
+		            		<div class="d-flex flex-column">
+		            			<p class="font-weight-bold mb-0 text-break" style="font-size: 12px;max-width: 140px;line-height: 16px;" :class="[ viewingSpamReport.status.account.is_admin ? 'text-danger': '']">@{{viewingSpamReport.status.account.acct}}</p>
+		            			<div class="d-flex text-muted mb-0" style="font-size: 10px;gap: 0.5rem;">
+		            				<span>{{viewingSpamReport.status.account.followers_count}} Followers</span>
+		            				<span>·</span>
+		            				<span>Joined {{ timeAgo(viewingSpamReport.status.account.created_at) }}</span>
+		            			</div>
+		            		</div>
+		            	</div>
+		            </a>
+		    	</div>
+	    	</div>
+
+			<div v-if="viewingSpamReport && viewingSpamReport.status" class="list-group mt-3">
+				<div v-if="viewingSpamReport && viewingSpamReport.status && viewingSpamReport.status.media_attachments.length" class="list-group-item d-flex flex-column flex-grow-1" style="gap:0.4rem;">
+					<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
+						<div>Reported Post</div>
+						<a class="font-weight-bold" :href="viewingSpamReport.status.url" target="_blank">View</a>
+					</div>
+
+					<img
+						v-if="viewingSpamReport.status.media_attachments[0].type === 'image'"
+						:src="viewingSpamReport.status.media_attachments[0].url"
+						height="140"
+						class="rounded"
+						style="object-fit: cover;"
+						onerror="this.src='/storage/no-preview.png';this.error=null;" />
+
+					<video
+						v-else-if="viewingSpamReport.status.media_attachments[0].type === 'video'"
+						height="140"
+						controls
+						:src="viewingSpamReport.status.media_attachments[0].url"
+						onerror="this.src='/storage/no-preview.png';this.onerror=null;"
+						></video>
+				</div>
+
+				<div
+					v-if="viewingSpamReport &&
+						viewingSpamReport.status &&
+						viewingSpamReport.status.content_text &&
+						viewingSpamReport.status.content_text.length"
+					class="list-group-item d-flex flex-column flex-grow-1"
+					style="gap:0.4rem;">
+					<div class="d-flex justify-content-between mt-n1 text-muted small font-weight-bold">
+						<div>Reported Post Caption</div>
+						<a class="font-weight-bold" :href="viewingSpamReport.status.url" target="_blank">View</a>
+					</div>
+					<p class="mb-0 read-more" style="font-size:12px;overflow-y: hidden">{{ viewingSpamReport.status.content_text }}</p>
+				</div>
+			</div>
+
+	    	<div class="mt-4">
+	    		<div>
+		    		<button
+		    			type="button"
+		    			class="btn btn-dark btn-block rounded-pill"
+		    			@click="handleSpamAction('mark-read')">
+		    			Mark as Read
+		    		</button>
+
+		    		<button
+		    			type="button"
+		    			class="btn btn-danger btn-block rounded-pill"
+		    			@click="handleSpamAction('mark-not-spam')">
+		    			Mark As Not Spam
+		    		</button>
+
+		    		<hr class="mt-3 mb-1">
+
+	    			<div
+	    				class="d-flex flex-row mt-2"
+	    				style="gap:0.3rem;">
+		    			<button
+		    				type="button"
+		    				class="btn btn-dark btn-block btn-sm rounded-pill mt-0"
+		    				@click="handleSpamAction('mark-all-read')">
+		    				Mark All As Read
+		    			</button>
+
+		    			<button
+		    				type="button"
+		    				class="btn btn-dark btn-block btn-sm rounded-pill mt-0"
+		    				@click="handleSpamAction('mark-all-not-spam')">
+		    				Mark All As Not Spam
+		    			</button>
+	    			</div>
+
+		    		<div>
+						<hr class="my-2">
+						<div class="d-flex flex-row mt-2" style="gap:0.3rem;">
+							<button
+								type="button"
+								class="btn btn-outline-danger btn-block btn-sm rounded-pill mt-0"
+								@click="handleSpamAction('delete-profile')">
+								Delete Account
+							</button>
+						</div>
+		    		</div>
+	    		</div>
+	    	</div>
+	    </template>
+    </b-modal>
+</div>
+</template>
+
+<script type="text/javascript">
+	export default {
+		data() {
+			return {
+				loaded: false,
+				stats: {
+					total: 0,
+					open: 0,
+					closed: 0,
+					autospam: 0,
+					autospam_open: 0,
+				},
+				tabIndex: 0,
+				reports: [],
+				pagination: {},
+				showReportModal: false,
+				viewingReport: undefined,
+				viewingReportLoading: false,
+				autospam: [],
+				autospamPagination: {},
+				autospamLoaded: false,
+				showSpamReportModal: false,
+				viewingSpamReport: undefined,
+				viewingSpamReportLoading: false
+			}
+		},
+
+		mounted() {
+			let u = new URLSearchParams(window.location.search);
+			if(u.has('tab') && u.has('id') && u.get('tab') === 'autospam') {
+				this.fetchStats(null, '/i/admin/api/reports/spam/all');
+				this.fetchSpamReport(u.get('id'));
+			} else if(u.has('tab') && u.has('id') && u.get('tab') === 'report') {
+				this.fetchStats();
+				this.fetchReport(u.get('id'));
+			} else {
+				window.history.pushState(null, null, '/i/admin/reports');
+				this.fetchStats();
+			}
+
+			this.$root.$on('bv::modal::hide', (bvEvent, modalId) => {
+				window.history.pushState(null, null, '/i/admin/reports');
+			})
+		},
+
+		methods: {
+			toggleTab(idx) {
+				switch(idx) {
+					case 0:
+						this.fetchStats('/i/admin/api/reports/all');
+					break;
+
+					case 1:
+						this.fetchStats('/i/admin/api/reports/all?filter=closed')
+					break;
+
+					case 2:
+						this.fetchStats(null, '/i/admin/api/reports/spam/all');
+					break;
+				}
+				window.history.pushState(null, null, '/i/admin/reports');
+				this.tabIndex = idx;
+			},
+
+            prettyCount(str) {
+                if(str) {
+                   return str.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
+                }
+                return str;
+            },
+
+            timeAgo(str) {
+                if(!str) {
+                    return str;
+                }
+                return App.util.format.timeAgo(str);
+            },
+
+            formatDate(str) {
+            	let date = new Date(str);
+            	return new Intl.DateTimeFormat('default', {
+            		month: 'long',
+            		day: 'numeric',
+            		year: 'numeric',
+            		hour: 'numeric',
+            		minute: 'numeric'
+            	}).format(date);
+            },
+
+            reportLabel(report) {
+            	switch(report.object_type) {
+            		case 'App\\Profile':
+            			return `${report.type} Profile`;
+            		break;
+            		case 'App\\Status':
+            			return `${report.type} Post`;
+            		break;
+            	}
+            },
+
+			fetchStats(fetchReportsUrl = '/i/admin/api/reports/all', fetchSpamUrl = null) {
+				axios.get('/i/admin/api/reports/stats')
+				.then(res => {
+					this.stats = res.data;
+				})
+				.finally(() => {
+					if(fetchReportsUrl) {
+						this.fetchReports(fetchReportsUrl);
+					} else if(fetchSpamUrl) {
+						this.fetchAutospam(fetchSpamUrl);
+					}
+					$('[data-toggle="tooltip"]').tooltip()
+				});
+			},
+
+			fetchReports(url = '/i/admin/api/reports/all') {
+				axios.get(url)
+				.then(res => {
+					this.reports = res.data.data;
+            		this.pagination = {
+                        next: res.data.links.next,
+                        prev: res.data.links.prev
+                    };
+				})
+				.finally(() => {
+					this.loaded = true;
+				});
+			},
+
+            paginate(dir) {
+                event.currentTarget.blur();
+                let url = dir == 'next' ? this.pagination.next : this.pagination.prev;
+                this.fetchReports(url);
+            },
+
+            viewReport(report) {
+            	this.viewingReportLoading = false;
+            	this.viewingReport = report;
+            	this.showReportModal = true;
+            	window.history.pushState(null, null, '/i/admin/reports?tab=report&id=' + report.id);
+            	setTimeout(() => {
+            		pixelfed.readmore()
+            	}, 1000)
+            },
+
+            handleAction(type, action) {
+            	event.currentTarget.blur();
+
+            	this.viewingReportLoading = true;
+
+				if(action !== 'ignore' && !window.confirm(this.getActionLabel(type, action))) {
+					this.viewingReportLoading = false;
+					return;
+				}
+
+				this.loaded = false;
+				axios.post('/i/admin/api/reports/handle', {
+					id: this.viewingReport.id,
+					object_id: this.viewingReport.object_id,
+					object_type: this.viewingReport.object_type,
+					action: action,
+					action_type: type
+				})
+				.catch(err => {
+					swal('Error', err.response.data.error, 'error');
+				})
+				.finally(() => {
+					this.viewingReportLoading = true;
+					this.viewingReport = false;
+					this.showReportModal = false;
+					setTimeout(() => {
+						this.fetchStats();
+					}, 1000);
+				})
+            },
+
+            getActionLabel(type, action) {
+            	if(type === 'profile') {
+            		switch(action) {
+            			case 'ignore':
+            				return 'Are you sure you want to ignore this profile report?';
+            			break;
+
+            			case 'nsfw':
+            				return 'Are you sure you want to mark this profile as NSFW?';
+            			break;
+
+            			case 'unlist':
+            				return 'Are you sure you want to mark all posts by this profile as unlisted?';
+            			break;
+
+            			case 'private':
+            				return 'Are you sure you want to mark all posts by this profile as private?';
+            			break;
+
+            			case 'delete':
+            				return 'Are you sure you want to delete this profile?';
+            			break;
+            		}
+            	} else if(type === 'post') {
+            		switch(action) {
+            			case 'ignore':
+            				return 'Are you sure you want to ignore this post report?';
+            			break;
+
+            			case 'nsfw':
+            				return 'Are you sure you want to mark this post as NSFW?';
+            			break;
+
+            			case 'unlist':
+            				return 'Are you sure you want to mark this post as unlisted?';
+            			break;
+
+            			case 'private':
+            				return 'Are you sure you want to mark this post as private?';
+            			break;
+
+            			case 'delete':
+            				return 'Are you sure you want to delete this post?';
+            			break;
+            		}
+            	}
+            },
+
+            fetchAutospam(url = '/i/admin/api/reports/spam/all') {
+            	axios.get(url)
+            	.then(res => {
+            		this.autospam = res.data.data;
+            		this.autospamPagination = {
+                        next: res.data.links.next,
+                        prev: res.data.links.prev
+                    }
+            	})
+            	.finally(() => {
+            		this.autospamLoaded = true;
+            		this.loaded = true;
+            	})
+            },
+
+            autospamPaginate(dir) {
+                event.currentTarget.blur();
+                let url = dir == 'next' ? this.autospamPagination.next : this.autospamPagination.prev;
+                this.fetchAutospam(url);
+            },
+
+            viewSpamReport(report) {
+            	this.viewingSpamReportLoading = false;
+            	this.viewingSpamReport = report;
+            	this.showSpamReportModal = true;
+            	window.history.pushState(null, null, '/i/admin/reports?tab=autospam&id=' + report.id);
+            	setTimeout(() => {
+            		pixelfed.readmore()
+            	}, 1000)
+            },
+
+            getSpamActionLabel(action) {
+        		switch(action) {
+        			case 'mark-all-read':
+        				return 'Are you sure you want to mark all spam reports by this account as read?';
+        			break;
+
+        			case 'mark-all-not-spam':
+        				return 'Are you sure you want to mark all spam reports by this account as not spam?';
+        			break;
+
+        			case 'delete-profile':
+        				return 'Are you sure you want to delete this profile?';
+        			break;
+        		}
+            },
+
+            handleSpamAction(action) {
+            	event.currentTarget.blur();
+
+            	this.viewingSpamReportLoading = true;
+
+				if(action !== 'mark-not-spam' && action !== 'mark-read' && !window.confirm(this.getSpamActionLabel(action))) {
+					this.viewingSpamReportLoading = false;
+					return;
+				}
+
+				this.loaded = false;
+				axios.post('/i/admin/api/reports/spam/handle', {
+					id: this.viewingSpamReport.id,
+					action: action,
+				})
+				.catch(err => {
+					swal('Error', err.response.data.error, 'error');
+				})
+				.finally(() => {
+					this.viewingSpamReportLoading = true;
+					this.viewingSpamReport = false;
+					this.showSpamReportModal = false;
+					setTimeout(() => {
+						this.fetchStats(null, '/i/admin/api/reports/spam/all');
+					}, 500);
+				})
+            },
+
+            fetchReport(id) {
+            	axios.get('/i/admin/api/reports/get/' + id)
+            	.then(res => {
+            		this.tabIndex = 0;
+            		this.viewReport(res.data.data);
+            	})
+            	.catch(err => {
+            		this.fetchStats();
+            		window.history.pushState(null, null, '/i/admin/reports');
+            	})
+            },
+
+            fetchSpamReport(id) {
+            	axios.get('/i/admin/api/reports/spam/get/' + id)
+            	.then(res => {
+            		this.tabIndex = 2;
+            		this.viewSpamReport(res.data.data);
+            	})
+            	.catch(err => {
+            		this.fetchStats();
+            		window.history.pushState(null, null, '/i/admin/reports');
+            	})
+            }
+		}
+	}
+</script>

+ 8 - 89
resources/views/admin/reports/home.blade.php

@@ -1,93 +1,12 @@
 @extends('admin.partial.template-full')
 
 @section('section')
-	<div class="title mb-3 d-flex justify-content-between align-items-center">
-		<h3 class="font-weight-bold d-inline-block">Reports</h3>
-		<div class="float-right">
-			@if(request()->has('filter') && request()->filter == 'closed')
-			<a class="mr-3 font-weight-light small text-muted" href="{{route('admin.reports')}}">
-				View Open Reports
-			</a>
-			@else
-			<a class="mr-3 font-weight-light small text-muted" href="{{route('admin.reports',['filter'=>'closed'])}}">
-				View Closed Reports
-			</a>
-			@endif
-		</div>
-	</div>
-
-	<div class="col-12 col-md-8 offset-md-2">
-		<div class="mb-4">
-			<a class="btn btn-outline-primary px-5 py-3 mr-3" href="/i/admin/reports/email-verifications">
-				<p class="font-weight-bold h4 mb-0">{{$mailVerifications}}</p>
-				Email Verify {{$mailVerifications == 1 ? 'Request' : 'Requests'}}
-			</a>
-			<a class="btn btn-outline-primary px-5 py-3 mr-3" href="/i/admin/reports/appeals">
-				<p class="font-weight-bold h4 mb-0">{{$ai}}</p>
-				Appeal {{$ai == 1 ? 'Request' : 'Requests'}}
-			</a>
-			<a class="btn btn-outline-primary px-5 py-3" href="/i/admin/reports/autospam">
-				<p class="font-weight-bold h4 mb-0">{{$spam}}</p>
-				Flagged {{$ai == 1 ? 'Post' : 'Posts'}}
-			</a>
-		</div>
-	</div>
-	@if($reports->count())
-	<div class="col-12 col-md-8 offset-md-2">
-		<div class="card shadow-none border">
-			<div class="list-group list-group-flush">
-				@foreach($reports as $report)
-				<div class="list-group-item p-1 {{$report->admin_seen ? 'bg-light' : 'bg-white'}}">
-					<div class="p-0">
-						<div class="media d-flex align-items-center">
-							<a class="text-decoration-none" href="{{$report->url()}}">
-								<img src="{{$report->status->media && $report->status->media->count() ? $report->status->thumb(true) : '/storage/no-preview.png'}}" width="64" height="64" class="rounded border shadow mr-3" style="object-fit: cover">
-							</a>
-							<div class="media-body">
-								<p class="mb-1 small"><span class="font-weight-bold text-uppercase text-danger">{{$report->type}}</span></p>
-								@if($report->reporter && $report->status)
-								<p class="mb-0"><a class="font-weight-bold text-dark" href="{{$report->reporter->url()}}">{{$report->reporter->username}}</a> reported this <a href="{{$report->status->url()}}" class="font-weight-bold text-dark">post</a></p>
-								@else
-								<p class="mb-0 lead">
-									@if(!$report->reporter)
-									<span class="font-weight-bold text-dark">Deleted user</span>
-									@else
-									<a class="font-weight-bold text-dark" href="{{$report->reporter->url()}}">{{$report->reporter->username}}</a> 
-									@endif
-									reported this 
-									@if(!$report->status)
-									<span class="font-weight-bold text-muted">deleted post</span>
-									@else
-									<a href="{{$report->status->url()}}" class="font-weight-bold text-dark">post</a> 
-									@endif
-
-								</p>
-
-								@endif
-							</div>
-							<div class="float-right">
-								@if($report->status)
-								<a class="text-lighter p-2 text-decoration-none" href="{{$report->url()}}">
-									View <i class="fas fa-chevron-right ml-2"></i>
-								</a>
-								@endif
-							</div>
-						</div>
-					</div>
-				</div>
-				@endforeach
-			</div>
-		</div>
-	</div>
-	@else
-	<div class="card shadow-none border">
-		<div class="card-body">
-			<p class="mb-0 p-5 text-center font-weight-bold lead">No reports found</p>
-		</div>
-	</div>
-	@endif
-
-	<div class="d-flex justify-content-center mt-5 small">
-		{{$reports->appends(['layout'=>request()->layout, 'filter' => request()->filter])->links()}}
-	</div>
+</div>
+<admin-reports />
 @endsection
+
+@push('scripts')
+<script type="text/javascript">
+    new Vue({ el: '#panel'});
+</script>
+@endpush

+ 13 - 3
resources/views/admin/reports/show.blade.php

@@ -1,6 +1,14 @@
 @extends('admin.partial.template-full')
 
 @section('section')
+	<div class="bg-primary px-4 py-3 mb-5 rounded d-flex align-items-center justify-content-between">
+		<div style="max-width: 70%;">
+			<p class="lead text-white my-0 font-weight-bold">Try the new Report UI</p>
+			<p class="text-white small mb-0">We are deprecating this Report UI in the next major version release. The updated Report UI is easier, faster and provides more options to handle reports and spam.</p>
+		</div>
+		<a href="/i/admin/reports?tab=report&id={{$report->id}}" class="btn btn-outline-white">View in new Report UI</a>
+	</div>
+
 	<div class="d-flex justify-content-between title mb-3">
 		<div>
 			<p class="font-weight-bold h3">
@@ -16,16 +24,17 @@
 	<div class="row">
 		<div class="col-12 col-md-8 mt-3">
 			<div class="card shadow-none border">
-				@if($report->status->media()->count())
+				@if($report->status && $report->status->media()->count())
 				<img class="card-img-top border-bottom" src="{{$report->status->thumb(true)}}">
 				@endif
 				<div class="card-body">
 					<div class="mt-2 p-3">
-						@if($report->status->caption)
+						@if($report->status && $report->status->caption)
 						<p class="text-break">
 							{{$report->status->media()->count() ? 'Caption' : 'Comment'}}: <span class="font-weight-bold">{{$report->status->caption}}</span>
 						</p>
 						@endif
+						@if($report->status)
 						<p class="mb-0">
 							Like Count: <span class="font-weight-bold">{{$report->status->likes_count}}</span>
 						</p>
@@ -41,7 +50,8 @@
                         <p class="" style="word-break: break-all !important;">
                             Local URL: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$report->status->id}}">{{url('/i/web/post/' . $report->status->id)}}</a></span>
                         </p>
-                        @if($report->status->in_reply_to_id)
+                        @endif
+                        @if($report->status && $report->status->in_reply_to_id)
                         <p class="mt-n3" style="word-break: break-all !important;">
                             Parent Post: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$report->status->in_reply_to_id}}">{{url('/i/web/post/' . $report->status->in_reply_to_id)}}</a></span>
                         </p>

+ 17 - 2
resources/views/admin/reports/show_spam.blade.php

@@ -1,6 +1,13 @@
 @extends('admin.partial.template-full')
 
 @section('section')
+	<div class="bg-primary px-4 py-3 mb-5 rounded d-flex align-items-center justify-content-between">
+		<div style="max-width: 70%;">
+			<p class="lead text-white my-0 font-weight-bold">Try the new Report UI</p>
+			<p class="text-white small mb-0">We are deprecating this Report UI in the next major version release. The updated Report UI is easier, faster and provides more options to handle reports and spam.</p>
+		</div>
+		<a href="/i/admin/reports?tab=autospam&id={{$appeal->id}}" class="btn btn-outline-white">View in new Report UI</a>
+	</div>
 <div class="d-flex justify-content-between title mb-3">
 	<div>
 		<p class="font-weight-bold h3">Autospam</p>
@@ -15,7 +22,7 @@
 		<div class="card shadow-none border">
 			<div class="card-header bg-light h5 font-weight-bold py-4">Unlisted + Content Warning</div>
 			@if($appeal->has_media)
-			<img class="card-img-top border-bottom" src="{{$appeal->status->thumb(true)}}" style="max-height: 40vh;object-fit: contain;">
+			<img class="card-img-top border-bottom" src="{{$appeal->status->thumb(true)}}" style="max-height: 40vh;object-fit: contain;" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0'">
 			@endif
 			<div class="card-body">
 				<div class="mt-2 p-3">
@@ -34,7 +41,7 @@
 						Timestamp: <span class="font-weight-bold">{{now()->parse($meta->created_at)->format('r')}}</span>
 					</p>
 					<p class="" style="word-break: break-all !important;">
-						URL: <span class="font-weight-bold text-primary"><a href="{{$meta->url}}">{{$meta->url}}</a></span>
+						URL: <span class="font-weight-bold text-primary"><a href="/i/web/post/{{$appeal->item_id}}" target="_blank">{{$meta->url}}</a></span>
 					</p>
 				</div>
 			</div>
@@ -49,6 +56,8 @@
 		<hr>
 		<button type="button" class="btn btn-default border btn-block font-weight-bold mb-3 action-btn" data-action="dismiss-all">Mark all as read</button>
 		<button type="button" class="btn btn-light border btn-block font-weight-bold mb-3 action-btn" data-action="approve-all">Mark all as not spam</button>
+		<hr>
+		<button type="button" class="btn btn-light border btn-block font-weight-bold mb-3 action-btn" data-action="mark-spammer">Mark as spammer</button>
 		<button type="button" class="btn btn-danger border btn-block font-weight-bold mb-3 action-btn mb-5" data-action="delete-account">Delete Account</button>
 		@endif
 		<div class="card shadow-none border">
@@ -107,6 +116,12 @@
 				}
 			break;
 
+			case 'mark-spammer':
+				if(!window.confirm('Are you sure you want to mark this account as a spammer?')) {
+					return;
+				}
+			break;
+
 			case 'delete-account':
 				if(!window.confirm('Are you sure you want to delete this account?')) {
 					return;

+ 7 - 0
routes/web.php

@@ -122,6 +122,13 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
 		Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi');
 		Route::get('instances/download-backup', 'AdminController@downloadBackup');
 		Route::post('instances/import-data', 'AdminController@importBackup');
+		Route::get('reports/stats', 'AdminController@reportsStats');
+		Route::get('reports/all', 'AdminController@reportsApiAll');
+		Route::get('reports/get/{id}', 'AdminController@reportsApiGet');
+		Route::post('reports/handle', 'AdminController@reportsApiHandle');
+		Route::get('reports/spam/all', 'AdminController@reportsApiSpamAll');
+		Route::get('reports/spam/get/{id}', 'AdminController@reportsApiSpamGet');
+		Route::post('reports/spam/handle', 'AdminController@reportsApiSpamHandle');
 	});
 });