Club System
This commit is contained in:
21
app/Helpers/HashesHelpers.php
Normal file
21
app/Helpers/HashesHelpers.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use stdClass;
|
||||
|
||||
class HashesHelpers
|
||||
{
|
||||
|
||||
public static function findHashes(string $sha1 ): ?StdClass
|
||||
{
|
||||
return DB::connection('hashes')->table('hashes')->where('sha1', $sha1)->first();
|
||||
}
|
||||
|
||||
public static function getReferenceName( int $id ): ?string
|
||||
{
|
||||
return DB::connection('hashes')->table('dat_reference')->where('id', $id)->first()?->name ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Auth\XenForoUser;
|
||||
use App\Models\Entry;
|
||||
use App\Services\XenforoApiService;
|
||||
|
||||
class XenForoHelpers {
|
||||
|
||||
@@ -34,4 +36,28 @@ class XenForoHelpers {
|
||||
}
|
||||
return self::XF_AVATAR_COLORS[ crc32( $user->data->username ) % count( self::XF_AVATAR_COLORS ) ];
|
||||
}
|
||||
|
||||
public static function updateEntriesCount( int $userId ): void
|
||||
{
|
||||
$count = Entry::where('user_id', $userId)->where('state','published')->count();
|
||||
$service = app(XenforoApiService::class);
|
||||
$service->updateEntriesCount( $count, $userId );
|
||||
}
|
||||
|
||||
public static function entryApproved( Entry $entry ): void
|
||||
{
|
||||
// 1. Update XF Entry count.
|
||||
self::updateEntriesCount( $entry->user_id );
|
||||
|
||||
// 2. Send a private message
|
||||
if( \Auth::user()->user_id === $entry->user_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$moderator = \Auth::user()->username;
|
||||
$title = "Entry approved : {$entry->title}";
|
||||
$message = "Your entry {$entry->title} has been approved by {$moderator}.";
|
||||
|
||||
$service->createConversation([ $entry->user_id ], $title, $message, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,31 +4,47 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\EntryHelpers;
|
||||
use App\Models\Entry;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EntryController extends Controller
|
||||
{
|
||||
|
||||
private const SECTION_TYPES = [ 'translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials' ];
|
||||
private const SECTION_TYPES = ['translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials'];
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
|
||||
return view('entries.index');
|
||||
}
|
||||
|
||||
public function show(string $section, Entry $entry): View
|
||||
{
|
||||
if( ! in_array($section, self::SECTION_TYPES) )
|
||||
if (!in_array($section, self::SECTION_TYPES))
|
||||
abort(404);
|
||||
|
||||
if( $entry->type !== $section )
|
||||
if ($entry->type !== $section)
|
||||
abort(404);
|
||||
|
||||
$comments = EntryHelpers::getLatestComments( $entry );
|
||||
Gate::authorize('viewAny', $entry);
|
||||
|
||||
return view('entries.show', compact('entry', 'section', 'comments' ) );
|
||||
// Permissions.
|
||||
$entryPolicy = match ($entry->state) {
|
||||
'pending' => 'viewPending',
|
||||
'draft' => 'viewDraft',
|
||||
'rejected' => 'viewRejected',
|
||||
'hidden' => 'viewHidden',
|
||||
'locked' => 'viewLocked',
|
||||
'published' => null,
|
||||
'default' => null
|
||||
};
|
||||
|
||||
if ($entryPolicy)
|
||||
Gate::authorize($entryPolicy, $entry);
|
||||
|
||||
$comments = EntryHelpers::getLatestComments($entry);
|
||||
|
||||
return view('entries.show', compact('entry', 'section', 'comments'));
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class FileServerController extends Controller {
|
||||
'filepath' => $data['file_path'],
|
||||
'filesize' => $data['file']['size'],
|
||||
'favorite_server' => $data['favorite_server'],
|
||||
'favorite_at' => time()
|
||||
'favorite_at' => time(),
|
||||
'state' => 'public'
|
||||
], now()->addHours(2) );
|
||||
|
||||
$data['finished'] = true;
|
||||
|
||||
49
app/Http/Controllers/QueueController.php
Normal file
49
app/Http/Controllers/QueueController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\XenForoHelpers;
|
||||
use App\Models\Entry;
|
||||
use App\Services\XenforoService;
|
||||
use Illuminate\Http\Request;
|
||||
class QueueController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$entries = Entry::inQueue()
|
||||
->with(['authors', 'game.platform'])
|
||||
->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END")
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
|
||||
return view('queue.index', compact('entries'));
|
||||
}
|
||||
|
||||
public function updateComment(Request $request, Entry $entry)
|
||||
{
|
||||
$request->validate(['comment' => 'nullable|string|max:2000']);
|
||||
|
||||
$entry->update(['staff_comment' => $request->input('comment')]);
|
||||
|
||||
return back()->with('success', 'Comment updated');
|
||||
}
|
||||
|
||||
public function approve(Request $request, Entry $entry)
|
||||
{
|
||||
// $entry->update(['state' => 'published']);
|
||||
|
||||
XenForoHelpers::entryApproved($entry);
|
||||
|
||||
return back()->with('success', 'Entry approved');
|
||||
}
|
||||
|
||||
public function reject(Request $request, Entry $entry)
|
||||
{
|
||||
$request->validate(['reason' => 'nullable|string|max:2000']);
|
||||
|
||||
$entry->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]);
|
||||
|
||||
return back()->with('success', 'Entry rejected');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\PublicFileExists;
|
||||
use App\Rules\XfUserExists;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -49,12 +50,20 @@ class StoreEntryRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
|
||||
$isEdit = (bool) $this->route('entry');
|
||||
|
||||
$rules = [];
|
||||
$section = $this->route('section');
|
||||
|
||||
$rules['files_uuid'] = 'array|required|min:1';
|
||||
$rules['files_uuid.*'] = 'string';
|
||||
|
||||
if( $isEdit ){
|
||||
$rules['files_state'] = 'array|required|min:1';
|
||||
$rules['files_state.*'] = 'string|in:public,private,archived';
|
||||
}
|
||||
|
||||
if( section_must_not_be( 'translations', $section ) ){
|
||||
$rules['entry_title'] = "required|string|max:255";
|
||||
} else {
|
||||
@@ -78,7 +87,6 @@ class StoreEntryRequest extends FormRequest
|
||||
$rules['new-game-genre'] = 'required_with:new-game-title|integer|nullable|exists:genres,id';
|
||||
}
|
||||
|
||||
|
||||
$rules['hashes'] = 'array|required|min:1';
|
||||
$rules['hashes.*.filename'] = 'required|string|max:512';
|
||||
$rules['hashes.*.hash_crc32'] = 'required|string|max:512';
|
||||
@@ -101,7 +109,25 @@ class StoreEntryRequest extends FormRequest
|
||||
$rules['release_site'] = 'nullable|url|max:500';
|
||||
$rules['youtube_video'] = 'nullable|url|max:500';
|
||||
|
||||
$rules['submit-state'] = 'required|string|in:draft,pending,published';
|
||||
if( $isEdit ){
|
||||
$ss = 'draft,pending,published';
|
||||
if( \Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-hidden', $this->route('entry')) )
|
||||
$ss .= ',hidden';
|
||||
if(\Auth::user()->can('moderate', $this->route('entry')) && \Auth::user()->can('view-locked', $this->route('entry')) )
|
||||
$ss .= ',locked';
|
||||
$rules['submit-state'] = 'required|in:' . $ss;
|
||||
} else {
|
||||
if( $this->user()->can('skip-queue', '\App\Models\Entry') ){
|
||||
$rules['submit-state'] = 'required|string|in:draft,pending,published';
|
||||
} else {
|
||||
$rules['submit-state'] = 'required|string|in:draft,pending';
|
||||
}
|
||||
}
|
||||
|
||||
if( $isEdit && $this->user()->can('moderate', $this->route('entry') ) ){
|
||||
$rules['staff_comment'] = 'nullable|string';
|
||||
$rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Helpers\HashesHelpers;
|
||||
use App\Models\EntryHash;
|
||||
use App\Models\Game;
|
||||
use App\Models\Genre;
|
||||
@@ -50,12 +51,28 @@ class HashesUpload extends Component
|
||||
*/
|
||||
public function addHash(string $filename, string $crc32, string $sha1, ?string $verified = null): void
|
||||
{
|
||||
$this->hashes[] = [
|
||||
'filename' => $filename,
|
||||
'hash_crc32' => $crc32,
|
||||
'hash_sha1' => $sha1,
|
||||
'verified' => $verified ?? "No-Intro" // TODO: Change it.
|
||||
];
|
||||
|
||||
if( $verified !== null && $verified !== "No" )
|
||||
$this->hashes[] = [
|
||||
'filename' => $filename,
|
||||
'hash_crc32' => $crc32,
|
||||
'hash_sha1' => $sha1,
|
||||
'verified' => $verified
|
||||
];
|
||||
else if( ( $hash = HashesHelpers::findHashes( $sha1 ) ) !== null )
|
||||
$this->hashes[] = [
|
||||
'filename' => $hash->filename,
|
||||
'hash_crc32' => $hash->crc32,
|
||||
'hash_sha1' => $hash->sha1,
|
||||
'verified' => HashesHelpers::getReferenceName( $hash->dat_reference_id )
|
||||
];
|
||||
else
|
||||
$this->hashes[] = [
|
||||
'filename' => $filename,
|
||||
'hash_crc32' => $crc32,
|
||||
'hash_sha1' => $sha1,
|
||||
'verified' => "No"
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
62
app/Livewire/XfUserSelector.php
Normal file
62
app/Livewire/XfUserSelector.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class XfUserSelector extends Component
|
||||
{
|
||||
|
||||
private const int MIN_CHARS = 3;
|
||||
|
||||
public string $search = '';
|
||||
public ?int $selected = null;
|
||||
public ?string $selectedUsername = null;
|
||||
|
||||
public function mount( ?int $initialUserId ){
|
||||
if( $initialUserId ){
|
||||
$user = DB::connection('xenforo')->table('user')->where('user_id', $initialUserId)->first();
|
||||
if( $user ){
|
||||
$this->selected = $user->user_id;
|
||||
$this->selectedUsername = $user->username;
|
||||
$this->search = $user->username;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
if( $this->selected && $this->search !== $this->selectedUsername) {
|
||||
$this->selected = null;
|
||||
$this->selectedUsername = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function selectUser( int $userId, string $username ): void
|
||||
{
|
||||
$this->selected = $userId;
|
||||
$this->selectedUsername = $username;
|
||||
$this->search = $username;
|
||||
}
|
||||
|
||||
public function getResultsProperty(): array
|
||||
{
|
||||
if( strlen($this->search) < self::MIN_CHARS || $this->selected ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::connection('xenforo')
|
||||
->table('user')
|
||||
->where('username', 'like', '%' . $this->search . '%')
|
||||
->orderBy('username')
|
||||
->limit(10)
|
||||
->get(['user_id','username'])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.xf-user-selector');
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Entry extends Model
|
||||
{
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
@@ -33,6 +36,8 @@ class Entry extends Model
|
||||
'user_id',
|
||||
'complete_title',
|
||||
'comments_thread_id',
|
||||
'staff_comment',
|
||||
'rejected_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -41,12 +46,21 @@ class Entry extends Model
|
||||
protected $casts = [
|
||||
'featured' => 'boolean',
|
||||
'release_date' => 'date',
|
||||
'rejected_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function scopePublished( Builder $query ): Builder {
|
||||
return $query->where( 'state', 'published' );
|
||||
}
|
||||
|
||||
public function scopeInQueue( Builder $query, int $daysRejected = 7 ): Builder {
|
||||
return $query->withTrashed()->where(function($q) use($daysRejected) {
|
||||
$q->where('state', 'pending')->whereNull('deleted_at');
|
||||
})->orWhere(function($q) use($daysRejected) {
|
||||
$q->where('state', 'rejected')->whereNotNull('rejected_at')->where('rejected_at', '>=', now()->subDays($daysRejected) );
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return game link.
|
||||
* @return BelongsTo
|
||||
@@ -91,4 +105,18 @@ class Entry extends Model
|
||||
return $this->hasMany(EntryHash::class);
|
||||
}
|
||||
|
||||
public function parseStaffCredits(): array {
|
||||
return json_decode( $this->staff_credits ?? "", true );
|
||||
}
|
||||
|
||||
public function getYoutubeVideoId(): ?string {
|
||||
if( !$this->youtube_link )
|
||||
return null;
|
||||
|
||||
$pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i';
|
||||
|
||||
preg_match($pattern, $this->youtube_link, $matches);
|
||||
return $matches[1] ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ use Illuminate\Auth\Access\Response;
|
||||
|
||||
class EntryPolicy
|
||||
{
|
||||
|
||||
public function view(User $user): bool
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
if( $user->_can( 'romhackplaza', 'view' ) )
|
||||
return true;
|
||||
@@ -17,6 +16,47 @@ class EntryPolicy
|
||||
return false;
|
||||
}
|
||||
|
||||
public function viewPending(User $user, Entry $entry): bool
|
||||
{
|
||||
// Author.
|
||||
if( $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
return $user->_can( 'romhackplaza', 'canModerateEntries' );
|
||||
}
|
||||
|
||||
public function viewDraft(User $user, Entry $entry): bool
|
||||
{
|
||||
// Author.
|
||||
if( $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
return $user->_can( 'romhackplaza', 'canSeeOthersDrafts' );
|
||||
}
|
||||
|
||||
public function viewRejected(User $user, Entry $entry): bool
|
||||
{
|
||||
// Author.
|
||||
if( $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
return $user->_can( 'romhackplaza', 'canSeeRejectedEntries' );
|
||||
}
|
||||
|
||||
public function viewHidden(User $user, Entry $entry): bool
|
||||
{
|
||||
return $user->_can('romhackplaza', 'canSeeHiddenEntries' );
|
||||
}
|
||||
|
||||
public function viewLocked(User $user, Entry $entry): bool
|
||||
{
|
||||
// Author.
|
||||
if( $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
return $user->_can('romhackplaza', 'canSeeLockedEntries' );
|
||||
}
|
||||
|
||||
public function create(User $user, ?Entry $entry = null ): bool
|
||||
{
|
||||
return $user->_can( 'romhackplaza', 'canSubmitEntry' );
|
||||
@@ -27,13 +67,101 @@ class EntryPolicy
|
||||
*/
|
||||
public function update(User $user, Entry $entry): bool
|
||||
{
|
||||
if( $user->_can('romhackplaza', 'canEditOthersEntries') )
|
||||
return true;
|
||||
if( $entry->state === 'published' ){
|
||||
|
||||
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
// Staff editors
|
||||
if( $user->_can('romhackplaza', 'canEditOthersEntries') )
|
||||
return true;
|
||||
|
||||
// Author.
|
||||
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
return false;
|
||||
|
||||
} else if( $entry->state === 'pending' ){
|
||||
|
||||
// Staff moderation.
|
||||
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can('romhackplaza', 'canModerateEntries') )
|
||||
return true;
|
||||
|
||||
// Author.
|
||||
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
} else if( $entry->state === 'draft' ){
|
||||
|
||||
// Staff.
|
||||
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ) )
|
||||
return true;
|
||||
|
||||
// Author.
|
||||
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
} else if( $entry->state === 'rejected' ){
|
||||
|
||||
// Staff.
|
||||
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ) )
|
||||
return true;
|
||||
|
||||
// Author.
|
||||
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $entry->user_id === $user->user_id )
|
||||
return true;
|
||||
|
||||
} else if( $entry->state === 'locked' ){
|
||||
|
||||
// Staff.
|
||||
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeLockedEntries' ) )
|
||||
return true;
|
||||
|
||||
return false;
|
||||
|
||||
} else if( $entry->state === 'hidden' ){
|
||||
|
||||
// Staff.
|
||||
if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeHiddenEntries' ) )
|
||||
return true;
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function skipQueue(User $user, Entry $entry): bool
|
||||
{
|
||||
return $user->_can( 'romhackplaza', 'canSubmitEntryInPublished' );
|
||||
}
|
||||
|
||||
public function updateComment(User $user, Entry $entry): bool
|
||||
{
|
||||
return $user->_can('romhackplaza', 'canModerateEntries' );
|
||||
}
|
||||
|
||||
public function manageButtonsInQueue(User $user, Entry $entry): bool
|
||||
{
|
||||
if( $entry->state === 'rejected' ){
|
||||
return $this->viewRejected( $user, $entry );
|
||||
}
|
||||
|
||||
return $user->_can('romhackplaza', 'canModerateEntries' );
|
||||
}
|
||||
|
||||
public function approve(User $user, Entry $entry): bool
|
||||
{
|
||||
return $user->_can('romhackplaza', 'canModerateEntries' );
|
||||
}
|
||||
|
||||
public function reject(User $user, Entry $entry): bool
|
||||
{
|
||||
return $user->_can('romhackplaza', 'canModerateEntries' );
|
||||
}
|
||||
|
||||
public function moderate(User $user, Entry $entry): bool
|
||||
{
|
||||
return $user->_can('romhackplaza', 'canModerateEntries' );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
25
app/Rules/XfUserExists.php
Normal file
25
app/Rules/XfUserExists.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class XfUserExists implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$user = DB::connection('xenforo')->table('user')->where('user_id', $value)->first();
|
||||
if( !$user ){
|
||||
$fail("The user ID {$value} does not exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class FileServersService {
|
||||
'current_chunk' => $currentChunk,
|
||||
'total_chunks' => $totalChunks,
|
||||
// TODO : Must replace User ID
|
||||
'zeus' => $this->generateZeusToken( 0, $server['base_url'], "Uploadchunk" ),
|
||||
'zeus' => $this->generateZeusToken( \Auth::user()->user_id, $server['base_url'], "Uploadchunk" ),
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Exceptions\SubmissionException;
|
||||
use App\Helpers\EntryHelpers;
|
||||
use App\Helpers\XenForoHelpers;
|
||||
use App\Http\Requests\StoreEntryRequest;
|
||||
use App\Jobs\CreateXenForoCommentsThread;
|
||||
use App\Models\Author;
|
||||
@@ -70,7 +71,8 @@ class SubmissionsService {
|
||||
'currentChunk' => 0,
|
||||
'done' => true,
|
||||
'error' => null,
|
||||
'uuid' => $uuid
|
||||
'uuid' => $uuid,
|
||||
'state' => $file->state
|
||||
];
|
||||
|
||||
$file = Cache::get("uploaded_file_{$uuid}");
|
||||
@@ -83,7 +85,8 @@ class SubmissionsService {
|
||||
'currentChunk' => 0,
|
||||
'done' => true,
|
||||
'error' => null,
|
||||
'uuid' => $uuid
|
||||
'uuid' => $uuid,
|
||||
'state' => $file['state']
|
||||
];
|
||||
|
||||
return null;
|
||||
@@ -105,7 +108,7 @@ class SubmissionsService {
|
||||
|
||||
$this->request = $request;
|
||||
$this->section = $section;
|
||||
$user_id = 0; // TODO: Replace that.
|
||||
$user_id = \Auth::user()->user_id;
|
||||
|
||||
$entry = DB::transaction(function () use ( $user_id ) {
|
||||
|
||||
@@ -184,6 +187,9 @@ class SubmissionsService {
|
||||
// Step 13: Try to create the comments section.
|
||||
$this->Step13_CreateCommentsThread( $entry );
|
||||
|
||||
// Step 14: Refresh XF count.
|
||||
XenForoHelpers::updateEntriesCount( $entry->user_id );
|
||||
|
||||
return $entry;
|
||||
|
||||
}
|
||||
@@ -417,7 +423,14 @@ class SubmissionsService {
|
||||
$this->request = $request;
|
||||
$this->section = $section;
|
||||
$this->entry = $entry;
|
||||
$user_id = 0; // TODO: Replace that.
|
||||
|
||||
if( \Auth::user()->can('moderate', $entry) ){
|
||||
$user_id = $this->request->input('owner_user_id');
|
||||
$oldUserId = $this->entry->user_id;
|
||||
} else {
|
||||
$user_id = \Auth::user()->user_id;
|
||||
$oldUserId = null;
|
||||
}
|
||||
|
||||
$oldMainImage = $entry->main_image;
|
||||
$galleryPaths = [];
|
||||
@@ -465,6 +478,11 @@ class SubmissionsService {
|
||||
'user_id' => $user_id,
|
||||
'complete_title' => $completeTitle,
|
||||
];
|
||||
|
||||
if( \Auth::user()->can('moderate', $this->entry) ){
|
||||
$fields['staff_comment'] = $this->request->input('staff_comment');
|
||||
}
|
||||
|
||||
$this->entry->update( $fields );
|
||||
|
||||
// STEP 6: Update entry files.
|
||||
@@ -500,6 +518,11 @@ class SubmissionsService {
|
||||
// STEP 11 : Update gallery storage.
|
||||
$this->eStep11c_UpdateGalleryImages( $galleryPaths );
|
||||
|
||||
// STEP 12: Refresh XF count.
|
||||
if( $oldUserId )
|
||||
XenForoHelpers::updateEntriesCount( $oldUserId );
|
||||
XenForoHelpers::updateEntriesCount( $entry->user_id );
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
@@ -540,6 +563,7 @@ class SubmissionsService {
|
||||
private function eStep6_UpdateEntryFiles(int $entryId ): void
|
||||
{
|
||||
$requestUuids = $this->request->input('files_uuid', []);
|
||||
$requestStates = $this->request->input('files_state', []);
|
||||
$existingUuids = EntryFile::where( 'entry_id', $entryId )->pluck('file_uuid')->toArray();
|
||||
|
||||
$needDeletion = array_diff( $existingUuids, $requestUuids );
|
||||
@@ -552,6 +576,11 @@ class SubmissionsService {
|
||||
if( !empty( $needAddition ) ){
|
||||
$this->Step7_SaveEntryFiles( $this->entry->id, $needAddition ); // Same code.
|
||||
}
|
||||
|
||||
$stateMap = array_combine( $requestUuids, $requestStates );
|
||||
foreach( $stateMap as $uuid => $state ){
|
||||
EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived')->update(['state' => $state]);
|
||||
}
|
||||
}
|
||||
|
||||
private function eStep7_UpdateHashes(int $entryId): void
|
||||
|
||||
@@ -72,6 +72,13 @@ class XenforoApiService {
|
||||
});
|
||||
}
|
||||
|
||||
public function createConversation( array $userIdList, string $title, string $message, bool $conversationOpen, bool $openInvite ): bool
|
||||
{
|
||||
$response = $this->post("conversations", data: ['recipient_ids' => $userIdList, 'title' => $title, 'message' => $message, 'open_invite' => $openInvite, 'conversation_open' => $conversationOpen] );
|
||||
|
||||
return $response['success'] ?? false;
|
||||
}
|
||||
|
||||
public function createCommentsThread( Entry $entry ): bool
|
||||
{
|
||||
if( !$entry->comments_thread_id || $entry->comments_thread_id <= 0 ){
|
||||
@@ -108,4 +115,10 @@ class XenforoApiService {
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function updateEntriesCount(int $entryCount, int $userId): bool
|
||||
{
|
||||
$response = $this->post("romhackplaza_entry/update_entry_count", data: ['count' => $entryCount, 'user_id' => $userId] );
|
||||
return $response['success'] ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use App\Models\Entry;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
@@ -12,20 +13,49 @@ class SubmitEntryStatus extends Component
|
||||
* Create a new component instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $section
|
||||
public string $section,
|
||||
public bool $isEdit,
|
||||
public ?string $currentState,
|
||||
public null|string|Entry $entry = 'App\Models\Entry',
|
||||
)
|
||||
{
|
||||
//
|
||||
if( $this->entry === null )
|
||||
$this->entry = 'App\Models\Entry';
|
||||
}
|
||||
|
||||
public function availableStates(): array
|
||||
{
|
||||
// TODO: Change for permissions.
|
||||
return [
|
||||
'draft' => "Save into my drafts",
|
||||
'pending' => "Add to submissions queue",
|
||||
'published' => "Publish it now"
|
||||
|
||||
$labelPublished = $this->isEdit && $this->currentState === 'published' ? "Keep it published" : "Publish it now";
|
||||
$labelPending = $this->isEdit && $this->currentState === 'pending' ? "Keep it to submissions queue" : "Add to submissions queue";
|
||||
$labelDraft = $this->isEdit && $this->currentState === 'draft' ? "Keep it into my drafts" : "Save into my drafts";
|
||||
$labelHidden = $this->isEdit && $this->currentState === 'hidden' ? "Keep it hidden" : "Hide this entry";
|
||||
$labelLocked = $this->isEdit && $this->currentState === 'locked' ? "Keep it locked" : "Lock this entry";
|
||||
|
||||
$states = [
|
||||
'draft' => $labelDraft,
|
||||
'pending' => $labelPending,
|
||||
];
|
||||
|
||||
if( $this->isEdit ) {
|
||||
|
||||
$isAuthor = $this->entry->user_id === \Auth::user()->user_id;
|
||||
if( $isAuthor && ( \Auth::user()->can('skip-queue', $this->entry) || $this->currentState === 'published' ) )
|
||||
$states['published'] = $labelPublished;
|
||||
else if( \Auth::user()->can('moderate', $this->entry) )
|
||||
$states['published'] = $labelPublished;
|
||||
if( \Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-hidden', $this->entry) )
|
||||
$states['hidden'] = $labelHidden;
|
||||
if(\Auth::user()->can('moderate', $this->entry) && \Auth::user()->can('view-locked', $this->entry) )
|
||||
$states['locked'] = $labelLocked;
|
||||
|
||||
} else {
|
||||
if( \Auth::user()->can('skip-queue', $this->entry ) ){
|
||||
$states['published'] = $labelPublished;
|
||||
}
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
public function defaultState(): string
|
||||
|
||||
@@ -21,6 +21,9 @@ class XfUsernameLink extends Component
|
||||
|
||||
if( $this->user === null && $this->userId !== null ){
|
||||
$this->user = app(XenforoService::class)->getXfUser($this->userId);
|
||||
if( $this->user === null ){
|
||||
$this->user = app(XenforoService::class)->getXfUser(config('xenforo.bot_user_id'));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"laravel/framework": "^13.7",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "^4.3",
|
||||
"predis/predis": "^3.4"
|
||||
"predis/predis": "^3.4",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-simplexml": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"barryvdh/laravel-debugbar": "^4.2",
|
||||
|
||||
@@ -124,6 +124,13 @@ return [
|
||||
'prefix' => 'xf_'
|
||||
],
|
||||
|
||||
'hashes' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => storage_path('hashes.sqlite'),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
]
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
[
|
||||
'name' => "Submissions queue",
|
||||
'icon' => 'gavel',
|
||||
'route' => 'home'
|
||||
'route' => 'queue.index'
|
||||
],
|
||||
]
|
||||
],
|
||||
|
||||
@@ -24,7 +24,7 @@ return new class extends Migration
|
||||
$table->string( 'main_image' )->nullable();
|
||||
|
||||
// TODO: Replace it by state.
|
||||
$table->enum( 'status', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft');
|
||||
$table->enum( 'state', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft');
|
||||
$table->boolean('featured')->default(false);
|
||||
|
||||
// FK
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@import './components/hovercard.css';
|
||||
@import './components/notifications.css';
|
||||
@import './components/settings.css';
|
||||
@import './components/queue.css';
|
||||
|
||||
@import './components/easymde.css';
|
||||
|
||||
|
||||
@@ -35,3 +35,5 @@ ul {
|
||||
margin-bottom: 20px;
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
[x-cloak] {display: none !important;}
|
||||
|
||||
@@ -459,3 +459,72 @@
|
||||
color: var(--text2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.upload-item-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 15px;
|
||||
}
|
||||
.file-state-icon { width: 18px; height: 18px; }
|
||||
.file-state-icon--public { color: var(--success); }
|
||||
.file-state-icon--private { color: var(--text2); }
|
||||
.file-state-icon--archived { color: var(--error); }
|
||||
|
||||
.upload-item-state { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.author-search { position: relative; }
|
||||
|
||||
.author-search-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.author-search-input .form-input {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.author-search-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--success);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.author-search-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
z-index: 100;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.author-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: var(--typography);
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.author-search-item:hover { background-color: var(--bg3); }
|
||||
|
||||
187
resources/css/components/queue.css
Normal file
187
resources/css/components/queue.css
Normal file
@@ -0,0 +1,187 @@
|
||||
.queue-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
padding: 80px 20px;
|
||||
color: var(--text2);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
background-color: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 4px;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.queue-item--pending {
|
||||
border-left-color: var(--rhpz-orange);
|
||||
}
|
||||
.queue-item--rejected {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-left-color: var(--error);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background-color: var(--error);
|
||||
width: var(--reject-progress, 100%);
|
||||
opacity: 0.5;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.queue-item-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.queue-item-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-item-actions-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
padding: 15px 20px;
|
||||
background-color: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 30px;
|
||||
right: 30px;
|
||||
height: 2px;
|
||||
background-color: var(--border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text2);
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.timeline-step--active .timeline-dot {
|
||||
border-color: var(--rhpz-orange);
|
||||
background-color: var(--rhpz-orange);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.timeline-step--validated .timeline-dot {
|
||||
border-color: var(--success);
|
||||
background-color: var(--success);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.timeline-step--rejected .timeline-dot {
|
||||
border-color: var(--error);
|
||||
background-color: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
color: var(--text2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline-step--active .timeline-label { color: var(--rhpz-orange); font-weight: 600; }
|
||||
.timeline-step--validated .timeline-label { color: var(--success); }
|
||||
.timeline-step--rejected .timeline-label { color: var(--error); }
|
||||
|
||||
.queue-reject-reason {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 15px;
|
||||
background-color: rgba(229, 115, 115, 0.08);
|
||||
border-left: 2px solid var(--error);
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.queue-mod-zone {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 15px;
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.queue-mod-separator {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.queue-mod-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.queue-reject-form {
|
||||
margin-top: 12px;
|
||||
padding: 15px;
|
||||
background-color: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.entry-gallery-item {
|
||||
@@ -116,10 +117,81 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--rhpz-orange);
|
||||
|
||||
img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-modal {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.gallery-modal-content {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border: 2px solid var(--border);
|
||||
background-color: var(--bg);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 30px;
|
||||
color: #fff;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--rhpz-orange);
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-modal-video {
|
||||
width: 90%;
|
||||
max-width: 960px;
|
||||
aspect-ratio: 16/9;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.6);
|
||||
border: 1px solid var(--border);
|
||||
background-color: #000;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,3 +309,74 @@
|
||||
background-color: var(--bg);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.entry-video-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.video-thumbnail-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
aspect-ratio: 16/9;
|
||||
background-color: #000;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.8;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.play-trigger {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
transition: background-color 0.2s, transform 0.2s ease-out;
|
||||
|
||||
i { margin-left: 4px; }
|
||||
}
|
||||
|
||||
&:hover {
|
||||
img {
|
||||
transform: scale(1.03);
|
||||
opacity: 1;
|
||||
}
|
||||
.play-trigger {
|
||||
background-color: var(--rhpz-orange);
|
||||
border-color: var(--rhpz-orange);
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
box-shadow: 0 0 15px rgba(255, 115, 0, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.entry-submission-byline {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,93 +61,98 @@ export function FSFileData(name, totalChunks, rawFile ) {
|
||||
*/
|
||||
uuid: crypto.randomUUID(),
|
||||
|
||||
/**
|
||||
* Look if this file is currently uploading.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isUploading()
|
||||
{
|
||||
return !this.done && !this.error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build API url.
|
||||
* @param {string} section
|
||||
* @returns {string} The API url.
|
||||
*/
|
||||
buildUrl(section)
|
||||
{
|
||||
return `/api/fs/upload-chunk/${section}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload the file.
|
||||
* @param {string} section section of the file.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async upload(section)
|
||||
{
|
||||
|
||||
if (!this.rawFile)
|
||||
return; // Can't upload in that case.
|
||||
/**
|
||||
* Current file state
|
||||
*/
|
||||
state: 'public',
|
||||
|
||||
/**
|
||||
* Get CSRF token for uploading request.
|
||||
* @type {string}
|
||||
* Look if this file is currently uploading.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const CSRF = document.querySelector('meta[name=csrf-token]')?.content ?? '';
|
||||
get isUploading()
|
||||
{
|
||||
return !this.done && !this.error;
|
||||
},
|
||||
|
||||
for (let i = 0; i < this.totalChunks; i++) {
|
||||
/**
|
||||
* Build API url.
|
||||
* @param {string} section
|
||||
* @returns {string} The API url.
|
||||
*/
|
||||
buildUrl(section)
|
||||
{
|
||||
return `/api/fs/upload-chunk/${section}`;
|
||||
},
|
||||
|
||||
if (this.error)
|
||||
return; // Abort the process.
|
||||
/**
|
||||
* Upload the file.
|
||||
* @param {string} section section of the file.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async upload(section)
|
||||
{
|
||||
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, this.rawFile.size);
|
||||
const chunk = this.rawFile.slice(start, end);
|
||||
if (!this.rawFile)
|
||||
return; // Can't upload in that case.
|
||||
|
||||
const formData = new FormData();
|
||||
/**
|
||||
* Get CSRF token for uploading request.
|
||||
* @type {string}
|
||||
*/
|
||||
const CSRF = document.querySelector('meta[name=csrf-token]')?.content ?? '';
|
||||
|
||||
formData.append('file', chunk);
|
||||
formData.append('file_uuid', this.uuid);
|
||||
formData.append('current_chunk', i);
|
||||
formData.append('total_chunks', this.totalChunks);
|
||||
formData.append('filename', this.rawFile.name);
|
||||
formData.append('_token', CSRF);
|
||||
for (let i = 0; i < this.totalChunks; i++) {
|
||||
|
||||
// -----
|
||||
// UPLOAD TIME !
|
||||
// -----
|
||||
if (this.error)
|
||||
return; // Abort the process.
|
||||
|
||||
try {
|
||||
const RESPONSE = await fetch(this.buildUrl(section), {method: 'POST', body: formData});
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, this.rawFile.size);
|
||||
const chunk = this.rawFile.slice(start, end);
|
||||
|
||||
if (!RESPONSE.ok) // Problem with the request.
|
||||
throw new Error(`${RESPONSE.status} ${RESPONSE.statusText}`);
|
||||
const formData = new FormData();
|
||||
|
||||
/** @type {UploadchunkResponse} */
|
||||
const DATA = await RESPONSE.json();
|
||||
formData.append('file', chunk);
|
||||
formData.append('file_uuid', this.uuid);
|
||||
formData.append('current_chunk', i);
|
||||
formData.append('total_chunks', this.totalChunks);
|
||||
formData.append('filename', this.rawFile.name);
|
||||
formData.append('_token', CSRF);
|
||||
|
||||
if (DATA.success !== true || DATA.uploaded !== true)
|
||||
// The request reached the file server but could not be sent.
|
||||
throw new Error(`${DATA.error}`);
|
||||
// -----
|
||||
// UPLOAD TIME !
|
||||
// -----
|
||||
|
||||
this.currentChunk = i + 1;
|
||||
this.progressValue = Math.round(((i + 1) / this.totalChunks) * 100);
|
||||
try {
|
||||
const RESPONSE = await fetch(this.buildUrl(section), {method: 'POST', body: formData});
|
||||
|
||||
if (DATA.finished === true) {
|
||||
this.done = true;
|
||||
if (!RESPONSE.ok) // Problem with the request.
|
||||
throw new Error(`${RESPONSE.status} ${RESPONSE.statusText}`);
|
||||
|
||||
/** @type {UploadchunkResponse} */
|
||||
const DATA = await RESPONSE.json();
|
||||
|
||||
if (DATA.success !== true || DATA.uploaded !== true)
|
||||
// The request reached the file server but could not be sent.
|
||||
throw new Error(`${DATA.error}`);
|
||||
|
||||
this.currentChunk = i + 1;
|
||||
this.progressValue = Math.round(((i + 1) / this.totalChunks) * 100);
|
||||
|
||||
if (DATA.finished === true) {
|
||||
this.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.error = 'Error on chunk ' + (i + 1) + '. ' + err.message;
|
||||
this.progressValue = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.error = 'Error on chunk ' + (i + 1) + '. ' + err.message;
|
||||
this.progressValue = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,10 @@ export function FSUploader(){
|
||||
*/
|
||||
handleRemoveFile( index ){
|
||||
this.files.splice(index, 1);
|
||||
},
|
||||
|
||||
changeFileState( index, newState ){
|
||||
this.files[index].state = newState;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
}" x-init="init()">
|
||||
<div>
|
||||
@if( section_must_be( [ 'romhacks', 'homebrew' ], $section ) )
|
||||
@if( section_must_be( [ 'romhacks', 'homebrew' ], $section ) && !$isEdit )
|
||||
<label class="nsfw-label"><input id="nsfw-checkbox" type="checkbox" name="nsfw-entry" x-model="nsfw" style="transform: scale(1.5)"> NSFW</label>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php /** @var \App\Models\EntryGallery $galleryItem */ ?>
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('page-title', $entry->title . " - " . config('app.name') )
|
||||
@@ -19,6 +20,37 @@
|
||||
<div class="entry-info">
|
||||
<h1 class="entry-title">
|
||||
{{ $entry->title }}
|
||||
@if( $entry->state === 'pending' )
|
||||
<div style="display:inline;color:var(--rhpz-orange);">
|
||||
-
|
||||
<i data-lucide="clock" size="24"></i>
|
||||
Pending approval
|
||||
</div>
|
||||
@elseif( $entry->state === 'rejected' )
|
||||
<div style="display:inline;color:var(--error);">
|
||||
-
|
||||
<i data-lucide="x-circle" size="24"></i>
|
||||
Rejected
|
||||
</div>
|
||||
@elseif( $entry->state === 'locked' )
|
||||
<div style="display:inline;color:var(--error);">
|
||||
-
|
||||
<i data-lucide="lock" size="24"></i>
|
||||
Locked
|
||||
</div>
|
||||
@elseif( $entry->state === 'draft' )
|
||||
<div style="display:inline;color:var(--rhpz-orange);">
|
||||
-
|
||||
<i data-lucide="scissors" size="24"></i>
|
||||
Draft
|
||||
</div>
|
||||
@elseif( $entry->state === 'hidden' )
|
||||
<div style="display:inline;color:var(--rhpz-orange);">
|
||||
-
|
||||
<i data-lucide="hide" size="24"></i>
|
||||
Hidden
|
||||
</div>
|
||||
@endif
|
||||
</h1>
|
||||
<div class="entry-authors">
|
||||
@forelse( $entry->authors as $author)
|
||||
@@ -29,6 +61,26 @@
|
||||
No authors
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="entry-submission-byline">
|
||||
@if($entry->user_id)
|
||||
<span>
|
||||
<i data-lucide="user" size="14"></i>
|
||||
Posted by <x-xf-username-link :user-id="$entry->user_id" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<span>
|
||||
<i data-lucide="calendar" size="14"></i>
|
||||
{{ $entry->created_at->format('M d, Y') }}
|
||||
</span>
|
||||
|
||||
@if($entry->updated_at && $entry->updated_at->gt($entry->created_at))
|
||||
<span>
|
||||
<i data-lucide="file-edit" size="14"></i>
|
||||
Updated {{ $entry->updated_at->diffForHumans() }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="entry-meta-grid">
|
||||
@if( $entry->game )
|
||||
<x-entry-meta-item label="Game Name" value="{{ $entry->game->name }}" />
|
||||
@@ -55,18 +107,78 @@
|
||||
<x-entry-meta-item label="Type of hack" value="{{ $entry->modifications->pluck('name')->implode(', ') }}" route="none" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="hack-actions">
|
||||
<div class="hack-actions" style="display:flex;gap:10px;">
|
||||
@if($entry->state === 'pending')
|
||||
@can('approve', $entry)
|
||||
<div x-data="{ rejectOpen: false }">
|
||||
<form action="{{ route('queue.approve', $entry) }}" method="POST" style="display:inline">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button type="submit" class="btn success" onclick="return confirm('Approve this entry?')">
|
||||
<i data-lucide="check-circle" size="14"></i>
|
||||
Approve
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn danger" style="margin-right:15px;" @click="rejectOpen = !rejectOpen">
|
||||
<i data-lucide="x-circle" size="14"></i>
|
||||
Reject
|
||||
</button>
|
||||
<div
|
||||
class="modal-overlay"
|
||||
x-cloak
|
||||
x-show="rejectOpen"
|
||||
x-transition.opacity
|
||||
@click.self="rejectOpen = false"
|
||||
@keydown.escape.window="rejectOpen = false"
|
||||
@modal:opened.window="refreshIcons($el)"
|
||||
>
|
||||
<div class="modal-window" x-show="rejectOpen" x-transition>
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Reject entry</span>
|
||||
<button type="button" class="modal-close" @click="rejectOpen = false">
|
||||
<i data-lucide="x" size="20"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form action="{{ route('queue.reject', $entry) }}" method="POST">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div class="form-group">
|
||||
<x-form-field-title name="Rejection reason" required="true" />
|
||||
<textarea
|
||||
class="form-input"
|
||||
name="reason"
|
||||
rows="4"
|
||||
placeholder="Explain why this entry is being rejected..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="queue-mod-actions">
|
||||
<button type="button" class="btn" @click="rejectOpen = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn danger">
|
||||
<i data-lucide="x-circle" size="14"></i>
|
||||
Confirm rejection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
<button class="btn primary" onclick="Livewire.dispatch('entryOpenFilesModal', { entryId: {{ $entry->id }} })">
|
||||
<i data-lucide="download"></i> Download
|
||||
</button>
|
||||
@can('update',$entry)
|
||||
<a href="{{ route('submit.edit', ['section' => $entry->type, 'entry' => $entry ] ) }}" class="btn primary">
|
||||
<a href="{{ route('submit.edit', ['section' => $entry->type, 'entry' => $entry ] ) }}" class="btn">
|
||||
<i data-lucide="edit"></i> Edit
|
||||
</a>
|
||||
@endcan
|
||||
<button class="btn">
|
||||
<i data-lucide="message-square"></i> Comments
|
||||
</button>
|
||||
@auth
|
||||
<a href="{{ xfRoute("romhackplaza_entry/{$entry->id}/report") }}" class="btn">
|
||||
<i data-lucide="flag"></i> Report / Claim Ownership
|
||||
@@ -95,11 +207,55 @@
|
||||
</div>
|
||||
@endif
|
||||
@if( $entry->staff_credits )
|
||||
<h2 class="entry-section-title">
|
||||
<i data-lucide="users-round"></i> Staff credits
|
||||
</h2>
|
||||
<x-entry-section-title label="Staff Credits" icon="users-round" />
|
||||
<div class="entry-description">
|
||||
{{ $entry->staff_credits }}
|
||||
<ul>
|
||||
@foreach( $entry->parseStaffCredits() as $item )
|
||||
<li>{{ $item['name'] }}: {{ $item['description'] }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
@if( $entry->gallery->isNotEmpty() )
|
||||
<div x-data="{ open: false, currentImage: ''}" x-cloak>
|
||||
<x-entry-section-title label="Gallery" icon="images" />
|
||||
<div class="entry-gallery">
|
||||
@foreach( $entry->gallery as $galleryItem )
|
||||
<div class="entry-gallery-item" @click="currentImage = '{{ Storage::url($galleryItem->image) }}'; open = true; "><img src="{{ Storage::url($galleryItem->image) }}"></div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="gallery-modal" x-show="open" x-transition.opacity.duration.300ms @click="open = false" @keydown.escape.window="open = false">
|
||||
<span class="gallery-modal-close" @click="open = false;"><i data-lucide="x"></i></span>
|
||||
<div class="gallery-modal-content" @click.stop>
|
||||
<img :src="currentImage">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if( $entry->relevant_link )
|
||||
<x-entry-section-title label="Relevant Link" icon="link" />
|
||||
<div class="entry-description">
|
||||
<a href="{{ $entry->relevant_link }}" target="_blank">{{ $entry->relevant_link }}</a>
|
||||
</div>
|
||||
@endif
|
||||
@if( $entry->getYoutubeVideoId() )
|
||||
<div x-data="{open: false, src: ''}" x-cloak class="youtube-section">
|
||||
|
||||
<x-entry-section-title label="Youtube Video" icon="play" />
|
||||
|
||||
<div class="video-thumbnail-wrapper" @click="src = 'https://www.youtube.com/embed/{{ $entry->getYoutubeVideoId() }}?autoplay=1'; open = true">
|
||||
<img src="https://img.youtube.com/vi/{{ $entry->youtube_id }}/maxresdefault.jpg">
|
||||
<div class="play-trigger">
|
||||
<i data-lucide="play"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gallery-modal" x-show="open" x-transition.opacity.duration.300ms @click="open = false; src = ''" @keydown.escape.window="open = false; src = ''">
|
||||
<span class="gallery-modal-close" @click="open = false; src = '';"><i data-lucide="x"></i></span>
|
||||
<div class="gallery-modal-video" @click.stop>
|
||||
<iframe :src="src" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
31
resources/views/livewire/xf-user-selector.blade.php
Normal file
31
resources/views/livewire/xf-user-selector.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<div class="author-search" x-data>
|
||||
<div class="author-search-input">
|
||||
<i data-lucide="search" size="14"></i>
|
||||
<input type="text" class="form-input" wire:model.live.debounce.300ms="search" autocomplete="off">
|
||||
@if($selected)
|
||||
<span class="author-search-selected">
|
||||
<i data-lucide="check" size="13"></i>
|
||||
{{ $selectedUsername }}
|
||||
</span>
|
||||
<button type="button" class="btn" wire:click="$set('selected', null); $set('search', '')">
|
||||
Remove
|
||||
</button>
|
||||
@endif
|
||||
@if($selected)
|
||||
<input type="hidden" name="owner_user_id" value="{{ $selected }}">
|
||||
@endif
|
||||
@if(count($this->results) > 0)
|
||||
<div class="author-search-dropdown">
|
||||
@foreach($this->results as $user)
|
||||
<button
|
||||
type="button"
|
||||
class="author-search-item"
|
||||
wire:click="selectUser({{ $user->user_id }}, '{{ addslashes($user->username) }}')"
|
||||
>
|
||||
{{ $user->username }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
19
resources/views/queue/index.blade.php
Normal file
19
resources/views/queue/index.blade.php
Normal file
@@ -0,0 +1,19 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('page-title', "Submissions Queue - " . config('app.name') )
|
||||
|
||||
@section('content')
|
||||
<div class="page-title">
|
||||
Submissions Queue
|
||||
</div>
|
||||
@if($entries->isEmpty())
|
||||
<div class="queue-empty">
|
||||
<i data-lucide="inbox" size="48"></i>
|
||||
<p>No pending submissions.</p>
|
||||
</div>
|
||||
@else
|
||||
@foreach($entries as $entry)
|
||||
@include('queue.item', ['entry' => $entry ] )
|
||||
@endforeach
|
||||
@endif
|
||||
@endsection
|
||||
155
resources/views/queue/item.blade.php
Normal file
155
resources/views/queue/item.blade.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<div class="queue-item
|
||||
{{ $entry->state === 'rejected' ? 'queue-item--rejected' : 'queue-item--pending' }}
|
||||
"
|
||||
@if($entry->state === 'rejected')
|
||||
style="--reject-progress: {{ min(100, (now()->diffInDays($entry->rejected_at) / 7) * 100) }}%"
|
||||
@endif
|
||||
>
|
||||
<div class="queue-item-header">
|
||||
<div class="queue-item-info">
|
||||
<h3 class="queue-item-title">{{ $entry->complete_title }}</h3>
|
||||
@if($entry->state === 'rejected')
|
||||
<span class="badge badge--danger">
|
||||
<i data-lucide="x-circle" size="12"></i>
|
||||
Rejected
|
||||
@php
|
||||
$daysLeft = intval(7 - now()->diffInDays($entry->rejected_at));
|
||||
@endphp
|
||||
@if($daysLeft > 0)
|
||||
- deleted in {{ $daysLeft }} days
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
<div class="queue-item-meta">
|
||||
Submitted by <x-xf-username-link :user-id="$entry->user_id" />
|
||||
on {{ $entry->created_at->format('Y-m-d') }}
|
||||
<span class="badge {{ $entry->type }}">{{ \App\View\Components\EntryCard::ENTRY_TYPES_BADGE[$entry->type] ?? $entry->type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manageButtonsInQueue',$entry)
|
||||
<div class="queue-item-actions-header">
|
||||
<a href="{{ route('entries.show', ['section' => $entry->type, 'entry' => $entry ]) }}" class="btn" target="_blank">
|
||||
<i data-lucide="external-link" size="14"></i>
|
||||
View Entry
|
||||
</a>
|
||||
<a href="{{ route('submit.edit', ['section' => $entry->type, 'entry' =>$entry ] ) }}" class="btn" target="_blank">
|
||||
<i data-lucide="pen" size="14"></i>
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<div class="timeline-container">
|
||||
<div class="timeline">
|
||||
|
||||
|
||||
<div class="timeline-step timeline-step--validated">
|
||||
<div class="timeline-dot">
|
||||
<i data-lucide="check" size="16"></i>
|
||||
</div>
|
||||
<span class="timeline-label">Submitted</span>
|
||||
</div>
|
||||
|
||||
<div class="timeline-step {{ $entry->state === 'pending' ? 'timeline-step--active' : 'timeline-step--validated' }}">
|
||||
<div class="timeline-dot">
|
||||
@if($entry->state === 'pending')
|
||||
<i data-lucide="search" size="16"></i>
|
||||
@else
|
||||
<i data-lucide="check" size="16"></i>
|
||||
@endif
|
||||
</div>
|
||||
<span class="timeline-label">Under review</span>
|
||||
</div>
|
||||
|
||||
@if($entry->state === 'rejected')
|
||||
<div class="timeline-step timeline-step--rejected">
|
||||
<div class="timeline-dot">
|
||||
<i data-lucide="x" size="16"></i>
|
||||
</div>
|
||||
<span class="timeline-label">Rejected</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="timeline-step {{ $entry->state === 'published' ? 'timeline-step--validated' : '' }}">
|
||||
<div class="timeline-dot">
|
||||
@if($entry->state === 'published')
|
||||
<i data-lucide="check" size="16"></i>
|
||||
@endif
|
||||
</div>
|
||||
<span class="timeline-label">Approved</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($entry->state === 'rejected' && $entry->staff_comment)
|
||||
<div class="queue-reject-reason">
|
||||
<i data-lucide="alert-circle" size="14"></i>
|
||||
<div>
|
||||
<strong>Rejection reason :</strong>
|
||||
{{ $entry->staff_comment }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@can('approve',$entry)
|
||||
@if($entry->state === 'pending')
|
||||
<div class="queue-mod-zone">
|
||||
<form action="{{ route('queue.comment', $entry ) }}" method="POST">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div class="form-group">
|
||||
<x-form-field-title name="Comment" />
|
||||
<textarea class="form-input" name="comment" rows="3">{{ $entry->staff_comment }}</textarea>
|
||||
</div>
|
||||
<div class="queue-mod-actions">
|
||||
<button type="submit" class="btn">
|
||||
<i data-lucide="save" size="14"></i>
|
||||
Save comment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="queue-mod-separator"></div>
|
||||
|
||||
<form action="{{ route('queue.approve', $entry) }}" method="POST" style="display:inline">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button type="submit" class="btn success" onclick="return confirm('Approve this entry?')">
|
||||
<i data-lucide="check-circle" size="14"></i>
|
||||
Approve
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div x-data="{open: false}">
|
||||
<button type="button" class="btn danger" @click="open = !open">
|
||||
<i data-lucide="x-circle" size="14"></i>
|
||||
Reject
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak class="queue-reject-form">
|
||||
<form action="{{ route('queue.reject', $entry) }}" method="POST">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div class="form-group">
|
||||
<x-form-field-title name="Rejection reason" required="true" />
|
||||
<textarea class="form-input" name="reason" rows="3" required></textarea>
|
||||
</div>
|
||||
<div class="queue-mod-actions">
|
||||
<button type="button" class="btn" @click="open = false">Cancel</button>
|
||||
<button type="submit" class="btn btn--danger">
|
||||
<i data-lucide="x-circle" size="14"></i>
|
||||
Confirm rejection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
|
||||
</div>
|
||||
@@ -168,10 +168,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($isEdit)
|
||||
<x-form-group-title label="Entry Management" icon="wrench" />
|
||||
@can('moderate',$entry)
|
||||
<div class="form-group grid-c2">
|
||||
<div>
|
||||
<x-form-field-title name="Staff comment" />
|
||||
<textarea class="form-textarea" name="staff_comment" rows="3">{{ old('staff_comment', $entry->staff_comment ?? '' ) }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<x-form-field-title name="Owner" required="true" />
|
||||
<livewire:xf-user-selector :initial-user-id="$entry->user_id" />
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
@cannot('moderate', $entry)
|
||||
|
||||
@endcannot
|
||||
@endif
|
||||
|
||||
@csrf
|
||||
|
||||
<div class="submit">
|
||||
<x-submit-entry-status :section="$section" />
|
||||
<x-submit-entry-status :section="$section" :is-edit="$isEdit" :current-state="$entry->state ?? null" :entry="$entry" />
|
||||
<button id="submit-button" type="submit" class="btn primary" style="padding:1%;">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -39,12 +39,18 @@
|
||||
<template x-if="!file.done && !file.error">
|
||||
<i data-lucide="loader-2" class="spin"></i>
|
||||
</template>
|
||||
<template x-if="file.done">
|
||||
<i data-lucide="check-circle"></i>
|
||||
</template>
|
||||
<template x-if="file.error">
|
||||
<i data-lucide="alert-circle"></i>
|
||||
</template>
|
||||
<template x-if="file.done && file.state === 'public'">
|
||||
<i data-lucide="eye" class="file-state-icon file-state-icon--public"></i>
|
||||
</template>
|
||||
<template x-if="file.done && file.state === 'private'">
|
||||
<i data-lucide="eye-off" class="file-state-icon file-state-icon--private"></i>
|
||||
</template>
|
||||
<template x-if="file.done && file.state === 'archived'">
|
||||
<i data-lucide="archive" class="file-state-icon file-state-icon--archived"></i>
|
||||
</template>
|
||||
|
||||
<div class="upload-item-info">
|
||||
<span class="upload-item-name" x-text="file.name"></span>
|
||||
@@ -55,6 +61,48 @@
|
||||
</div>
|
||||
<span class="upload-item-error" x-show="file.error" x-text="file.error"></span>
|
||||
</div>
|
||||
@if($isEdit)
|
||||
<div class="upload-item-state" x-show="file.done" x-data="{ confirmArchive: false }">
|
||||
<select class="form-select" :disabled="file.state === 'archived'" @change="
|
||||
if( $event.target.value === 'archived'){
|
||||
confirmArchive = true;
|
||||
$event.target.value = file.state;
|
||||
} else {
|
||||
$wire ? $wire.dispatch('fileStateChanged') : null;
|
||||
changeFileState(i, $event.target.value);
|
||||
}
|
||||
" :value="file.state">
|
||||
<option value="public" :selected="file.state === 'public'">Public</option>
|
||||
<option value="private" :selected="file.state === 'private'">Private</option>
|
||||
<option value="archived" :selected="file.state === 'archived'">Archived</option>
|
||||
</select>
|
||||
<div class="modal-overlay" x-cloak x-show="confirmArchive" x-transition.opacity.duration.300ms @click.self="confirmArchive = false" @keydown.escape.window="confirmArchive = false">
|
||||
<div class="modal-window" x-show="confirmArchive" x-transition>
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">
|
||||
<i data-lucide="archive" size="16"></i>
|
||||
Archive file
|
||||
</span>
|
||||
<button type="button" class="modal-close" @click="confirmArchive = false">
|
||||
<i data-lucide="x" size="20"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Archiving a file is <b>irreversible</b>.</p>
|
||||
<div class="queue-mod-actions" style="margin-top: 15px">
|
||||
<button type="button" class="btn" @click="confirmArchive = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn danger" @click="changeFileState(i,'archived'); confirmArchive = false;">
|
||||
<i data-lucide="archive" size="14"></i>
|
||||
Confirm archive
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="upload-item-actions">
|
||||
<button type="button" class="btn" x-show="file.error" @click="handleRetryFile(i)">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
@@ -64,6 +112,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="files_uuid[]" :value="file.uuid" x-show="file.done">
|
||||
@if($isEdit)
|
||||
<input type="hidden" name="files_state[]" :value="file.state" x-show="file.done">
|
||||
@endif
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -6,3 +6,5 @@ use Illuminate\Support\Facades\Artisan;
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('entries:purge-rejected')->daily();
|
||||
|
||||
@@ -36,6 +36,26 @@ Route::name('submit.')->prefix('/edit')->controller(\App\Http\Controllers\Submis
|
||||
->where([ 'section' => 'translations|romhacks|homebrew|utilities|documents|lua-scripts|tutorials', 'entry' => '[0-9\-]+' ]);
|
||||
});
|
||||
|
||||
// QueueController
|
||||
Route::name('queue.')->prefix('/queue')->controller(\App\Http\Controllers\QueueController::class)->group(function () {
|
||||
Route::get('/', 'index' )->name('index');
|
||||
|
||||
Route::patch('/{entry:id}/comment', 'updateComment' )
|
||||
->middleware(['xf.auth', 'can:updateComment,entry' ] )
|
||||
->where([ 'entry' => '[0-9\-]+' ])
|
||||
->name('comment');
|
||||
|
||||
Route::patch('/{entry:id}/approve', 'approve' )
|
||||
->middleware(['xf.auth', 'can:approve,entry' ] )
|
||||
->where([ 'entry' => '[0-9\-]+' ])
|
||||
->name('approve');
|
||||
|
||||
Route::patch('/{entry:id}/reject', 'reject' )
|
||||
->middleware(['xf.auth', 'can:reject,entry' ] )
|
||||
->where([ 'entry' => '[0-9\-]+' ])
|
||||
->name('reject');
|
||||
});
|
||||
|
||||
// RedirectController
|
||||
Route::name('redirect.')->controller(\App\Http\Controllers\RedirectController::class)->group(function () {
|
||||
Route::get('/entry/report_redirect', 'entryReportRedirect' )->name('entry_report');
|
||||
|
||||
BIN
storage/hashes.sqlite
Normal file
BIN
storage/hashes.sqlite
Normal file
Binary file not shown.
@@ -14,7 +14,6 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
host: 'rhpz.local',
|
||||
|
||||
Reference in New Issue
Block a user