diff --git a/app/Helpers/HashesHelpers.php b/app/Helpers/HashesHelpers.php new file mode 100644 index 0000000..7e67ccd --- /dev/null +++ b/app/Helpers/HashesHelpers.php @@ -0,0 +1,21 @@ +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; + } + +} diff --git a/app/Helpers/XenForoHelpers.php b/app/Helpers/XenForoHelpers.php index efbcd66..a07e71b 100644 --- a/app/Helpers/XenForoHelpers.php +++ b/app/Helpers/XenForoHelpers.php @@ -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); + } } diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 5f995ae..96b49a2 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -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')); } diff --git a/app/Http/Controllers/FileServerController.php b/app/Http/Controllers/FileServerController.php index 3ef909e..f8c71f3 100644 --- a/app/Http/Controllers/FileServerController.php +++ b/app/Http/Controllers/FileServerController.php @@ -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; diff --git a/app/Http/Controllers/QueueController.php b/app/Http/Controllers/QueueController.php new file mode 100644 index 0000000..c9f4e91 --- /dev/null +++ b/app/Http/Controllers/QueueController.php @@ -0,0 +1,49 @@ +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'); + } + +} diff --git a/app/Http/Requests/StoreEntryRequest.php b/app/Http/Requests/StoreEntryRequest.php index 1b1229b..d7adff4 100644 --- a/app/Http/Requests/StoreEntryRequest.php +++ b/app/Http/Requests/StoreEntryRequest.php @@ -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; } diff --git a/app/Livewire/HashesUpload.php b/app/Livewire/HashesUpload.php index b0035f7..2aa5a91 100644 --- a/app/Livewire/HashesUpload.php +++ b/app/Livewire/HashesUpload.php @@ -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" + ]; } /** diff --git a/app/Livewire/XfUserSelector.php b/app/Livewire/XfUserSelector.php new file mode 100644 index 0000000..02baafd --- /dev/null +++ b/app/Livewire/XfUserSelector.php @@ -0,0 +1,62 @@ +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'); + } +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 1a84843..db044fb 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -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; + } + } diff --git a/app/Policies/EntryPolicy.php b/app/Policies/EntryPolicy.php index 1527128..e78eccd 100644 --- a/app/Policies/EntryPolicy.php +++ b/app/Policies/EntryPolicy.php @@ -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' ); + } + } diff --git a/app/Rules/XfUserExists.php b/app/Rules/XfUserExists.php new file mode 100644 index 0000000..4eb57ef --- /dev/null +++ b/app/Rules/XfUserExists.php @@ -0,0 +1,25 @@ +table('user')->where('user_id', $value)->first(); + if( !$user ){ + $fail("The user ID {$value} does not exist."); + } + } +} diff --git a/app/Services/FileServersService.php b/app/Services/FileServersService.php index 899979f..42d82c0 100644 --- a/app/Services/FileServersService.php +++ b/app/Services/FileServersService.php @@ -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()) { diff --git a/app/Services/SubmissionsService.php b/app/Services/SubmissionsService.php index 343c5a7..a11dc40 100644 --- a/app/Services/SubmissionsService.php +++ b/app/Services/SubmissionsService.php @@ -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 diff --git a/app/Services/XenforoApiService.php b/app/Services/XenforoApiService.php index 275fa54..320243c 100644 --- a/app/Services/XenforoApiService.php +++ b/app/Services/XenforoApiService.php @@ -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; + } + } diff --git a/app/View/Components/SubmitEntryStatus.php b/app/View/Components/SubmitEntryStatus.php index e8e7a85..5f02761 100644 --- a/app/View/Components/SubmitEntryStatus.php +++ b/app/View/Components/SubmitEntryStatus.php @@ -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 diff --git a/app/View/Components/XfUsernameLink.php b/app/View/Components/XfUsernameLink.php index 5580834..c3d77cd 100644 --- a/app/View/Components/XfUsernameLink.php +++ b/app/View/Components/XfUsernameLink.php @@ -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')); + } } } diff --git a/composer.json b/composer.json index 74825aa..dbd334c 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/database.php b/config/database.php index 443cc2b..c3c8ad2 100644 --- a/config/database.php +++ b/config/database.php @@ -124,6 +124,13 @@ return [ 'prefix' => 'xf_' ], + 'hashes' => [ + 'driver' => 'sqlite', + 'database' => storage_path('hashes.sqlite'), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ] + ], /* diff --git a/config/menu.php b/config/menu.php index f3c24a4..669e849 100644 --- a/config/menu.php +++ b/config/menu.php @@ -21,7 +21,7 @@ return [ [ 'name' => "Submissions queue", 'icon' => 'gavel', - 'route' => 'home' + 'route' => 'queue.index' ], ] ], diff --git a/database/migrations/2026_05_10_072747_create_entries_table.php b/database/migrations/2026_05_10_072747_create_entries_table.php index a298cb0..0c6a41f 100644 --- a/database/migrations/2026_05_10_072747_create_entries_table.php +++ b/database/migrations/2026_05_10_072747_create_entries_table.php @@ -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 diff --git a/resources/css/app.css b/resources/css/app.css index 51c1e62..3aa3e17 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -16,6 +16,7 @@ @import './components/hovercard.css'; @import './components/notifications.css'; @import './components/settings.css'; +@import './components/queue.css'; @import './components/easymde.css'; diff --git a/resources/css/base/reset.css b/resources/css/base/reset.css index 94d8975..53bf823 100644 --- a/resources/css/base/reset.css +++ b/resources/css/base/reset.css @@ -35,3 +35,5 @@ ul { margin-bottom: 20px; list-style-type: square; } + +[x-cloak] {display: none !important;} diff --git a/resources/css/components/forms.css b/resources/css/components/forms.css index 1c41c68..f1fa4ff 100644 --- a/resources/css/components/forms.css +++ b/resources/css/components/forms.css @@ -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); } diff --git a/resources/css/components/queue.css b/resources/css/components/queue.css new file mode 100644 index 0000000..5848bc0 --- /dev/null +++ b/resources/css/components/queue.css @@ -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); +} + diff --git a/resources/css/layout/entry.css b/resources/css/layout/entry.css index b6f7d86..8a41758 100644 --- a/resources/css/layout/entry.css +++ b/resources/css/layout/entry.css @@ -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; + } +} diff --git a/resources/js/SubmissionsClass/FSFileData.js b/resources/js/SubmissionsClass/FSFileData.js index 64e5652..8fffbdd 100644 --- a/resources/js/SubmissionsClass/FSFileData.js +++ b/resources/js/SubmissionsClass/FSFileData.js @@ -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} - */ - 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} + */ + 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; } - } - } } diff --git a/resources/js/SubmissionsClass/FSUploader.js b/resources/js/SubmissionsClass/FSUploader.js index a0ff0c9..96af2ed 100644 --- a/resources/js/SubmissionsClass/FSUploader.js +++ b/resources/js/SubmissionsClass/FSUploader.js @@ -127,6 +127,10 @@ export function FSUploader(){ */ handleRemoveFile( index ){ this.files.splice(index, 1); + }, + + changeFileState( index, newState ){ + this.files[index].state = newState; } } diff --git a/resources/views/components/submit-entry-status.blade.php b/resources/views/components/submit-entry-status.blade.php index 5487e5b..37d8503 100644 --- a/resources/views/components/submit-entry-status.blade.php +++ b/resources/views/components/submit-entry-status.blade.php @@ -10,7 +10,7 @@ } }" x-init="init()">
- @if( section_must_be( [ 'romhacks', 'homebrew' ], $section ) ) + @if( section_must_be( [ 'romhacks', 'homebrew' ], $section ) && !$isEdit ) @endif
diff --git a/resources/views/entries/show.blade.php b/resources/views/entries/show.blade.php index c790869..d1c49f5 100644 --- a/resources/views/entries/show.blade.php +++ b/resources/views/entries/show.blade.php @@ -1,3 +1,4 @@ + @extends('layouts.app') @section('page-title', $entry->title . " - " . config('app.name') ) @@ -19,6 +20,37 @@ + @if($isEdit) + + @can('moderate',$entry) +
+
+ + +
+
+ + +
+
+ @endcan + @cannot('moderate', $entry) + + @endcannot + @endif + @csrf
- +
diff --git a/resources/views/submissions/fs-upload.blade.php b/resources/views/submissions/fs-upload.blade.php index a34b877..9027dab 100644 --- a/resources/views/submissions/fs-upload.blade.php +++ b/resources/views/submissions/fs-upload.blade.php @@ -39,12 +39,18 @@ - + + +
@@ -55,6 +61,48 @@
+ @if($isEdit) +
+ + +
+ @endif
+ @if($isEdit) + + @endif diff --git a/routes/console.php b/routes/console.php index 3c9adf1..b4b2ab6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/routes/web.php b/routes/web.php index 2c5e8a8..62fae69 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/storage/hashes.sqlite b/storage/hashes.sqlite new file mode 100644 index 0000000..bd79f6d Binary files /dev/null and b/storage/hashes.sqlite differ diff --git a/vite.config.js b/vite.config.js index 7b604d3..5981261 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,7 +14,6 @@ export default defineConfig({ }), ], }), - tailwindcss(), ], server: { host: 'rhpz.local',