Merge pull request #65 from waffle-lord/impl/mod-card-moderation-options

Impl/mod moderation options
This commit is contained in:
Refringe 2025-02-06 07:51:19 -05:00 committed by GitHub
commit 36222169fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 318 additions and 29 deletions

View File

@ -0,0 +1,103 @@
<?php
namespace App\Livewire\Mod;
use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use Livewire\Component;
class ModerationActionButton extends Component
{
public ?string $moderatedObjectId = null;
public string $guid = '';
public string $actionType;
public string $targetType = '';
public bool $allowActions = false;
public bool $isRunning = false;
protected $listeners = ['refreshComponent' => '$refresh'];
public function mount(): void
{
$this->guid = uniqid('', true);
}
public function render()
{
$this->allowActions = ! $this->isRunning;
return view('livewire.mod.moderation-action-button');
}
public function runActionEvent(): void
{
$this->isRunning = true;
$this->dispatch("startAction.{$this->guid}");
}
#[On('startAction.{guid}')]
public function invokeAction(): void
{
if ($this->moderatedObjectId == null || $this->moderatedObjectId == '') {
Log::info('Failed: no ID specified.');
return;
}
Log::info("Object ID: $this->moderatedObjectId");
if ($this->targetType !== 'mod' && $this->targetType !== 'modVersion') {
Log::info('Failed: invalid target type.');
return;
}
switch ($this->targetType) {
case 'mod':
$moderatedObject = Mod::where('id', '=', $this->moderatedObjectId)->first();
break;
case 'modVersion':
$moderatedObject = ModVersion::where('id', '=', $this->moderatedObjectId)->first();
break;
default:
Log::info('Failed: invalid target type.');
return;
}
if ($moderatedObject == null) {
Log::info('Failed: moderated object is null');
return;
}
switch ($this->actionType) {
case 'delete':
$moderatedObject->delete();
break;
case 'enable':
case 'disable':
$moderatedObject->toggleDisabled();
break;
default:
Log::info('Failed: invalid action type.');
return;
}
$this->js('window.location.reload()');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Mod;
use Livewire\Component;
class ModerationOptions extends Component
{
public string $objectId;
public string $targetType;
public bool $disabled;
public string $displayName;
public bool $showDeleteDialog = false;
public bool $showDisableDialog = false;
public function render()
{
return view('livewire.mod.moderation-options');
}
public function confirmDelete(): void
{
$this->showDeleteDialog = true;
}
public function confirmDisable(): void
{
$this->showDisableDialog = true;
}
}

View File

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Http\Filters\V1\QueryFilter; use App\Http\Filters\V1\QueryFilter;
use App\Models\Scopes\DisabledScope;
use App\Models\Scopes\PublishedScope; use App\Models\Scopes\PublishedScope;
use App\Traits\CanModerate;
use Database\Factories\ModFactory; use Database\Factories\ModFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -54,6 +54,7 @@ use Override;
*/ */
class Mod extends Model class Mod extends Model
{ {
use CanModerate;
/** @use HasFactory<ModFactory> */ /** @use HasFactory<ModFactory> */
use HasFactory; use HasFactory;
@ -66,7 +67,6 @@ class Mod extends Model
#[Override] #[Override]
protected static function booted(): void protected static function booted(): void
{ {
static::addGlobalScope(new DisabledScope);
static::addGlobalScope(new PublishedScope); static::addGlobalScope(new PublishedScope);
} }

View File

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Exceptions\InvalidVersionNumberException; use App\Exceptions\InvalidVersionNumberException;
use App\Models\Scopes\DisabledScope;
use App\Models\Scopes\PublishedScope; use App\Models\Scopes\PublishedScope;
use App\Support\Version; use App\Support\Version;
use App\Traits\CanModerate;
use Database\Factories\ModVersionFactory; use Database\Factories\ModVersionFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -50,6 +50,7 @@ use Override;
*/ */
class ModVersion extends Model class ModVersion extends Model
{ {
use CanModerate;
/** @use HasFactory<ModVersionFactory> */ /** @use HasFactory<ModVersionFactory> */
use HasFactory; use HasFactory;
@ -68,8 +69,6 @@ class ModVersion extends Model
#[Override] #[Override]
protected static function booted(): void protected static function booted(): void
{ {
static::addGlobalScope(new DisabledScope);
static::addGlobalScope(new PublishedScope); static::addGlobalScope(new PublishedScope);
static::saving(function (ModVersion $modVersion): void { static::saving(function (ModVersion $modVersion): void {

View File

@ -192,6 +192,14 @@ class User extends Authenticatable implements MustVerifyEmail
return Str::lower($this->role?->name) === 'administrator'; return Str::lower($this->role?->name) === 'administrator';
} }
/**
* Conveniently check is a user is a mod or an admin
*/
public function isModOrAdmin(): bool
{
return $this->isMod() || $this->isAdmin();
}
/** /**
* Overwritten to instead use the queued version of the VerifyEmail notification. * Overwritten to instead use the queued version of the VerifyEmail notification.
*/ */

View File

@ -22,7 +22,11 @@ class ModPolicy
*/ */
public function view(?User $user, Mod $mod): bool public function view(?User $user, Mod $mod): bool
{ {
// Everyone can view mods. // Everyone can view mods, unless they are disabled.
if ($mod->disabled && ! $user?->isModOrAdmin()) {
return false;
}
return true; return true;
} }
@ -39,7 +43,7 @@ class ModPolicy
*/ */
public function update(User $user, Mod $mod): bool public function update(User $user, Mod $mod): bool
{ {
return false; return $user->isMod() || $user->isAdmin() || $mod->users->contains($user);
} }
/** /**
@ -47,7 +51,10 @@ class ModPolicy
*/ */
public function delete(User $user, Mod $mod): bool public function delete(User $user, Mod $mod): bool
{ {
return false; // I'm guessing we want the mod author to also be able to do this?
// what if there are multiple authors?
// I'm leaving that out for now -waffle.lazy
return $user->isAdmin();
} }
/** /**

View File

@ -0,0 +1,12 @@
<?php
namespace App\Traits;
trait CanModerate
{
public function toggleDisabled(): void
{
$this->disabled = ! $this->disabled;
$this->save();
}
}

View File

@ -120,27 +120,34 @@ main a:not(.mod-list-component):not(.tab):not([role="menuitem"]) {
} }
} }
.ribbon { @layer components {
font-size: 18px; .ribbon {
font-weight: bold; font-size: 18px;
color: #fff; font-weight: bold;
@apply text-white bg-cyan-500 dark:bg-cyan-700;
}
.ribbon {
--f: .5em;
position: absolute;
top: 0;
left: 0;
line-height: 1.5;
padding-inline: 1lh;
padding-bottom: var(--f);
border-image: conic-gradient(#0008 0 0) 51%/var(--f);
clip-path: polygon(100% calc(100% - var(--f)), 100% 100%, calc(100% - var(--f)) calc(100% - var(--f)), var(--f) calc(100% - var(--f)), 0 100%, 0 calc(100% - var(--f)), 999px calc(100% - var(--f) - 999px), calc(100% - 999px) calc(100% - var(--f) - 999px));
transform: translate(calc((cos(45deg) - 1) * 100%), -100%) rotate(-45deg);
transform-origin: 100% 100%;
}
} }
.ribbon { .ribbon-red {
--f: .5em; @apply ribbon;
position: absolute; @apply text-white bg-red-500 dark:bg-red-700;
top: 0;
left: 0;
line-height: 1.5;
padding-inline: 1lh;
padding-bottom: var(--f);
border-image: conic-gradient(#0008 0 0) 51%/var(--f);
clip-path: polygon(100% calc(100% - var(--f)), 100% 100%, calc(100% - var(--f)) calc(100% - var(--f)), var(--f) calc(100% - var(--f)), 0 100%, 0 calc(100% - var(--f)), 999px calc(100% - var(--f) - 999px), calc(100% - 999px) calc(100% - var(--f) - 999px));
transform: translate(calc((cos(45deg) - 1) * 100%), -100%) rotate(-45deg);
transform-origin: 100% 100%;
background-color: #0e7490;
} }
.rainbow { .rainbow {
height: 100%; height: 100%;
width: 100%; width: 100%;

View File

@ -1,12 +1,15 @@
@props(['mod', 'version']) @props(['mod', 'version'])
<a href="{{ $mod->detailUrl() }}" class="mod-list-component relative mx-auto w-full max-w-2xl"> <a href="{{ $mod->detailUrl() }}" class="mod-list-component relative mx-auto w-full max-w-2xl">
@if ($mod->featured && !request()->routeIs('home')) @if ($mod->featured && !$mod->disabled && !request()->routeIs('home'))
<div class="ribbon z-10">{{ __('Featured!') }}</div> <div class="ribbon z-10">{{ __('Featured!') }}</div>
@endif @endif
@if ($mod->disabled)
<div class="ribbon-red z-10">{{ __('Disabled') }}</div>
@endif
<div class="flex flex-col group h-full w-full bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl overflow-hidden hover:shadow-lg hover:bg-gray-50 dark:hover:bg-black hover:shadow-gray-400 dark:hover:shadow-black transition-colors ease-out duration-700"> <div class="flex flex-col group h-full w-full bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl overflow-hidden hover:shadow-lg hover:bg-gray-50 dark:hover:bg-black hover:shadow-gray-400 dark:hover:shadow-black transition-colors ease-out duration-700">
<div class="h-auto md:h-full md:flex"> <div class="h-auto md:h-full md:flex">
<div class="h-auto md:h-full md:shrink-0 overflow-hidden"> <div class="relative h-auto md:h-full md:shrink-0 overflow-hidden">
@if ($mod->thumbnail) @if ($mod->thumbnail)
<img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200"> <img src="{{ $mod->thumbnailUrl }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
@else @else

View File

@ -173,7 +173,9 @@
@if ($mods->isNotEmpty()) @if ($mods->isNotEmpty())
<div class="my-8 grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="my-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach ($mods as $mod) @foreach ($mods as $mod)
<x-mod-card :mod="$mod" :version="$mod->latestVersion" /> @if(!$mod->disabled || (auth()->check() && auth()->user()->isModOrAdmin()))
<x-mod-card :mod="$mod" :version="$mod->latestVersion" />
@endif
@endforeach @endforeach
</div> </div>
@else @else

View File

@ -0,0 +1,18 @@
<div class="flex gap-4">
@if($this->allowActions)
<x-button x-on:click="show = false">
{{ __('Cancel') }}
</x-button>
<x-danger-button wire:click="runActionEvent">
{{ __(ucfirst($this->actionType)) }}
</x-danger-button>
@endif
@if($this->isRunning)
<div class="text-blue-600">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<style>.spinner_ajPY{transform-origin:center;animation:spinner_AtaB .75s infinite linear}@keyframes spinner_AtaB{100%{transform:rotate(360deg)}}</style>
<path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity=".25"/><path d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" class="spinner_ajPY"/>
</svg>
</div>
@endif
</div>

View File

@ -0,0 +1,79 @@
<div>
<x-dropdown alignment="right" contentClasses="py-1 rounded-full bg-gray-200 dark:bg-gray-800">
<x-slot name="trigger">
<button class="relative text-blue-400 dark:text-blue-500 hover:text-blue-600 dark:hover:text-blue-700">
{{-- Icon (shield with keyhole)--}}
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 5.75V11c0 5.001 2.958 8.676 8.725 10.948a.75.75 0 0 0 .55 0C18.042 19.676 21 16 21 11V5.75a.75.75 0 0 0-.75-.75c-2.663 0-5.258-.943-7.8-2.85a.75.75 0 0 0-.9 0C9.008 4.057 6.413 5 3.75 5a.75.75 0 0 0-.75.75ZM13.995 11a2 2 0 0 1-1.245 1.852v2.398a.75.75 0 0 1-1.5 0v-2.394A2 2 0 1 1 13.995 11Z"/>
</svg>
</button>
</x-slot>
<x-slot name="content">
<div>
<button wire:click.prevent="confirmDisable" class="p-2 h-full w-full text-blue-500 dark:text-blue-500 bg-gray-200 dark:bg-gray-800 hover:text-blue-400 dark:hover:text-blue-400">
<div class="flex">
<span class="pr-2">
@if ($this->disabled)
{{-- Icon (circle with checkmark)--}}
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm3.22 6.97-4.47 4.47-1.97-1.97a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l5-5a.75.75 0 1 0-1.06-1.06Z" />
</svg>
@else
{{-- Icon (circle with dash)--}}
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.001c5.524 0 10 4.477 10 10s-4.476 10-10 10c-5.522 0-10-4.477-10-10s4.478-10 10-10Zm4.25 9.25h-8.5a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5Z" />
</svg>
@endif
</span>
{{$this->disabled ? __('Enable') : __('Disable') }}
</div>
</button>
</div>
@if(auth()->user()->isAdmin())
<div>
<button wire:click.prevent="confirmDelete" class="p-2 h-full w-full text-red-500 dark:text-red-500 bg-gray-200 dark:bg-gray-800 hover:text-red-400 dark:hover:text-red-400">
<div class="flex">
<span class="pr-2">
{{-- Icon (trash can)--}}
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5 6a1 1 0 0 1-.883.993L20.5 7h-.845l-1.231 12.52A2.75 2.75 0 0 1 15.687 22H8.313a2.75 2.75 0 0 1-2.737-2.48L4.345 7H3.5a1 1 0 0 1 0-2h5a3.5 3.5 0 1 1 7 0h5a1 1 0 0 1 1 1Zm-7.25 3.25a.75.75 0 0 0-.743.648L13.5 10v7l.007.102a.75.75 0 0 0 1.486 0L15 17v-7l-.007-.102a.75.75 0 0 0-.743-.648Zm-4.5 0a.75.75 0 0 0-.743.648L9 10v7l.007.102a.75.75 0 0 0 1.486 0L10.5 17v-7l-.007-.102a.75.75 0 0 0-.743-.648ZM12 3.5A1.5 1.5 0 0 0 10.5 5h3A1.5 1.5 0 0 0 12 3.5Z" />
</svg>
</span>
{{ __('Delete') }}
</div>
</button>
</div>
@endif
</x-slot>
</x-dropdown>
@push('modals')
<x-dialog-modal wire:model="showDisableDialog">
<x-slot name="title">
<h2 class="text-2xl">{{__('Confirm')}} {{__($this->disabled ? 'Enable' : 'Disable')}}</h2>
</x-slot>
<x-slot name="content">
<p>Are you sure you want to {{__($this->disabled ? 'enable' : 'disable')}} '{{$this->displayName}}'?</p>
</x-slot>
<x-slot name="footer">
<livewire:mod.moderation-action-button actionType="{{ $this->disabled ? 'enable' : 'disable' }}" :targetType="$targetType" :moderatedObjectId="$objectId" />
</x-slot>
</x-dialog-modal>
@endpush
@push('modals')
<x-dialog-modal wire:model="showDeleteDialog">
<x-slot name="title">
<h2 class="text-2xl">{{ __('Confirm') }} {{ __('Delete') }}</h2>
</x-slot>
<x-slot name="content">
<p>Are you sure you want to {{__('delete')}} '{{$this->displayName}}'?</p>
</x-slot>
<x-slot name="footer">
<livewire:mod.moderation-action-button actionType='delete' :targetType="$targetType" :moderatedObjectId="$objectId" />
</x-slot>
</x-dialog-modal>
@endpush
</div>

View File

@ -11,9 +11,17 @@
{{-- Main Mod Details Card --}} {{-- Main Mod Details Card --}}
<div class="relative p-4 sm:p-6 text-center sm:text-left bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl"> <div class="relative p-4 sm:p-6 text-center sm:text-left bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
@if ($mod->featured) @if (auth()->check() && auth()->user()->isModOrAdmin())
<div class="absolute top-0 right-0 z-50 m-2">
<livewire:mod.moderation-options :objectId="$mod->id" targetType="mod" :displayName="$mod->name" :disabled="$mod->disabled" />
</div>
@endif
@if ($mod->featured && !$mod->disabled)
<div class="ribbon z-10">{{ __('Featured!') }}</div> <div class="ribbon z-10">{{ __('Featured!') }}</div>
@endif @endif
@if ($mod->disabled)
<div class="ribbon-red z-10">{{ __('Disabled') }}</div>
@endif
<div class="flex flex-col sm:flex-row gap-4 sm:gap-6"> <div class="flex flex-col sm:flex-row gap-4 sm:gap-6">
<div class="grow-0 shrink-0 flex justify-center items-center"> <div class="grow-0 shrink-0 flex justify-center items-center">
@if ($mod->thumbnail) @if ($mod->thumbnail)
@ -98,7 +106,15 @@
<div x-show="selectedTab === 'versions'"> <div x-show="selectedTab === 'versions'">
@foreach ($mod->versions as $version) @foreach ($mod->versions as $version)
<div class="p-4 mb-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl"> <div class="p-4 mb-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
@if($version->disabled)
<div class="ribbon-red z-10">{{ __('Disabled') }}</div>
@endif
<div class="pb-6 border-b-2 border-gray-200 dark:border-gray-800"> <div class="pb-6 border-b-2 border-gray-200 dark:border-gray-800">
@if (auth()->check() && auth()->user()->isModOrAdmin())
<div class="absolute top-0 right-0 z-50 m-2">
<livewire:mod.moderation-options :objectId="$version->id" targetType="modVersion" :displayName="$version->version" :disabled="$version->disabled" />
</div>
@endif
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a class="text-2xl font-extrabold text-gray-900 dark:text-white" href="{{ $version->downloadUrl() }}"> <a class="text-2xl font-extrabold text-gray-900 dark:text-white" href="{{ $version->downloadUrl() }}">
{{ __('Version') }} {{ $version->version }} {{ __('Version') }} {{ $version->version }}