diff --git a/app/Console/Commands/DeleteRejectedEntries.php b/app/Console/Commands/DeleteRejectedEntries.php index c65582f..395742b 100644 --- a/app/Console/Commands/DeleteRejectedEntries.php +++ b/app/Console/Commands/DeleteRejectedEntries.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Entry; +use App\Models\News; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; @@ -20,7 +21,11 @@ class DeleteRejectedEntries extends Command $count = Entry::where('state', 'rejected') ->where('rejected_at', '<', now()->subDays($days)) ->delete(); + $count += News::where('state', 'rejected') + ->where('rejected_at', '<', now()->subDays($days)) + ->delete(); - $this->info("Deleted {$count} entries"); + $this->info("Deleted {$count} entries/news"); + return self::SUCCESS; } } diff --git a/app/Console/Commands/PurgeFeaturedEntries.php b/app/Console/Commands/PurgeFeaturedEntries.php new file mode 100644 index 0000000..e487ce2 --- /dev/null +++ b/app/Console/Commands/PurgeFeaturedEntries.php @@ -0,0 +1,31 @@ +option('days'); + + $cutoff = now()->subDays($days); + + $count = Entry::query() + ->where('featured', true) + ->where('featured_at', '<=', $cutoff) + ->update([ + 'featured' => false, + 'featured_at' => null, + ]); + + $this->info("$count entr" . ($count > 1 ? 'ies' : 'y') . " unfeatured."); + return self::SUCCESS; + } +} diff --git a/app/Helpers/EntryHelpers.php b/app/Helpers/EntryHelpers.php index 603673a..25228a3 100644 --- a/app/Helpers/EntryHelpers.php +++ b/app/Helpers/EntryHelpers.php @@ -3,6 +3,8 @@ namespace App\Helpers; use App\Models\Entry; +use App\Models\EntryFile; +use App\Models\News; use App\Services\XenforoApiService; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -62,13 +64,16 @@ class EntryHelpers { }; } - public static function getLatestComments(Entry $entry, int $limit = 20): array { + public static function getLatestComments(Entry|News $entry, int $limit = 20): array { if( !$entry->comments_thread_id ){ return []; } - $cacheKey = "entry_comments_{$entry->id}"; + if( is_a( $entry, News::class ) ) + $cacheKey = "news_comments_{$entry->id}"; + else + $cacheKey = "entry_comments_{$entry->id}"; return Cache::remember($cacheKey, now()->addDays(1), function () use ($entry, $limit) { $service = app(XenforoApiService::class); @@ -93,6 +98,25 @@ class EntryHelpers { public static function enableOnlinePatcherBasedOnExtension(string $filename): bool { - return Str::endsWith(Str::lower($filename), ['.ips', '.bps', '.xdelta', '.zip' ]); + return Str::endsWith(Str::lower($filename), ['.ips', '.bps', '.xdelta', '.ups', '.aps', '.ppf', '.zip' ]); + } + + public static function getYoutubeVideoId(string $url): ?string + { + $pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i'; + + preg_match($pattern, $url, $matches); + return $matches[1] ?? null; + } + + public static function fileAlreadyDownloaded(EntryFile $entryFile): bool + { + return session("downloaded_file_{$entryFile->file_uuid}", null ) !== null; + } + + public static function markFileAsDownloaded(EntryFile $entryFile): bool + { + session(["downloaded_file_{$entryFile->file_uuid}" => 1]); + return true; } } diff --git a/app/Helpers/PlayOnlineHelpers.php b/app/Helpers/PlayOnlineHelpers.php new file mode 100644 index 0000000..f7377b8 --- /dev/null +++ b/app/Helpers/PlayOnlineHelpers.php @@ -0,0 +1,57 @@ +updateEntriesCount( $count, $userId ); } - public static function entryApproved( Entry $entry ): void + public static function entryApproved( Entry|News $entry ): void { // 1. Update XF Entry count. self::updateEntriesCount( $entry->user_id ); @@ -58,6 +59,29 @@ class XenForoHelpers { $title = "Entry approved : {$entry->title}"; $message = "Your entry {$entry->title} has been approved by {$moderator}."; + $service = app(XenForoApiService::class); + + $service->createConversation([ $entry->user_id ], $title, $message, false, false); + } + + public static function entryRejected( Entry|News $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 rejected : {$entry->title}"; + $message = "Your entry {$entry->title} has been rejected by {$moderator}.\nReason: {$entry->staff_comment}\n\nYou have 7 days to edit your entry before it is permanently deleted."; + + $service = app(XenForoApiService::class); + $service->createConversation([ $entry->user_id ], $title, $message, false, false); } } diff --git a/app/Http/Controllers/DynamicLoadController.php b/app/Http/Controllers/DynamicLoadController.php index bbf847a..0f91890 100644 --- a/app/Http/Controllers/DynamicLoadController.php +++ b/app/Http/Controllers/DynamicLoadController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers; use App\Helpers\XenForoHelpers; +use App\Services\ActivityService; use App\Services\XenforoApiService; use App\Services\XenforoService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -63,4 +65,24 @@ class DynamicLoadController extends Controller return response()->json( $data ); } + + public function activityFeed(Request $request): JsonResponse + { + $availableFilters = ['entries', 'news', 'messages', 'threads', 'clubs']; + + $requested = $request->query('filters') + ? explode(',', $request->query('filters')) + : []; + + $activeFilters = !empty($requested) + ? array_intersect($requested, $availableFilters) + : $availableFilters; + + $service = app(ActivityService::class); + $items = $service->getActivities(array_values($activeFilters)); + + return response()->json([ + 'html' => view('activity.timeline', compact('items'))->render(), + ]); + } } diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 07b9b71..80e4094 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\EntryHelpers; use App\Models\Entry; +use App\Models\News; use Illuminate\Support\Facades\Gate; use Illuminate\Http\Request; use Illuminate\View\View; @@ -11,7 +12,7 @@ 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']; public function index(): View { @@ -31,7 +32,8 @@ class EntryController extends Controller if ($entry->type !== $section) abort(404); - Gate::authorize('viewAny', $entry); + if( !\Auth::guest() ) + Gate::authorize('viewAny', $entry); // Permissions. $entryPolicy = match ($entry->state) { @@ -61,7 +63,12 @@ class EntryController extends Controller ->orderBy('updated_at', 'desc') ->paginate(20); - return view('entries.drafts', compact('drafts')); + $newsDrafts = News::where('user_id', \Auth::user()->user_id ) + ->where('state', 'draft') + ->orderBy('updated_at', 'desc') + ->paginate(20); + + return view('entries.drafts', compact('drafts', 'newsDrafts')); } } diff --git a/app/Http/Controllers/EntryFeaturedRequestController.php b/app/Http/Controllers/EntryFeaturedRequestController.php new file mode 100644 index 0000000..6a158e2 --- /dev/null +++ b/app/Http/Controllers/EntryFeaturedRequestController.php @@ -0,0 +1,23 @@ +featuredRequest($entry); + return response()->json([ + 'success' => $response, + ]); + } + +} diff --git a/app/Http/Controllers/FileServerController.php b/app/Http/Controllers/FileServerController.php index c1bea53..5a03c77 100644 --- a/app/Http/Controllers/FileServerController.php +++ b/app/Http/Controllers/FileServerController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\EntryHelpers; use App\Models\EntryFile; +use App\Models\LogXfUser; use App\Services\FileServersService; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\JsonResponse; @@ -45,7 +46,7 @@ class FileServerController extends Controller { return response()->json($data); } - \Cache::put("uploaded_file_{$fileUuid}", [ + $fileData = [ 'uuid' => $fileUuid, 'type' => $type, 'filename' => $filename, @@ -54,7 +55,16 @@ class FileServerController extends Controller { 'favorite_server' => $data['favorite_server'], 'favorite_at' => time(), 'state' => 'public', - ], now()->addHours(2) ); + ]; + + activity('entry-file') + ->causedBy(LogXfUser::find(\Auth::user()->getAuthIdentifier())) + ->withProperties($fileData) + ->event('file_upload') + ->log("File uploaded") + ; + + \Cache::put("uploaded_file_{$fileUuid}", $fileData, now()->addHours(2) ); $data['finished'] = true; return response()->json($data); @@ -67,7 +77,11 @@ class FileServerController extends Controller { abort(404); } - // TODO: DL Count. + if( !EntryHelpers::fileAlreadyDownloaded($file) ) { + EntryHelpers::markFileAsDownloaded($file); + $file->increaseDownloadCount(); + } + return redirect( $this->fs->getDownloadFileUrl( $file) ); } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index e6054db..3b003d9 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,14 +2,42 @@ namespace App\Http\Controllers; +use App\Models\Entry; +use App\Services\ActivityService; use Illuminate\Http\Request; use Illuminate\View\View; +use App\Models\News; class HomeController extends Controller { - public function index(): View { - return view('home'); + public function __construct( private ActivityService $service ) {} + + public function index( Request $request ): View { + + $filters = [ 'entries', 'news', 'messages', 'threads', 'clubs' ]; + + $cookie = $request->cookie('activity_filters'); + $activeFilters = $cookie ? array_intersect( json_decode( $cookie, true ) ?? [], $filters ) : $filters; + + if( empty( $activeFilters ) ) { + $activeFilters = $filters; + } + + $items = $this->service->getActivities( array_values( $activeFilters ) ); + + $viewFilters = [ + 'entries' => ['label' => 'Entries', 'icon' => 'database'], + 'news' => ['label' => 'News', 'icon' => 'newspaper'], + 'messages' => ['label' => 'Posts', 'icon' => 'message-square'], + 'threads' => ['label' => 'Threads', 'icon' => 'messages-square'], + 'clubs' => ['label' => 'Clubs', 'icon' => 'balloon'], + ]; + + $latestNews = News::published()->latest('created_at')->limit(5)->get(); + $featuredEntries = Entry::published()->where('featured', true)->latest('featured_at')->get(); + + return view('home', compact('items', 'activeFilters', 'viewFilters', 'latestNews', 'featuredEntries')); } } diff --git a/app/Http/Controllers/ModCPController.php b/app/Http/Controllers/ModCPController.php index b0d0d52..bf75df0 100644 --- a/app/Http/Controllers/ModCPController.php +++ b/app/Http/Controllers/ModCPController.php @@ -75,4 +75,9 @@ class ModCPController extends Controller $entry->forceDelete(); return back()->with('success', "Entry permanently deleted"); } + + public function logs() + { + return view('modcp.logs'); + } } diff --git a/app/Http/Controllers/NewsController.php b/app/Http/Controllers/NewsController.php new file mode 100644 index 0000000..23bb429 --- /dev/null +++ b/app/Http/Controllers/NewsController.php @@ -0,0 +1,124 @@ +state) { + 'pending' => 'viewPending', + 'draft' => 'viewDraft', + 'rejected' => 'viewRejected', + 'hidden' => 'viewHidden', + 'locked' => 'viewLocked', + 'published' => null, + 'default' => null + }; + + if ($entryPolicy) + Gate::authorize($entryPolicy, $news); + + $comments = EntryHelpers::getLatestComments($news); + + return view('news.show', compact('news', 'comments')); + } + + public function create(Request $request) + { + $data = [ + 'news' => new News(), + 'isEdit' => false, + 'oldCategory' => old('category') ? [ old('category') ] : [] + ]; + + return view ('news.create', $data); + } + + public function edit(Request $request, News $news) + { + $data = [ + 'news' => $news, + 'isEdit' => true, + 'oldCategory' => old('category', $news->category_id) ? [ old('category', $news->category_id) ] : [] + ]; + + return view ('news.edit', $data); + } + + public function store(Request $request) + { + $request = $request->input('submit-state') === 'draft' ? app(StoreNewsDraftRequest::class) : app(StoreNewsRequest::class); + $request->validateResolved(); + + $service = app(NewsService::class); + + try { + $entry = $service->storeNews($request); + + return match ($entry->state) { + 'published' => redirect()->route('news.show', ['news' => $entry->slug])->with('success', "Your entry has been published."), + 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), + default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") + }; + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + } + + public function update(Request $request, News $news) + { + $request = $request->input('submit-state') === 'draft' ? app(StoreNewsDraftRequest::class) : app(StoreNewsRequest::class); + $request->validateResolved(); + + $service = app(NewsService::class); + + try { + $news = $service->editNews($request, $news); + + return match ($news->state) { + 'published' => redirect()->route('news.show', ['news' => $news->slug])->with('success', "Your entry has been published."), + 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), + default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") + }; + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + + } + + public function destroy(Request $request, News $news) + { + if( $news->comments_thread_id ) + DeleteXenForoCommentsThread::dispatch( $news->comments_thread_id ); + + $news->delete(); + return redirect( route('news.index') )->with('success', "Entry successfully deleted."); + } +} diff --git a/app/Http/Controllers/QueueController.php b/app/Http/Controllers/QueueController.php index c9f4e91..7b5d037 100644 --- a/app/Http/Controllers/QueueController.php +++ b/app/Http/Controllers/QueueController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\XenForoHelpers; use App\Models\Entry; +use App\Models\News; use App\Services\XenforoService; use Illuminate\Http\Request; class QueueController extends Controller @@ -14,7 +15,25 @@ class QueueController extends Controller ->with(['authors', 'game.platform']) ->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END") ->orderBy('created_at', 'asc') - ->get(); + ->get() + ->map(fn($item) => $item->setAttribute('queue_type', 'entry')); + + $news = News::inQueue() + ->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END") + ->orderBy('created_at', 'asc') + ->get() + ->map(fn($item) => $item->setAttribute('queue_type', 'news')); + + $entries = $entries->concat($news)->sort(function($a, $b) { + $aPending = $a->state === 'pending' ? 0 : 1; + $bPending = $b->state === 'pending' ? 0 : 1; + + if($aPending !== $bPending) { + return $aPending <=> $bPending; + } + + return $a->created_at <=> $b->created_at; + })->values(); return view('queue.index', compact('entries')); } @@ -25,24 +44,53 @@ class QueueController extends Controller $entry->update(['staff_comment' => $request->input('comment')]); + return back()->with('success', 'Comment supdated'); + } + + public function updateComment_news(Request $request, News $news) + { + $request->validate(['comment' => 'nullable|string|max:2000']); + + $news->update(['staff_comment' => $request->input('comment')]); + return back()->with('success', 'Comment updated'); } public function approve(Request $request, Entry $entry) { - // $entry->update(['state' => 'published']); + $entry->update(['state' => 'published', 'created_at' => now()]); XenForoHelpers::entryApproved($entry); return back()->with('success', 'Entry approved'); } + public function approve_news(Request $request, News $news) + { + $news->update(['state' => 'published', 'created_at' => now()]); + + XenForoHelpers::entryApproved($news); + + 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() ]); + XenForoHelpers::entryRejected($entry); + return back()->with('success', 'Entry rejected'); + } + + public function reject_news(Request $request, News $news) + { + $request->validate(['reason' => 'nullable|string|max:2000']); + + $news->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]); + + XenForoHelpers::entryRejected($news); return back()->with('success', 'Entry rejected'); } diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php index 10d9753..61254e2 100644 --- a/app/Http/Controllers/RedirectController.php +++ b/app/Http/Controllers/RedirectController.php @@ -14,4 +14,13 @@ class RedirectController extends Controller return redirect()->route('entries.show', ['section' => $entry->type, 'entry' => $entry])->with('success', "Your report has been sent."); } + + public function newsReportRedirect( Request $request ) + { + $id = $request->input('id'); + $entry = News::findOrFail($id); + + return redirect()->route('news.show', ['news' => $entry])->with('success', "Your report has been sent."); + } + } diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php index 5d8b4c5..ebc7c4b 100644 --- a/app/Http/Controllers/SubmissionController.php +++ b/app/Http/Controllers/SubmissionController.php @@ -33,6 +33,62 @@ class SubmissionController extends Controller public function __construct(private SubmissionsService $services){} + public function index(Request $request) + { + + $entryTypes = [ + 'romhack' => [ + 'slug' => 'romhacks', + 'label' => 'Romhack', + 'icon' => 'gamepad-2', + 'color' => '#4a6fc2', + 'bg' => '#d5e1fc1a', + 'border' => '#d5e1fc40', + ], + 'translation' => [ + 'slug' => 'translations', + 'label' => 'Translation', + 'icon' => 'languages', + 'color' => '#4a8a2a', + 'bg' => '#e7f4d91a', + 'border' => '#e7f4d940', + ], + 'homebrew' => [ + 'slug' => 'homebrew', + 'label' => 'Homebrew', + 'icon' => 'cpu', + 'color' => '#c23060', + 'bg' => '#ffeaf01a', + 'border' => '#ffeaf040', + ], + 'utility' => [ + 'slug' => 'utilities', + 'label' => 'Utility', + 'icon' => 'wrench', + 'color' => '#8a6600', + 'bg' => '#fff8d51a', + 'border' => '#fff8d540', + ], + 'document' => [ + 'slug' => 'documents', + 'label' => 'Document', + 'icon' => 'file-text', + 'color' => '#7a35c2', + 'bg' => '#f3eaff1a', + 'border' => '#f3eaff40', + ], + 'lua-script' => [ + 'slug' => 'lua-script', + 'label' => 'Lua script', + 'icon' => 'terminal', + 'color' => '#a04515', + 'bg' => '#eed6c51a', + 'border' => '#eed6c540', + ] ]; + + return view('submissions.index', compact('entryTypes')); + } + public function create(Request $request, string $section) { $data = [ @@ -108,7 +164,7 @@ class SubmissionController extends Controller if( $entry->type !== $section ) abort(404); - if( $entry->comments_thread_id) + if( $entry->comments_thread_id ) DeleteXenForoCommentsThread::dispatch( $entry->comments_thread_id ); $entry->delete(); diff --git a/app/Http/Controllers/ToolsController.php b/app/Http/Controllers/ToolsController.php index ef2180f..9e54d91 100644 --- a/app/Http/Controllers/ToolsController.php +++ b/app/Http/Controllers/ToolsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Entry; use App\Models\EntryFile; use App\Services\FileServersService; use Illuminate\Http\Request; @@ -15,9 +16,29 @@ class ToolsController extends Controller return view('tools.patcher'); } - public function directPatch( Request $request, int $entry_id, EntryFile $file ) + public function directPatch( Request $request, int $entryId, EntryFile $file ) { - if( $file->entry_id != $entry_id ) { + if( $file->entry_id != $entryId ) { + abort(404); + } + + $service = app(FileServersService::class); + + + $patches = [ + 'file' => $service->getDownloadFileUrl( $file ), + 'name' => $file->entry->title, + 'outputName' => $file->filename + ]; + + + + return view('tools.patcher', compact('patches')); + } + + public function play( Request $request, int $entryId, EntryFile $file ) + { + if( $file->entry_id != $entryId ) { abort(404); } @@ -28,7 +49,11 @@ class ToolsController extends Controller 'name' => $file->entry->title, 'outputName' => $file->filename ]; + $emuConfig = [ + 'core' => $file->playOnlineSetting?->core, + 'threads' => $file->playOnlineSetting?->threads, + ]; - return view('tools.patcher', compact('patches')); + return view('tools.play', compact('patches', 'emuConfig')); } } diff --git a/app/Http/Requests/StoreEntryRequest.php b/app/Http/Requests/StoreEntryRequest.php index 5884b85..e9b7a58 100644 --- a/app/Http/Requests/StoreEntryRequest.php +++ b/app/Http/Requests/StoreEntryRequest.php @@ -149,6 +149,9 @@ class StoreEntryRequest extends FormRequest $rules['files_metadata'] = 'array|nullable'; $rules['files_metadata.*.online_patcher'] = 'nullable|boolean'; $rules['files_metadata.*.secondary_online_patcher'] = 'nullable|boolean|required_with:files_metadata.*.online_patcher'; + $rules['files_metadata.*.play_online'] = 'nullable|boolean'; + $rules['files_metadata.*.play_online_core'] = 'nullable|string'; + $rules['files_metadata.*.play_online_threads'] = 'nullable|boolean'; } if( $isEdit && $this->user()->can('moderate', $this->route('entry') ) ){ diff --git a/app/Http/Requests/StoreNewsDraftRequest.php b/app/Http/Requests/StoreNewsDraftRequest.php new file mode 100644 index 0000000..87d806c --- /dev/null +++ b/app/Http/Requests/StoreNewsDraftRequest.php @@ -0,0 +1,26 @@ + $r === 'required' ? 'nullable' : $r, $rule); + } + + return preg_replace( + ['/\brequired_without\S*/', '/required_with\S*/', '/\brequired\b/'], + ['nullable', 'nullable', 'nullable'], + $rule + ); + }, $rules ); + + return $rules; + } +} diff --git a/app/Http/Requests/StoreNewsRequest.php b/app/Http/Requests/StoreNewsRequest.php new file mode 100644 index 0000000..822abc3 --- /dev/null +++ b/app/Http/Requests/StoreNewsRequest.php @@ -0,0 +1,78 @@ +route('news'); + if( $news ) + return $this->user()->can('update', $news); + + return $this->user()->can('create', News::class); + } + + public function prepareForValidation(): void + { + $this->merge([ + 'gallery' => $this->input('gallery') !== '' ? $this->input('gallery') : null, + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $isEdit = (bool) $this->route('news'); + + $rules = []; + + $rules['title'] = 'required|string|max:255'; + $rules['category'] = 'required|integer|exists:categories,id'; + + $rules['description'] = 'required|string'; + $rules['gallery'] = 'array|required|min:1'; + $rules['gallery.*'] = [ 'string', new PublicFileExists ]; + + $rules['entry_id'] = 'nullable|integer|exists:entries,id'; + $rules['release_site'] = 'nullable|url|max:500'; + $rules['youtube_video'] = 'nullable|url|max:500'; + + if( $isEdit ){ + $ss = 'draft,pending,published'; + if( \Auth::user()->can('moderate', $this->route('news')) && \Auth::user()->can('view-hidden', $this->route('news')) ) + $ss .= ',hidden'; + if(\Auth::user()->can('moderate', $this->route('news')) && \Auth::user()->can('view-locked', $this->route('news')) ) + $ss .= ',locked'; + $rules['submit-state'] = 'required|in:' . $ss; + } else { + if( $this->user()->can('skip-queue', '\App\Models\News') ){ + $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('news') ) ){ + $rules['staff_comment'] = 'nullable|string'; + $rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ]; + $rules['comments_thread_id'] = 'nullable|integer'; + } + + return $rules; + + } +} diff --git a/app/Jobs/CreateXenForoCommentsThread.php b/app/Jobs/CreateXenForoCommentsThread.php index 0edeb09..ce532c5 100644 --- a/app/Jobs/CreateXenForoCommentsThread.php +++ b/app/Jobs/CreateXenForoCommentsThread.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Entry; +use App\Models\News; use App\Services\XenforoApiService; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -18,7 +19,7 @@ class CreateXenForoCommentsThread implements ShouldQueue * Create a new job instance. */ public function __construct( - protected Entry $entry + protected Entry|News $entry ) { // diff --git a/app/Jobs/DeleteFile.php b/app/Jobs/DeleteFile.php new file mode 100644 index 0000000..52369dd --- /dev/null +++ b/app/Jobs/DeleteFile.php @@ -0,0 +1,36 @@ +deleteFile($this->filePath, $this->fileName, $this->userId); + } +} diff --git a/app/Livewire/ActivityLogs.php b/app/Livewire/ActivityLogs.php new file mode 100644 index 0000000..a80b40d --- /dev/null +++ b/app/Livewire/ActivityLogs.php @@ -0,0 +1,64 @@ +resetPage(); } + + public function clearFilters(): void + { + $this->reset('search', 'logName', 'event', 'dateFrom', 'dateTo', 'causerId'); + $this->resetPage(); + } + + public function render() + { + $logs = Activity::query() + ->with(['causer']) + ->when($this->search, fn($q) => $q + ->where('description', 'like', "%{$this->search}%") + ->orWhere('subject_type', 'like', "%{$this->search}%") + ->orWhere('log_name', 'like', "%{$this->search}%") + ) + ->when($this->logName, fn($q) => $q->where('log_name', $this->logName)) + ->when($this->event, fn($q) => $q->where('event', $this->event)) + ->when($this->causerId, fn($q) => $q->where('causer_id', $this->causerId)) + ->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom)) + ->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo)) + ->latest() + ->paginate(50); + + $logNames = Activity::distinct()->orderBy('log_name')->pluck('log_name')->filter()->values(); + $events = Activity::distinct()->orderBy('event')->pluck('event')->filter()->values(); + + $hasFilters = $this->logName || $this->event || $this->dateFrom || $this->dateTo || $this->causerId; + + return view('livewire.activity-logs', compact('logs', 'logNames', 'events', 'hasFilters')); + } +} diff --git a/app/Livewire/EntrySelector.php b/app/Livewire/EntrySelector.php new file mode 100644 index 0000000..e78b74c --- /dev/null +++ b/app/Livewire/EntrySelector.php @@ -0,0 +1,72 @@ +selectedEntryId = $oldEntryId; + $this->entryName = $entry->complete_title ?? $entry->title; + } + } + } + + public function updatedSearch(): void + { + if( $this->selectedEntryId ) { + $this->selectedEntryId = null; + $this->entryName = null; + } + $this->dropdown = strlen($this->search) > 2; + } + + public function selectEntry( int $id ){ + $entry = Entry::find($id); + if( $entry ) { + $this->selectedEntryId = $id; + $this->entryName = $entry->complete_title ?? $entry->title; + $this->search = $entry->complete_title ?? $entry->title; + $this->dropdown = false; + + } + } + + public function clearEntry(): void + { + $this->selectedEntryId = null; + $this->entryName = null; + $this->search = ''; + } + + public function render() + { + $entries = collect(); + + if( $this->dropdown && strlen($this->search) > 2 ) { + $entries = Entry::where('complete_title', 'like', '%' . $this->search . '%') + ->orWhere('title', 'like', '%' . $this->search . '%') + ->orderBy('complete_title', 'asc') + ->orderBy('title', 'asc') + ->limit(20) + ->get(); + } + + $data = [ 'entries' => $entries, 'required_chars' => 3 ]; + + return view('livewire.entry-selector', $data); + } +} diff --git a/app/Livewire/NewsDatabase.php b/app/Livewire/NewsDatabase.php new file mode 100644 index 0000000..3c64c58 --- /dev/null +++ b/app/Livewire/NewsDatabase.php @@ -0,0 +1,104 @@ + 'Date added', + 'title' => 'Title' + ]; + + public const int PAGINATION = 30; + + public function updatedSearch(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedCategories(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + public function updatedCategoriesMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); } + + public function clearFilters(): void + { + $this->reset([ + 'search', 'categories', + ]); + $this->resetPage(); + } + + public function setSort(string $field): void + { + if( $this->sortBy === $field ) { + $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortBy = $field; + $this->sortDir = 'asc'; + } + $this->resetPage(); + $this->dispatch('filters-updated'); + } + + private function buildQuery() + { + $query = News::query()->published(); + + if( $this->search ){ + $query->where(function($q){ + $q->where('title', 'like', '%'.$this->search.'%'); + }); + } + + if( $this->categories ) { + $query->whereIn('category_id', $this->categories); + } + + return $query->orderBy($this->sortBy, $this->sortDir); + } + + public function render() + { + return view('livewire.news-database', [ + 'news' => $this->buildQuery()->paginate(self::PAGINATION), + 'allCategories' => Category::where(function ($query) { + $query->whereJsonContains('restricted_to', "news") + ->orWhereNull('restricted_to'); + })->orderBy('name')->get() + ]); + } +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php index ef532b9..22a2795 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Helpers\EntryHelpers; use App\Traits\HasGallery; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,6 +11,8 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Monolog\Level; +use Spatie\Activitylog\Models\Concerns\LogsActivity; +use Spatie\Activitylog\Support\LogOptions; /** * @property int $id @@ -22,6 +25,7 @@ use Monolog\Level; * @property string|null $staff_comment * @property \Illuminate\Support\Carbon|null $rejected_at * @property bool $featured + * @property \Illuminate\Support\Carbon|null $featured_at * @property int|null $game_id * @property int|null $platform_id * @property int|null $status_id @@ -69,6 +73,7 @@ use Monolog\Level; * @method static Builder|Entry whereDeletedAt($value) * @method static Builder|Entry whereDescription($value) * @method static Builder|Entry whereFeatured($value) + * @method static Builder|Entry whereFeaturedAt($value) * @method static Builder|Entry whereGameId($value) * @method static Builder|Entry whereId($value) * @method static Builder|Entry whereLevelId($value) @@ -90,12 +95,14 @@ use Monolog\Level; * @method static Builder|Entry whereYoutubeLink($value) * @method static Builder|Entry withTrashed(bool $withTrashed = true) * @method static Builder|Entry withoutTrashed() + * @property-read \Illuminate\Database\Eloquent\Collection $activitiesAsSubject + * @property-read int|null $activities_as_subject_count * @mixin \Eloquent */ class Entry extends Model { - use SoftDeletes, HasGallery; + use SoftDeletes, HasGallery, LogsActivity; /** * @var string[] @@ -108,6 +115,7 @@ class Entry extends Model 'main_image', 'state', 'featured', + 'featured_at', 'game_id', 'platform_id', 'status_id', @@ -121,7 +129,8 @@ class Entry extends Model 'comments_thread_id', 'staff_comment', 'rejected_at', - 'level_id' + 'level_id', + 'created_at' ]; /** @@ -131,6 +140,7 @@ class Entry extends Model 'featured' => 'boolean', 'release_date' => 'date', 'rejected_at' => 'datetime', + 'featured_at' => 'datetime', ]; public function scopePublished( Builder $query ): Builder { @@ -197,7 +207,7 @@ class Entry extends Model return $this->hasMany(EntryHash::class); } - public function parseStaffCredits(): array { + public function parseStaffCredits(): ?array { return json_decode( $this->staff_credits ?? "", true ); } @@ -205,10 +215,17 @@ class Entry extends Model if( !$this->youtube_link ) return null; - $pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i'; + return EntryHelpers::getYoutubeVideoId( $this->youtube_link ); + } - preg_match($pattern, $this->youtube_link, $matches); - return $matches[1] ?? null; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->useLogName('entry') + ->logAll() + ->logOnlyDirty() + ->dontLogEmptyChanges() + ->setDescriptionForEvent(fn(string $eventName) => "Entry {$eventName}"); } } diff --git a/app/Models/EntryFile.php b/app/Models/EntryFile.php index 99bcd7f..a213637 100644 --- a/app/Models/EntryFile.php +++ b/app/Models/EntryFile.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; /** * @property int $id @@ -36,6 +37,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereSecondaryOnlinePatcher($value) * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereState($value) * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereUpdatedAt($value) + * @property-read \App\Models\PlayOnlineSetting|null $playOnlineSetting + * @property int $download_count + * @method static \Illuminate\Database\Eloquent\Builder|EntryFile whereDownloadCount($value) * @mixin \Eloquent */ class EntryFile extends Model @@ -53,4 +57,15 @@ class EntryFile extends Model return $this->belongsTo(Entry::class); } + public function playOnlineSetting(): HasOne + { + return $this->hasOne(PlayOnlineSetting::class,'file_id'); + } + + public function increaseDownloadCount(): void + { + $this->download_count++; + $this->save(); + } + } diff --git a/app/Models/EntryGallery.php b/app/Models/EntryGallery.php index 48b6ff2..e8a9014 100644 --- a/app/Models/EntryGallery.php +++ b/app/Models/EntryGallery.php @@ -4,5 +4,24 @@ namespace App\Models; /** * @deprecated Use Gallery instead. + * @property int $id + * @property string $galleryable_type + * @property int $galleryable_id + * @property string $image + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $galleryable + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery query() + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereGalleryableId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereGalleryableType($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereImage($value) + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereUpdatedAt($value) + * @property int $order + * @method static \Illuminate\Database\Eloquent\Builder|EntryGallery whereOrder($value) + * @mixin \Eloquent */ class EntryGallery extends Gallery {} diff --git a/app/Models/Gallery.php b/app/Models/Gallery.php index 3d5f7dd..288650e 100644 --- a/app/Models/Gallery.php +++ b/app/Models/Gallery.php @@ -19,6 +19,13 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereId($value) * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereImage($value) * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereUpdatedAt($value) + * @property string $galleryable_type + * @property int $galleryable_id + * @property-read Model|\Eloquent $galleryable + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereGalleryableId($value) + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereGalleryableType($value) + * @property int $order + * @method static \Illuminate\Database\Eloquent\Builder|Gallery whereOrder($value) * @mixin \Eloquent */ class Gallery extends Model diff --git a/app/Models/LogXfUser.php b/app/Models/LogXfUser.php new file mode 100644 index 0000000..def689a --- /dev/null +++ b/app/Models/LogXfUser.php @@ -0,0 +1,129 @@ +|LogXfUser newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser query() + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereActivityVisible($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAlertsUnread($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAlertsUnviewed($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarHeight($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarHighdpi($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarOptimized($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereAvatarWidth($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereConversationsUnread($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereCustomTitle($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereDisplayStyleGroupId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereGravatar($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsAdmin($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsBanned($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsModerator($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereIsStaff($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereLanguageId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereLastActivity($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereLastSummaryEmailDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereMessageCount($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser wherePermissionCombinationId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser wherePrivacyPolicyAccepted($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereQuestionSolutionCount($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereReactionScore($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereRegisterDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereRhpzEntryCount($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereSecondaryGroupIds($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereSecretKey($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereSecurityLock($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereStyleId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereStyleVariation($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereTermsAccepted($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereTimezone($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereTrophyPoints($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUserGroupId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUserState($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUsername($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUsernameDate($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereUsernameDateVisible($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereVisible($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereVoteScore($value) + * @method static \Illuminate\Database\Eloquent\Builder|LogXfUser whereWarningPoints($value) + * @mixin \Eloquent + */ +class LogXfUser extends Model +{ + + protected $connection = 'xenforo'; + protected $table = 'user'; + protected $primaryKey = 'user_id'; + public $timestamps = false; + public $incrementing = true; + + public function save(array $options = []) + { + return false; + } + + public function delete() + { + return false; + } + + public function update(array $attributes = [], array $options = []) + { + return false; + } + +} diff --git a/app/Models/News.php b/app/Models/News.php new file mode 100644 index 0000000..7bd8548 --- /dev/null +++ b/app/Models/News.php @@ -0,0 +1,118 @@ + $gallery + * @property-read int|null $gallery_count + * @method static Builder|News inQueue(int $daysRejected = 7) + * @method static Builder|News newModelQuery() + * @method static Builder|News newQuery() + * @method static Builder|News onlyTrashed() + * @method static Builder|News published() + * @method static Builder|News query() + * @method static Builder|News whereCategoryId($value) + * @method static Builder|News whereCommentsThreadId($value) + * @method static Builder|News whereCreatedAt($value) + * @method static Builder|News whereDeletedAt($value) + * @method static Builder|News whereDescription($value) + * @method static Builder|News whereEntryId($value) + * @method static Builder|News whereId($value) + * @method static Builder|News whereRejectedAt($value) + * @method static Builder|News whereRelevantLink($value) + * @method static Builder|News whereSlug($value) + * @method static Builder|News whereStaffComment($value) + * @method static Builder|News whereState($value) + * @method static Builder|News whereTitle($value) + * @method static Builder|News whereUpdatedAt($value) + * @method static Builder|News whereUserId($value) + * @method static Builder|News whereYoutubeLink($value) + * @method static Builder|News withTrashed(bool $withTrashed = true) + * @method static Builder|News withoutTrashed() + * @mixin \Eloquent + */ +class News extends Model +{ + + use SoftDeletes, HasGallery; + + protected $table = 'news'; + + protected $fillable = [ + 'title', + 'slug', + 'description', + 'state', + 'category_id', + 'entry_id', + 'relevant_link', + 'youtube_link', + 'user_id', + 'comments_thread_id', + 'staff_comment', + 'rejected_at', + 'created_at' + ]; + + + protected $casts = [ + '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) ); + }); + } + + public function entry(): BelongsTo + { + return $this->belongsTo(Entry::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function getYoutubeVideoId(): ?string { + if( !$this->youtube_link ) + return null; + + return EntryHelpers::getYoutubeVideoId( $this->youtube_link ); + } + +} diff --git a/app/Models/Platform.php b/app/Models/Platform.php index 8fc5269..d62581b 100644 --- a/app/Models/Platform.php +++ b/app/Models/Platform.php @@ -25,6 +25,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @method static \Illuminate\Database\Eloquent\Builder|Platform whereShortName($value) * @method static \Illuminate\Database\Eloquent\Builder|Platform whereSlug($value) * @method static \Illuminate\Database\Eloquent\Builder|Platform whereUpdatedAt($value) + * @property string|null $play_online_core + * @method static \Illuminate\Database\Eloquent\Builder|Platform wherePlayOnlineCore($value) * @mixin \Eloquent */ class Platform extends Model diff --git a/app/Models/PlayOnlineSetting.php b/app/Models/PlayOnlineSetting.php new file mode 100644 index 0000000..95c0b98 --- /dev/null +++ b/app/Models/PlayOnlineSetting.php @@ -0,0 +1,45 @@ +|PlayOnlineSetting newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting query() + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereCore($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereFileId($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereThreads($value) + * @method static \Illuminate\Database\Eloquent\Builder|PlayOnlineSetting whereUpdatedAt($value) + * @mixin \Eloquent + */ +class PlayOnlineSetting extends Model +{ + protected $primaryKey = 'file_id'; + public $incrementing = false; + protected $keyType = 'int'; + + protected $fillable = [ + 'file_id', + 'core', + 'threads' + ]; + + protected $casts = [ + 'threads' => 'boolean', + ]; + + public function file(): BelongsTo + { + return $this->belongsTo(EntryFile::class, 'file_id'); + } +} diff --git a/app/Policies/NewsPolicy.php b/app/Policies/NewsPolicy.php new file mode 100644 index 0000000..75cce62 --- /dev/null +++ b/app/Policies/NewsPolicy.php @@ -0,0 +1,165 @@ +_can( 'romhackplaza', 'view' ) ) + return true; + + return false; + } + + public function viewPending(User $user, News $news): bool + { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canModerateEntries' ); + } + + public function viewDraft(User $user, News $news): bool { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ); + } + + public function viewRejected(User $user, News $news): bool + { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ); + } + + public function viewHidden(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canSeeHiddenEntries' ); + } + + public function viewLocked(User $user, News $news): bool + { + // Author. + if( $news->user_id === $user->user_id ) + return true; + + return $user->_can('romhackplaza', 'canSeeLockedEntries' ); + } + + public function create(User $user, ?News $news = null ): bool + { + return $user->_can( 'romhackplaza', 'canSubmitEntry' ); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, News $news): bool + { + if( $news->state === 'published' ){ + + // Staff editors + if( $user->_can('romhackplaza', 'canEditOthersEntries') ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + return false; + + } else if( $news->state === 'pending' ){ + + // Staff moderation. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can('romhackplaza', 'canModerateEntries') ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + } else if( $news->state === 'draft' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeOthersDrafts' ) ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + } else if( $news->state === 'rejected' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeRejectedEntries' ) ) + return true; + + // Author. + if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $news->user_id === $user->user_id ) + return true; + + } else if( $news->state === 'locked' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeLockedEntries' ) ) + return true; + + return false; + + } else if( $news->state === 'hidden' ){ + + // Staff. + if( $user->_can('romhackplaza', 'canEditOthersEntries') && $user->_can( 'romhackplaza', 'canSeeHiddenEntries' ) ) + return true; + + return false; + + } + + return false; + } + + public function skipQueue(User $user, ?News $news = null): bool + { + return $user->_can( 'romhackplaza', 'canSubmitEntryInPublished' ); + } + + public function updateComment(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function manageButtonsInQueue(User $user, News $news): bool + { + if( $news->state === 'rejected' ){ + return $this->viewRejected( $user, $news ); + } + + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function approve(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function reject(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } + + public function moderate(User $user, News $news): bool + { + return $user->_can('romhackplaza', 'canModerateEntries' ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7a1413e..bba7ec1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,8 +6,10 @@ use App\Auth\XenForoGuard; use App\Auth\XenForoUser; use App\Policies\TempFilePolicy; use App\Services\TemporaryFileService; +use App\Support\XenForoCauserResolver; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; +use Spatie\Activitylog\Support\CauserResolver; class AppServiceProvider extends ServiceProvider { @@ -16,7 +18,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(CauserResolver::class, XenForoCauserResolver::class ); } /** diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php new file mode 100644 index 0000000..896f4b1 --- /dev/null +++ b/app/Services/ActivityService.php @@ -0,0 +1,223 @@ +merge($this->getEntries()); + } + if( in_array( 'news', $activities ) ) { + $c = $c->merge($this->getNews()); + } + if( in_array( 'messages', $activities ) ) { + $c = $c->merge($this->getMessages()); + } + if( in_array( 'threads', $activities ) ) { + $c = $c->merge($this->getThreads()); + } + if( in_array( 'clubs', $activities ) ) { + $c = $c->merge($this->getClubs()); + } + + return $c->sortByDesc('date') + ->values() + ->take(30) + ->map(function(array $item){ + $obj = (object) $item; + $obj->date = Carbon::createFromTimestamp($obj->date); + return $obj; + }); + } + + private function formatEntry( Entry $entry ): array + { + return [ + 'type' => 'entry', + 'title' => $entry->complete_title ?? $entry->title, + 'url' => route('entries.show', ['section' => $entry->type, 'entry' => $entry]), + 'image' => $entry->main_image ? \Storage::url($entry->main_image) : null, + 'date' => $entry->created_at->timestamp, + 'author' => $entry->authors->pluck('name')->implode(', '), + 'user_id' => $entry->user_id, + 'badge' => EntryCard::ENTRY_TYPES_BADGE[$entry->type], + 'badge_class' => $entry->type, + 'excerpt' => $entry->description ? \Str::limit(strip_tags($entry->description), 80) : null, + 'meta' => $entry->getRealPlatform()?->name + ]; + } + + private function formatNews( News $news ): array + { + return [ + 'type' => 'news', + 'title' => $news->title, + 'url' => route('news.show', ['news' => $news]), + 'image' => $news->gallery()->first() ? \Storage::url($news->gallery()->first()->image) : null, + 'date' => $news->created_at->timestamp, + 'author' => null, + 'user_id' => $news->user_id, + 'badge' => 'News', + 'badge_class' => 'news', + 'excerpt' => $news->description ? \Str::limit(strip_tags($news->description), 80) : null, + 'meta' => $news->category?->name + ]; + } + + private function formatMessage( object $message ): array + { + return [ + 'type' => 'message', + 'title' => $message->title, + 'url' => xfRoute('threads/.' ) . $message->thread_id . '/post-' . $message->post_id, + 'image' => null, + 'date' => $message->post_date, + 'author' => null, + 'user_id' => $message->user_id, + 'badge' => 'Post', + 'badge_class' => 'message', + 'excerpt' => $message->message ? \Str::limit(strip_tags($message->message), 80) : null, + 'meta' => null + ]; + + } + + private function formatThread( object $thread ): array + { + return [ + 'type' => 'thread', + 'title' => $thread->title, + 'url' => xfRoute('threads/.' ) . $thread->thread_id, + 'image' => null, + 'date' => $thread->post_date, + 'author' => null, + 'user_id' => $thread->user_id, + 'badge' => 'Thread', + 'badge_class' => 'thread', + 'excerpt' => $thread->message ? \Str::limit(strip_tags($thread->message), 80) : null, + 'meta' => null + ]; + + } + + private function formatClub( object $club ): array + { + return [ + 'type' => 'club', + 'title' => $club->title, + 'url' => xfRoute('forums/.' ) . $club->node_id, + 'image' => null, // TODO: Remplacer par banner_date + 'date' => $club->club_creation_date, + 'author' => null, + 'user_id' => $club->user_id, + 'badge' => 'Club', + 'badge_class' => 'club', + 'excerpt' => $club->description ? \Str::limit(strip_tags($club->description), 80) : null, + 'meta' => null + ]; + } + + private function getEntries(): array + { + return Cache::remember('activity_entries', self::CACHE_ENTRIES, function() { + return Entry::published() + ->with(['authors', 'game.platform']) + ->latest('created_at') + ->limit(self::ITEMS_PER_TYPE) + ->get() + ->map($this->formatEntry(...)) + ->toArray(); + }); + } + + private function getNews(): array + { + return Cache::remember('activity_news', self::CACHE_NEWS, function() { + return News::published() + ->with('gallery') + ->latest('created_at') + ->limit(self::ITEMS_PER_TYPE) + ->get() + ->map($this->formatNews(...)) + ->toArray(); + }); + } + + private function getMessages(): array + { + return Cache::remember('activity_messages', self::CACHE_MESSAGES, function() { + return DB::connection('xenforo') + ->table('post') + ->join('user', 'post.user_id', '=', 'user.user_id') + ->join('thread', 'post.thread_id', '=', 'thread.thread_id') + ->where('post.message_state', 'visible') + ->where('thread.first_post_id', '!=', 'post.post_id') + ->orderByDesc('post.post_date') + ->limit(self::ITEMS_PER_TYPE) + ->select([ + 'thread.title', 'thread.thread_id', 'post.post_id', 'post.post_date', + 'post.user_id', 'post.message' + ]) + ->get() + ->map($this->formatMessage(...)) + ->toArray(); + }); + } + + private function getThreads(): array + { + return Cache::remember('activity_threads', self::CACHE_THREADS, function() { + return DB::connection('xenforo') + ->table('thread') + ->join('user', 'thread.user_id', '=', 'user.user_id') + ->join('post', 'thread.first_post_id', '=', 'post.post_id') + ->where('thread.discussion_state', 'visible') + ->where('thread.discussion_type', '!=', 'redirect' ) + ->orderByDesc('thread.post_date') + ->limit(self::ITEMS_PER_TYPE) + ->select([ + 'thread.title', 'thread.thread_id', 'thread.post_date', 'thread.user_id', + 'post.message' + ]) + ->get() + ->map($this->formatThread(...)) + ->toArray(); + }); + } + + private function getClubs(): array + { + return Cache::remember('activity_clubs', self::CACHE_CLUBS, function() { + return DB::connection('xenforo') + ->table('club') + ->where('club_state', 'visible') + ->orderByDesc('club_creation_date') + ->limit(self::ITEMS_PER_TYPE) + ->select([ + 'club.title', 'club.description', 'club.node_id', + 'club.club_creation_date', 'club.user_id' + ]) + ->get() + ->map($this->formatClub(...)) + ->toArray(); + }); + } +} diff --git a/app/Services/FileServersService.php b/app/Services/FileServersService.php index 42d82c0..742db53 100644 --- a/app/Services/FileServersService.php +++ b/app/Services/FileServersService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\EntryFile; +use App\Models\LogXfUser; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; @@ -110,7 +111,6 @@ class FileServersService { 'filename' => $filename, 'current_chunk' => $currentChunk, 'total_chunks' => $totalChunks, - // TODO : Must replace User ID 'zeus' => $this->generateZeusToken( \Auth::user()->user_id, $server['base_url'], "Uploadchunk" ), ]); @@ -129,4 +129,43 @@ class FileServersService { return $data; } + public function deleteFile( + string $filePath, + string $fileName, + int $userId + ): bool { + + foreach( $this->servers as $serverKey => $server ){ + + $response = Http::withHeaders([]) + ->post( $server['delete_file'], [ + 'filepath' => $filePath, + 'filename' => $fileName, + 'zeus' => $this->generateZeusToken( $userId, $server['base_url'], "Deletefile" ), + ]); + + if (!$response->successful()) { + throw new \RuntimeException( $response->body() ); + } + + $data = $response->json(); + if( isset( $data['status'] ) && $data['status'] === 'deleted' ){ + continue; + } else { + return false; + } + } + + activity('entry-file') + ->causedBy(LogXfUser::find($userId)) + ->withProperties([ + 'filepath' => $filePath, + 'filename' => $fileName, + ]) + ->event('file_deletion') + ->log('File deleted'); + + return true; + } + } diff --git a/app/Services/NewsService.php b/app/Services/NewsService.php new file mode 100644 index 0000000..1bd4a75 --- /dev/null +++ b/app/Services/NewsService.php @@ -0,0 +1,194 @@ +request = $request; + $user_id = \Auth::user()->user_id; + + $news = DB::transaction(function () use ($user_id) { + + // STEP 1 : Slug + $slug = EntryHelpers::uniqueSlug( $this->request->input('title'), News::class ); + + $fields = [ + 'title' => $this->request->input('title'), + 'slug' => $slug, + 'category_id' => $this->request->input('category'), + 'description' => $this->request->input('description'), + 'state' => $this->request->input('submit-state'), + 'entry_id' => $this->request->input('entry_id'), + 'relevant_link' => $this->request->input('release_site'), + 'youtube_link' => $this->request->input('youtube_link'), + 'user_id' => $user_id, + ]; + + $news = News::create( $fields ); + + // STEP 3 : Prepare Gallery images. + $this->Step3a_PrepareGalleryImages( $news ); + + return $news; + + }); + + $this->Step3b_SaveGalleryImages( $news ); + + $this->Step4_CreateCommentsThread( $news ); + + return $news; + } + + private function Step3a_PrepareGalleryImages(News $news): void + { + foreach( $this->request->input('gallery', [] ) ?? [] as $i => $imagePath ){ + $news->gallery()->create([ + 'image' => $imagePath, + 'order' => $i + ]); + } + } + + private function Step3b_SaveGalleryImages(News $news): void + { + foreach ( $news->gallery ?? [] as $galleryItem ) { + $newPath = 'news/gallery-images/' . $news->id . '/' . basename($galleryItem->image); + + if( !Storage::disk('public')->move($galleryItem->image, $newPath) ) + continue; + + $galleryItem->update(['image' => $newPath]); + } + } + + private function Step4_CreateCommentsThread( News $news ): void + { + if( $news->state !== 'published' ) + return; + + if( !$news->comments_thread_id ) + CreateXenForoCommentsThread::dispatch( $news ); + } + + public function editNews(Request $request, News $news): News + { + $this->request = $request; + $this->news = $news; + + if( \Auth::user()->can('moderate', $news) ){ + $user_id = $this->request->input('owner_user_id'); + } else { + $user_id = \Auth::user()->user_id; + } + + $galleryPaths = []; + + $news = DB::transaction(function () use ($user_id, &$galleryPaths) { + + // STEP 1 : Refresh slug. + if( $this->request->input('title') !== $this->news->title ){ + $this->news->slug = EntryHelpers::uniqueSlug( $this->request->input('title'), News::class, $this->news->id ); + } + + $fields = [ + 'title' => $this->request->input('title'), + 'slug' => $this->news->slug, + 'category_id' => $this->request->input('category'), + 'description' => $this->request->input('description'), + 'state' => $this->request->input('submit-state'), + 'entry_id' => $this->request->input('entry_id'), + 'relevant_link' => $this->request->input('release_site'), + 'youtube_link' => $this->request->input('youtube_link'), + 'user_id' => $user_id, + ]; + + if( \Auth::user()->can('moderate', $this->news) ){ + $fields['staff_comment'] = $this->request->input('staff_comment'); + $fields['comments_thread_id'] = $this->request->input('comments_thread_id'); + } + + $this->news->update( $fields ); + + $galleryPaths = $this->eStep3a_UpdateGalleryImages(); + + return $this->news; + + }); + + $this->eStep3b_UpdateGalleryImages( $galleryPaths ); + + $this->step4_CreateCommentsThread( $news ); + + return $news; + + } + + private function eStep3a_UpdateGalleryImages(): array + { + $requestGallery = $this->request->input('gallery', [] ) ?? []; + $existingGalleryPaths = $this->news->gallery->pluck('image')->toArray(); + + $needDeletion = array_diff( $existingGalleryPaths, $requestGallery ); + + if( !empty( $needDeletion ) ){ + $this->news->gallery()->whereIn('image', $needDeletion )->delete(); + } + + $needAddition = array_diff( $requestGallery, $existingGalleryPaths ); + $images = []; + foreach( $needAddition as $imagePath ){ + $images[] = $this->news->gallery()->create([ + 'image' => $imagePath, + ]); + } + + foreach( $requestGallery as $i => $imagePath ){ + $this->news->gallery()->where('image', $imagePath )->update(['order' => $i]); + } + + return [ 'addition' => $images, 'deletion' => $needDeletion ]; + } + + private function eStep3b_UpdateGalleryImages( array $pathsChanges ): void + { + foreach ( $pathsChanges['deletion'] as $deletePath ){ + if( Storage::disk('public')->exists($deletePath) ) + Storage::disk('public')->delete($deletePath); + } + + foreach ( $pathsChanges['addition'] as $galleryItem ){ + $newPath = 'news/gallery-images/' . $this->news->id . '/' . basename( $galleryItem->image ); + + if( !Storage::disk('public')->move( $galleryItem->image, $newPath ) ){ + continue; + } + + $galleryItem->update(['image' => $newPath]); + } + } + +} diff --git a/app/Services/SubmissionsService.php b/app/Services/SubmissionsService.php index b7df22b..15130cf 100644 --- a/app/Services/SubmissionsService.php +++ b/app/Services/SubmissionsService.php @@ -4,9 +4,11 @@ namespace App\Services; use App\Exceptions\SubmissionException; use App\Helpers\EntryHelpers; +use App\Helpers\PlayOnlineHelpers; use App\Helpers\XenForoHelpers; use App\Http\Requests\StoreEntryRequest; use App\Jobs\CreateXenForoCommentsThread; +use App\Jobs\DeleteFile; use App\Models\Author; use App\Models\Category; use App\Models\Entry; @@ -76,8 +78,12 @@ class SubmissionsService { 'error' => null, 'uuid' => $uuid, 'state' => $file->state, + 'can_be_online_patched' => EntryHelpers::enableOnlinePatcherBasedOnExtension($file['filename']), 'meta_online_patcher' => $file->online_patcher, 'meta_secondary_online_patcher' => $file->secondary_online_patcher, + 'meta_play_online' => $file->playOnlineSetting()->exists() ? true : false, + 'meta_play_online_core' => $file->playOnlineSetting()->exists() ? $file->playOnlineSetting->core : '', + 'meta_play_online_threads' => $file->playOnlineSetting()->exists() ? $file->playOnlineSetting->threads : false, ]; $file = Cache::get("uploaded_file_{$uuid}"); @@ -92,8 +98,12 @@ class SubmissionsService { 'error' => null, 'uuid' => $uuid, 'state' => $file['state'], + 'can_be_online_patched' => EntryHelpers::enableOnlinePatcherBasedOnExtension($file['filename']), 'meta_online_patcher' => false, 'meta_secondary_online_patcher' => false, + 'meta_play_online' => false, + 'meta_play_online_core' => null, + 'meta_play_online_threads' => false ]; return null; @@ -298,15 +308,20 @@ class SubmissionsService { foreach ( $uuidData ?? [] as $uuid ) { $fileData = Cache::pull("uploaded_file_{$uuid}"); if( !$fileData ) - throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry. If it's an edition, delete all your new files and retry." ); + throw new SubmissionException( "File {$uuid} has expired. Please delete all your files and retry. If it's an edition, delete all the new files and retry." ); - $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); - if( !$onlinePatcher ) - $onlinePatcher = EntryHelpers::enableOnlinePatcherBasedOnExtension( $fileData['filename'] ); + if( section_must_be( [ 'romhacks', 'translations' ], $entry->type ) ) { + $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); + if (!$onlinePatcher) + $onlinePatcher = EntryHelpers::enableOnlinePatcherBasedOnExtension($fileData['filename']); - $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + } else { + $onlinePatcher = false; + $secondaryOnlinePatcher = false; + } - EntryFile::create([ + $file = EntryFile::create([ 'entry_id' => $entry->id, 'file_uuid' => $uuid, 'filename' => $fileData['filename'], @@ -319,6 +334,26 @@ class SubmissionsService { 'secondary_online_patcher' => $secondaryOnlinePatcher, ]); + if( section_must_be( ['romhacks', 'translations', 'homebrew'], $entry->type ) ) { + $playOnline = (bool)($metadataArray[$uuid]['play_online'] ?? false); + $playOnlineCore = $metadataArray[$uuid]['play_online_core'] ?? null; + $playOnlineThreads = (bool)($metadataArray[$uuid]['play_online_threads'] ?? false); + + if (!$playOnline && $entry->getRealPlatform()?->play_online_core !== null) { + $playOnline = true; + $playOnlineCore = $entry->getRealPlatform()?->play_online_core; + } + + if ($playOnline) { + $file->playOnlineSetting()->updateOrCreate( + ['file_id' => $file->id], + [ + 'core' => $playOnlineCore, + 'threads' => $playOnlineThreads, + ] + ); + } + } } } @@ -452,9 +487,10 @@ class SubmissionsService { private function Step12a_PrepareGalleryImages( Entry $entry ): void { - foreach ( $this->request->input('gallery', [] ) ?? [] as $imagePath ) { + foreach ( $this->request->input('gallery', [] ) ?? [] as $i => $imagePath ) { $entry->gallery()->create([ 'image' => $imagePath, + 'order' => $i ]); } } @@ -556,6 +592,10 @@ class SubmissionsService { if( \Auth::user()->can('moderate', $this->entry) ){ $fields['staff_comment'] = $this->request->input('staff_comment'); $fields['featured'] = $this->request->input('featured') ?? false; + if( $fields['featured'] == true && $this->entry->featured_at === null ) + $fields['featured_at'] = now(); + if( $fields['featured'] == false ) + $fields['featured_at'] = null; $fields['comments_thread_id'] = $this->request->input('comments_thread_id'); } @@ -666,6 +706,10 @@ class SubmissionsService { $needDeletion = array_diff( $existingUuids, $requestUuids ); if( !empty( $needDeletion ) ){ + $userId = \Auth::user()->user_id; + EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->get()->each( function ( $f ) use ( $userId ) { + DeleteFile::dispatch( $f->filepath, $f->filename, $userId); + }); EntryFile::where('entry_id', $entryId)->whereIn('file_uuid', $needDeletion)->delete(); } @@ -680,15 +724,45 @@ class SubmissionsService { foreach( $stateMap as $uuid => $state ){ - $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); - $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + if( section_must_be( ['romhacks', 'translations'], $this->entry->type ) ) { + $onlinePatcher = (bool)($metadataArray[$uuid]['online_patcher'] ?? false); + $secondaryOnlinePatcher = (bool)($metadataArray[$uuid]['secondary_online_patcher'] ?? false); + } else { + $onlinePatcher = false; + $secondaryOnlinePatcher = false; + } - EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived') - ->update([ - 'state' => $state, - 'online_patcher' => $onlinePatcher, - 'secondary_online_patcher' => $secondaryOnlinePatcher, - ]); + $entryFile = EntryFile::where('file_uuid', $uuid)->where('entry_id', $entryId)->where('state', '!=', 'archived')->first(); + if( !$entryFile ) + continue; + + $entryFile->update([ + 'state' => $state, + 'online_patcher' => $onlinePatcher, + 'secondary_online_patcher' => $secondaryOnlinePatcher, + ]); + + if( section_must_be( ['romhacks', 'translations', 'homebrew'], $this->entry->type ) ) { + + $playOnline = (bool)($metadataArray[$uuid]['play_online'] ?? false); + $playOnlineCore = $metadataArray[$uuid]['play_online_core'] ?? null; + $playOnlineThreads = (bool)($metadataArray[$uuid]['play_online_threads'] ?? false); + + if ($playOnline) { + if ($playOnlineCore === null || !in_array($playOnlineCore, PlayOnlineHelpers::getCoreLists())) + $playOnlineCore = $this->entry->getRealPlatform()->play_online_core ? $this->entry->getRealPlatform()->play_online_core : 'nes'; + + $entryFile->playOnlineSetting()->updateOrCreate( + ['file_id' => $entryFile->id], + [ + 'core' => $playOnlineCore, + 'threads' => $playOnlineThreads, + ] + ); + } else { + $entryFile->playOnlineSetting()->delete(); + } + } } } @@ -860,6 +934,10 @@ class SubmissionsService { ]); } + foreach ( $requestGallery as $i => $imagePath ){ + $this->entry->gallery()->where('image', $imagePath )->update(['order' => $i]); + } + return [ 'addition' => $images, 'deletion' => $needDeletion ]; } diff --git a/app/Services/XenforoApiService.php b/app/Services/XenforoApiService.php index f1ce2ef..602c6e3 100644 --- a/app/Services/XenforoApiService.php +++ b/app/Services/XenforoApiService.php @@ -92,7 +92,7 @@ class XenforoApiService { return $response['success'] ?? false; } - public function createCommentsThread( Entry $entry ): bool + public function createCommentsThread( Entry|News $entry ): bool { if( !$entry->comments_thread_id || $entry->comments_thread_id <= 0 ){ $data = [ @@ -134,6 +134,14 @@ class XenforoApiService { return $response['success'] ?? false; } + public function featuredRequest( Entry $entry ): bool + { + $response = $this->post("romhackplaza_entry/featured", data: [ + 'entry_id' => $entry->id, 'user_id' => $entry->user_id, 'entry_title' => $entry->complete_title ?? $entry->title, + ]); + return $response['success'] ?? false; + } + public function deleteThreadWithEntry(int $threadId): bool { return (bool) $this->delete( "threads/{$threadId}", data: ['reason' => "Deletion with entry." ] ); diff --git a/app/Services/XenforoService.php b/app/Services/XenforoService.php index fc8d0d1..47ca18b 100644 --- a/app/Services/XenforoService.php +++ b/app/Services/XenforoService.php @@ -195,9 +195,9 @@ class XenforoService { } - private function hashCSRFToken( string $token ): string + private function hashCSRFToken( string $token, int $timestamp ): string { - return hash_hmac('md5', $token . time(), config('app.xf_salt') ); + return hash_hmac('md5', $token . $timestamp, config('app.xf_salt') ); } public function getCSRFToken(): string { @@ -207,6 +207,28 @@ class XenforoService { Cookie::queue('xf_csrf', $token, 0, '/', config('session.domain'), 0, false, false ); } - return time() . ',' . $this->hashCSRFToken($token); + $timestamp = time(); + return $timestamp . ',' . $this->hashCSRFToken($token, $timestamp); + } + public function verifyCSRFToken( string $requestToken ): bool + { + $token = Cookie::get('xf_csrf'); + if( !$token ){ + return false; + } + + try { + [$timestamp, $hash] = explode(',', $requestToken); + } catch (\Throwable $th) { + return false; + } + + $timestamp = intval($timestamp); + $currentTimestamp = time(); + + if( abs( $currentTimestamp - $timestamp ) > 3600 ) + return false; + + return $hash === $this->hashCSRFToken($token, $timestamp); } } diff --git a/app/Support/XenForoCauserResolver.php b/app/Support/XenForoCauserResolver.php new file mode 100644 index 0000000..0e9e749 --- /dev/null +++ b/app/Support/XenForoCauserResolver.php @@ -0,0 +1,22 @@ +getAuthIdentifier() ){ + return LogXfUser::find($user->getAuthIdentifier()); + } + + return null; + } +} diff --git a/app/Traits/HasGallery.php b/app/Traits/HasGallery.php index c775d50..57f0ed0 100644 --- a/app/Traits/HasGallery.php +++ b/app/Traits/HasGallery.php @@ -9,6 +9,6 @@ trait HasGallery { public function gallery(): MorphMany { - return $this->morphMany(Gallery::class, 'galleryable')->orderBy('id'); + return $this->morphMany(Gallery::class, 'galleryable')->orderBy('order')->orderBy('id'); } } diff --git a/app/View/Components/CategorySelector.php b/app/View/Components/CategorySelector.php index 44c7fdb..9e0f412 100644 --- a/app/View/Components/CategorySelector.php +++ b/app/View/Components/CategorySelector.php @@ -19,7 +19,8 @@ class CategorySelector extends Component public function __construct( public string $section, public array $selected = [], - public bool $required = true + public bool $required = true, + public bool $news = false ) { $this->categories = Category::query() @@ -36,6 +37,6 @@ class CategorySelector extends Component */ public function render(): View|Closure|string { - return view('components.category-selector'); + return $this->news === true ? view('components.news-category-selector' ) : view('components.category-selector'); } } diff --git a/app/View/Components/NewsCard.php b/app/View/Components/NewsCard.php new file mode 100644 index 0000000..fdb81dc --- /dev/null +++ b/app/View/Components/NewsCard.php @@ -0,0 +1,31 @@ +entry === null ) - $this->entry = 'App\Models\Entry'; + if( $this->entry === null ){ + if( $news ) + $this->entry = 'App\Models\News'; + else + $this->entry = 'App\Models\Entry'; + } } public function availableStates(): array diff --git a/app/helpers.php b/app/helpers.php index fb7eaff..a8fd4d0 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -25,6 +25,15 @@ if( !function_exists( 'section_must_not_be' ) ){ } +if( !function_exists('userTheme' ) ){ + function userTheme(): string { + if( !\Auth::guest() ){ + return \Auth::user()->style_variation ?? 'default'; + } + return \Illuminate\Support\Facades\Cookie::get('xf_style_variation', 'default'); + } +} + if( !function_exists( 'databaseRoute' ) ){ function databaseRoute( array $params = [] ): string diff --git a/bootstrap/app.php b/bootstrap/app.php index 3928513..641e643 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf','theme','entries_per_page']); + $middleware->encryptCookies(except: ['xf_session','xf_user','xf_csrf','xf_style_variation','activity_filters']); $middleware->alias([ 'xf.auth' => \App\Http\Middleware\CheckXenForoPermissions::class, ]); diff --git a/composer.json b/composer.json index cf73412..9df3a22 100644 --- a/composer.json +++ b/composer.json @@ -7,14 +7,16 @@ "license": "MIT", "require": { "php": "^8.4", + "ext-pdo": "*", + "ext-simplexml": "*", + "ext-xmlreader": "*", "diglactic/laravel-breadcrumbs": "^10.1", "filament/filament": "^5.6", "laravel/framework": "^13.7", "laravel/tinker": "^3.0", "livewire/livewire": "^4.3", "predis/predis": "^3.4", - "ext-xmlreader": "*", - "ext-simplexml": "*" + "spatie/laravel-activitylog": "^5.0" }, "require-dev": { "barryvdh/laravel-debugbar": "^4.2", diff --git a/composer.lock b/composer.lock index a713774..de6c591 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "79ce85f4c121f30d964f9322cb7120a4", + "content-hash": "d561062afd8c291a93e8fd1e00f4e901", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -5273,6 +5273,99 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-activitylog", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd", + "shasum": "" + }, + "require": { + "illuminate/config": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-25T10:04:54+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.0", @@ -11241,8 +11334,9 @@ "prefer-lowest": false, "platform": { "php": "^8.4", - "ext-xmlreader": "*", - "ext-simplexml": "*" + "ext-pdo": "*", + "ext-simplexml": "*", + "ext-xmlreader": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/database.php b/config/database.php index c3c8ad2..c806590 100644 --- a/config/database.php +++ b/config/database.php @@ -129,6 +129,13 @@ return [ 'database' => storage_path('hashes.sqlite'), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + ], + + 'discord' => [ + 'driver' => 'sqlite', + 'database' => env('DISCORD_DB_PATH'), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ] ], diff --git a/config/menu.php b/config/menu.php index 18f3fc1..89cabe7 100644 --- a/config/menu.php +++ b/config/menu.php @@ -18,16 +18,21 @@ return [ 'icon' => 'database', 'route' => 'entries.index' ], + [ + 'name' => 'News', + 'icon' => 'newspaper', + 'route' => 'news.index' + ], [ 'name' => "Submissions queue", 'icon' => 'gavel', 'route' => 'queue.index' ], [ - 'name' => "My Drafts", + 'name' => "My drafts", 'icon' => 'scissors', 'route' => 'entries.drafts', - 'auth' => true + 'requires_auth' => true ] ] ], 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 0c6a41f..4cac30a 100644 --- a/database/migrations/2026_05_10_072747_create_entries_table.php +++ b/database/migrations/2026_05_10_072747_create_entries_table.php @@ -23,7 +23,6 @@ return new class extends Migration $table->longText( 'description' ); $table->string( 'main_image' )->nullable(); - // TODO: Replace it by state. $table->enum( 'state', [ 'draft', 'pending', 'published', 'locked', 'hidden' ] )->default('draft'); $table->boolean('featured')->default(false); diff --git a/database/migrations/2026_06_10_090320_create_news_table.php b/database/migrations/2026_06_10_090320_create_news_table.php index dab2a32..28ea81a 100644 --- a/database/migrations/2026_06_10_090320_create_news_table.php +++ b/database/migrations/2026_06_10_090320_create_news_table.php @@ -12,7 +12,22 @@ return new class extends Migration public function up(): void { Schema::create('news', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); + $table->longText('description'); + $table->enum('state', [ 'draft', 'pending', 'published', 'locked', 'rejected', 'hidden' ] )->default('draft'); + + $table->foreignId('entry_id')->nullable()->constrained('entries')->nullOnDelete(); + $table->string('relevant_link', 500 )->nullable(); + $table->string('youtube_link', 500 )->nullable(); + + $table->unsignedBigInteger( 'user_id' ); // xf_user_id + $table->unsignedBigInteger( 'comments_thread_id' )->nullable(); // xf_thread + + $table->softDeletes(); $table->timestamps(); }); } diff --git a/database/migrations/2026_06_10_091105_add_fields_for_queue_to_news.php b/database/migrations/2026_06_10_091105_add_fields_for_queue_to_news.php new file mode 100644 index 0000000..0e3f9b1 --- /dev/null +++ b/database/migrations/2026_06_10_091105_add_fields_for_queue_to_news.php @@ -0,0 +1,29 @@ +text("staff_comment")->nullable()->after("state"); + $table->timestamp("rejected_at")->nullable()->after("staff_comment"); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('news', function (Blueprint $table) { + $table->dropColumn(['staff_comment', 'rejected_at']); + }); + } +}; diff --git a/database/migrations/2026_06_11_155053_add_order_to_galleries_table.php b/database/migrations/2026_06_11_155053_add_order_to_galleries_table.php new file mode 100644 index 0000000..3a770f6 --- /dev/null +++ b/database/migrations/2026_06_11_155053_add_order_to_galleries_table.php @@ -0,0 +1,28 @@ +unsignedSmallInteger('order')->default(0)->after('image'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('galleries', function (Blueprint $table) { + $table->dropColumn('order'); + }); + } +}; diff --git a/database/migrations/2026_06_13_210606_add_featured_at_field_to_entries.php b/database/migrations/2026_06_13_210606_add_featured_at_field_to_entries.php new file mode 100644 index 0000000..53af415 --- /dev/null +++ b/database/migrations/2026_06_13_210606_add_featured_at_field_to_entries.php @@ -0,0 +1,28 @@ +dateTime('featured_at')->nullable()->after('featured'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entries', function (Blueprint $table) { + $table->dropColumn('featured_at'); + }); + } +}; diff --git a/database/migrations/2026_06_14_163648_create_play_online_settings_table.php b/database/migrations/2026_06_14_163648_create_play_online_settings_table.php new file mode 100644 index 0000000..d0a4e6d --- /dev/null +++ b/database/migrations/2026_06_14_163648_create_play_online_settings_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('file_id')->primary(); + $table->foreign('file_id') + ->references('id') + ->on('entry_files') + ->onDelete('cascade'); + + $table->string('core', 30); + $table->boolean('threads')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('play_online_settings'); + } +}; diff --git a/database/migrations/2026_06_14_174906_add_default_core_play_online_for_platforms.php b/database/migrations/2026_06_14_174906_add_default_core_play_online_for_platforms.php new file mode 100644 index 0000000..8ae5710 --- /dev/null +++ b/database/migrations/2026_06_14_174906_add_default_core_play_online_for_platforms.php @@ -0,0 +1,28 @@ +string('play_online_core')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('platforms', function (Blueprint $table) { + $table->dropColumn('play_online_core'); + }); + } +}; diff --git a/database/migrations/2026_06_16_100941_create_activity_log_table.php b/database/migrations/2026_06_16_100941_create_activity_log_table.php new file mode 100644 index 0000000..5c17c24 --- /dev/null +++ b/database/migrations/2026_06_16_100941_create_activity_log_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2026_06_16_122812_add_download_field_to_entry_files.php b/database/migrations/2026_06_16_122812_add_download_field_to_entry_files.php new file mode 100644 index 0000000..9c241e1 --- /dev/null +++ b/database/migrations/2026_06_16_122812_add_download_field_to_entry_files.php @@ -0,0 +1,28 @@ +unsignedBigInteger('download_count')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entry_files', function (Blueprint $table) { + $table->dropColumn('download_count'); + }); + } +}; diff --git a/database/migrations/discord/2026_06_12_130031_create_actions_table.php b/database/migrations/discord/2026_06_12_130031_create_actions_table.php new file mode 100644 index 0000000..7715239 --- /dev/null +++ b/database/migrations/discord/2026_06_12_130031_create_actions_table.php @@ -0,0 +1,29 @@ +create('actions', function (Blueprint $table) { + $table->id(); + $table->string('action'); + $table->json('data'); + $table->boolean('done')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::connection('discord')->dropIfExists('actions'); + } +}; diff --git a/database/migrations/discord/2026_06_12_131701_add_fields_to_actions.php b/database/migrations/discord/2026_06_12_131701_add_fields_to_actions.php new file mode 100644 index 0000000..d2da0dd --- /dev/null +++ b/database/migrations/discord/2026_06_12_131701_add_fields_to_actions.php @@ -0,0 +1,28 @@ +table('actions', function (Blueprint $table) { + $table->string('errors')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::connection('discord')->table('actions', function (Blueprint $table) { + $table->dropColumn('errors'); + }); + } +}; diff --git a/extra.less b/extra.less index 62e665d..2611c34 100644 --- a/extra.less +++ b/extra.less @@ -63,7 +63,7 @@ ul { --error: #e57373; --info: #1976d2; --success: #81c784; - --success2: #fdeb0f; + --success2: #388e3c; /* Typo */ --typography: 'Segoe UI', 'San Francisco', 'Helvetica Neue', sans-serif; @@ -309,6 +309,11 @@ ul { color: var(--text); border-color: var(--success2); } +.\$badge.yellow, .\$badge.utilities { + background-color: #fdeb0f; + color: #000; + border-color: #fdeb0f; +} .\$topbar-badge { position: absolute; @@ -791,6 +796,169 @@ ul { +/* File: resources/css/components/drafts.css */ +.\$drafts-count { + font-size: 0.85rem; + color: var(--text2); + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +.\$drafts-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + padding: 80px 20px; + background-color: var(--bg2); + border: 1px dashed var(--border); + text-align: center; + color: var(--text2); + + h3 { + font-size: 1.1rem; + color: var(--text); + margin: 0; + } + + p { + font-size: 0.9rem; + margin: 0; + } + +} + +.\$drafts-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.\$drafts-item { + display: flex; + gap: 20px; + background-color: var(--bg2); + border: 1px solid var(--border); + border-left: 3px solid var(--rhpz-orange); + padding: 20px; + transition: border-color 0.15s; + + &:hover { + border-color: var(--rhpz-orange); + } +} + +.\$drafts-cover { + width: 80px; + height: 80px; + flex-shrink: 0; + background-color: var(--bg); + border: 1px solid var(--border); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +} + +.\$drafts-cover-placeholder { + color: var(--border); +} + +.\$drafts-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.\$drafts-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; +} + +.\$drafts-title { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.\$drafts-meta { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.\$drafts-dates { + display: flex; + flex-direction: column; + gap: 4px; + text-align: right; + font-size: 0.78rem; + color: var(--text2); + flex-shrink: 0; + + span { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + } +} + +.\$drafts-progress { + display: flex; + align-items: center; + gap: 10px; +} + +.\$drafts-progress-bar { + flex: 1; + height: 4px; + background-color: var(--bg4); + overflow: hidden; +} + +.\$drafts-progress-fill { + height: 100%; + background-color: var(--rhpz-orange); + transition: width 0.3s ease; + + .\$complete { + background-color: var(--success); + } +} +.\$drafts-progress-label { + font-size: 0.75rem; + color: var(--text2); + white-space: nowrap; +} +.\$drafts-actions { + display: flex; + flex-direction: column; + gap: 8px; + justify-content: center; + flex-shrink: 0; + .\$btn { + white-space: nowrap; + } +} + + /* File: resources/css/components/easymde.css */ .\$EasyMDEContainer { display: flex; @@ -1264,6 +1432,50 @@ ul { flex-direction: column; gap: 5px; } +.\$gallery-item { + position: relative; + cursor: grab; + transition: opacity 0.2s, transform 0.15s; + user-select: none; +} + +.gallery-item:active { cursor: grabbing; } + +.\$gallery-item--dragging { + opacity: 0.4; + transform: scale(0.97); +} + +.\$gallery-drag-handle { + position: absolute; + top: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.6); + color: #fff; + padding: 3px 4px; + display: flex; + align-items: center; + cursor: grab; + opacity: 0; + transition: opacity 0.15s; +} + +.gallery-item:hover .gallery-drag-handle { opacity: 1; } + +.\$gallery-order-badge { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.7); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 6px; + min-width: 20px; + text-align: center; +} .\$authors-list { display: grid; grid-template-columns: repeat(4, 1fr); @@ -1444,6 +1656,47 @@ ul { .author-search-item:hover { background-color: var(--bg3); } +.\$game-selector-mode { + display: flex; + gap: 0; + margin-bottom: 15px; + border: 1px solid var(--border); +} + +.\$game-selector-mode-btn { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + background: none; + border: none; + border-right: 1px solid var(--border); + color: var(--text2); + font-size: 0.85rem; + cursor: pointer; + font-family: var(--font-family); + transition: background-color 0.1s, color 0.1s; +} + +.\$game-selector-mode-btn:last-child { + border-right: none; +} + +.\$game-selector-mode-btn:hover { + background-color: var(--bg3); + color: var(--text); +} + +.\$game-selector-mode-btn.active { + background-color: var(--bg3); + color: var(--rhpz-orange); + border-bottom: 2px solid var(--rhpz-orange); +} + +.\$game-selector-platform-only { + grid-column: span 1; +} + /* File: resources/css/components/grid.css */ .\$grid-c2 { @@ -1658,11 +1911,563 @@ ul { } -.\$modal-content { +.\$modal-content, .\$modal-body { padding: 20px; } +/* File: resources/css/components/modcp.css */ +.\$modcp-wrapper { + display: flex; + gap: 0; + align-items: flex-start; + min-height: calc(100vh - 60px); +} + +.\$modcp-sidebar { + width: 220px; + flex-shrink: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + position: sticky; + top: 0; + align-self: flex-start; + margin-right: 15px; +} + +.\$modcp-sidebar-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + font-weight: 600; + font-size: 0.88rem; + color: var(--text); + border-bottom: 1px solid var(--border); + background-color: var(--bg3); + text-transform: uppercase; + letter-spacing: 0.5px; +} + + +.modcp-nav { padding: 8px 0; } + +.modcp-nav-group { margin-bottom: 4px; } + +.\$modcp-nav-label { + display: block; + padding: 8px 16px 4px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text2); +} + +.\$modcp-nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 16px; + font-size: 0.88rem; + color: var(--text); + text-decoration: none; + border-left: 3px solid transparent; + transition: background-color 0.1s, border-color 0.1s; + + &:hover { + background-color: var(--bg3); + text-decoration: none; + } + .\$active { + background-color: var(--bg3); + border-left-color: var(--rhpz-orange); + color: var(--text); + font-weight: 600; + } +} + +.\$modcp-nav-badge { + margin-left: auto; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.65rem; + font-weight: 700; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; +} + +.\$modcp-content { + flex: 1; + min-width: 0; + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 25px; +} + +.\$modcp-page-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.3rem; + font-weight: 600; + color: var(--text); + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); +} + +.\$modcp-count { + margin-left: auto; + font-size: 0.85rem; + font-weight: normal; + color: var(--text2); +} + +.\$modcp-section-title { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 12px; +} + +.\$modcp-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + margin-bottom: 25px; +} + +.\$modcp-stat-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background-color: var(--bg3); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + text-decoration: none; + transition: border-color 0.15s, background-color 0.15s; + color: var(--text); + + &:hover { + background-color: var(--bg4); + text-decoration: none; + } +} + +.modcp-stat-card--orange { border-left-color: var(--rhpz-orange); } +.modcp-stat-card--danger { border-left-color: var(--error); } +.modcp-stat-card--muted { cursor: default; } + +.modcp-stat-icon { color: var(--text2); } +.modcp-stat-card--orange .modcp-stat-icon { color: var(--rhpz-orange); } +.modcp-stat-card--danger .modcp-stat-icon { color: var(--error); } + +.modcp-stat-info { display: flex; flex-direction: column; } +.modcp-stat-value { font-size: 1.4rem; font-weight: 700; color: var(--text); line-height: 1; } +.modcp-stat-label { font-size: 0.75rem; color: var(--text2); margin-top: 3px; } + +.\$modcp-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 25px; +} + +.\$modcp-quick-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + background-color: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + font-size: 0.85rem; + text-decoration: none; + transition: background-color 0.1s, border-color 0.1s; + + &:hover { + background-color: var(--bg4); + border-color: var(--rhpz-orange); + text-decoration: none; + } +} + +.modcp-list { display: flex; flex-direction: column; } + +.\$modcp-list-item { + display: flex; + align-items: center; + gap: 15px; + padding: 12px 15px; + border-bottom: 1px solid var(--border); + transition: background-color 0.1s; +} + +.modcp-list-item:last-child { border-bottom: none; } +.modcp-list-item:hover { background-color: var(--bg3); } +.modcp-list-item--deleted { opacity: 0.8; } + +.\$modcp-list-item-cover { + width: 44px; + height: 44px; + flex-shrink: 0; + background-color: var(--bg); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + color: var(--border); +} + +.\$modcp-list-item-cover img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.\$modcp-list-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.\$modcp-list-item-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.\$modcp-list-item-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: var(--text2); +} + +.\$modcp-list-item-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.\$modcp-list-item-edit { + display: flex; + gap: 6px; + flex: 1; + align-items: center; +} + +.\$modcp-list-item-edit .\$form-input { + flex: 1; + padding: 5px 10px; + font-size: 0.88rem; +} + +.\$modcp-list-see-all { + display: block; + text-align: center; + padding: 10px; + font-size: 0.85rem; + color: var(--rhpz-orange); + border-top: 1px solid var(--border); + text-decoration: none; +} + +.\$modcp-add-form { + background-color: var(--bg3); + border: 1px solid var(--border); + padding: 15px; + margin-bottom: 20px; +} + +.\$modcp-add-form-inner { + display: flex; + gap: 8px; + align-items: center; +} + +.modcp-add-form-inner .form-input { flex: 1; } + +.\$modcp-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 50px 20px; + color: var(--text2); + text-align: center; +} + +.\$mod-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 15px; + margin-bottom: 20px; + font-size: 0.88rem; + border: 1px solid; +} + +.\$mod-alert--success { + background-color: rgba(129, 199, 132, 0.08); + border-color: rgba(129, 199, 132, 0.3); + color: var(--success); +} + +.\$modcp-list-item-edit--game { + flex-wrap: wrap; + gap: 6px; +} + +.modcp-list-item-edit--game .form-input { min-width: 180px; flex: 2; } +.modcp-list-item-edit--game .form-select { flex: 1; min-width: 120px; } + +.\$log-filters { + margin-bottom: 16px; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.\$log-filters-main { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + flex-wrap: wrap; +} + +.\$log-search-wrap { + flex: 1; + min-width: 200px; + position: relative; + display: flex; + align-items: center; +} + +.\$log-search-wrap i { + position: absolute; + left: 10px; + color: var(--text2); + pointer-events: none; +} + +.log-search-wrap .form-input { padding-left: 30px; } + +.log-select { min-width: 130px; } + +.\$log-filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--rhpz-orange); + flex-shrink: 0; +} + +.\$log-filters-extra { + border-top: 1px solid var(--border); + padding: 12px 14px; +} + +.\$log-filters-extra-inner { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.\$log-filter-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.\$log-filter-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text2); +} + +.log-transition-enter { transition: all .15s ease; } +.log-transition-leave { transition: all .1s ease; } + +.\$log-results-bar { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.78rem; + color: var(--text2); + margin-bottom: 10px; + padding: 0 2px; +} + +.log-loading { opacity: 0.5; } + +.log-item { align-items: flex-start; padding: 11px 14px; } +.log-item--open { background-color: var(--bg3); } + +.\$log-event-dot { + width: 26px; + height: 26px; + flex-shrink: 0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + border: 1px solid var(--border); + background-color: var(--bg3); + color: var(--text2); +} + +.\$log-event-dot--created { + background-color: rgba(129,199,132,.1); + border-color: rgba(129,199,132,.35); + color: var(--success); +} + +.\$log-event-dot--updated { + background-color: rgba(255,115,0,.1); + border-color: rgba(255,115,0,.35); + color: var(--rhpz-orange); +} + +.\$log-event-dot--deleted { + background-color: rgba(229,115,115,.1); + border-color: rgba(229,115,115,.35); + color: var(--error); +} + +.\$log-channel-badge { + display: inline-flex; + align-items: center; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + background-color: rgba(255,255,255,.05); + border: 1px solid var(--border); + color: var(--text2); +} + +.log-id { color: var(--text2); font-size: 0.78rem; } +.log-sep { color: var(--border); } + +.\$log-item-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; +} + +.\$log-timestamp { + font-size: 0.75rem; + color: var(--text2); + white-space: nowrap; +} + +.log-expand-btn { padding: 4px 7px; } + +.\$log-properties { + background-color: var(--bg); + border-bottom: 1px solid var(--border); + padding: 14px 14px 14px 54px; +} + +.\$log-diff-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 8px; +} + +.\$log-diff { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.\$log-diff th { + text-align: left; + padding: 5px 10px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text2); + border-bottom: 1px solid var(--border); +} + +.\$log-diff td { + padding: 5px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; + max-width: 300px; + overflow-wrap: break-word; +} + +.log-diff tr:last-child td { border-bottom: none; } + +.\$log-diff-key { + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + width: 160px; + white-space: nowrap; +} + +.log-diff-old-head { color: var(--error) !important; } +.log-diff-new-head { color: var(--success) !important; } + +.\$log-diff-old { + color: var(--error); + background-color: rgba(229,115,115,.05); +} + +.\$log-diff-new { + color: var(--success); + background-color: rgba(129,199,132,.05); +} + +.\$log-raw { + font-family: monospace; + font-size: 0.78rem; + color: var(--text2); + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 12px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.\$log-pagination { + padding: 14px 0 4px; + border-top: 1px solid var(--border); +} + + /* File: resources/css/components/notifications.css */ .\$notifications, .\$conversations { position: absolute; @@ -2154,6 +2959,580 @@ ul { } +/* File: resources/css/components/tools.css */ +.\$patcher-container { + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 25px; + margin-bottom: 20px; +} + +.\$patcher-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 768px) { + .\$patcher-grid { + grid-template-columns: 1fr; + } +} + +.\$patcher-dropzone { + border: 2px dashed var(--border); + background-color: var(--bg3); + padding: 55px 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +} + +.\$patcher-dropzone:hover, .\$patcher-dropzone.dragover { + border-color: var(--rhpz-orange); + background-color: var(--bg4); +} + +.\$patcher-dropzone.has-file { + border-color: var(--success); + background-color: rgba(129, 199, 132, 0.02); +} + +.\$patcher-status-box { + margin-top: 20px; + padding: 15px; + border: 1px solid var(--border); + background-color: var(--bg3); + font-size: 0.95rem; + line-height: 1.4; +} + +.\$btn:disabled { + opacity: 0.4; + cursor: not-allowed; + background-color: var(--bg3); + border-color: var(--border); + color: var(--text2); +} + +.\$embed-patch-box { + border: 1px solid var(--border); + background-color: var(--bg3); + padding: 25px; + height: 85%; + display: flex; + flex-direction: column; + justify-content: center; + gap: 15px; +} +.\$embed-patch-box-icon { + display: flex; + align-items: center; + gap: 15px; +} +.\$embed-patch-box-icon-block { + width: 48px; + height: 48px; + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; +} + + +/* File: resources/css/layout/activity.css */ + +.\$activity-hero-excerpt { + font-size: 0.9rem; + color: rgba(255,255,255,0.75); + margin-bottom: 12px; + line-height: 1.5; + max-width: 600px; +} + +.\$activity-tl-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); + gap: 15px; + flex-wrap: wrap; +} + +.\$activity-tl-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.15rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.\$activity-tl-filters { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.\$activity-tl-filter { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + background: none; + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.8rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; +} + +.activity-tl-filter:hover { background-color: var(--bg3); color: var(--text); } +.\$activity-tl-filter.active { + background-color: var(--bg3); + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); +} + +.\$activity-day-sep { + display: flex; + align-items: center; + gap: 10px; + padding-left: 54px; + margin: 20px 0 12px; +} + +.\$activity-day-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.8px; + white-space: nowrap; +} + +.\$activity-day-line { + flex: 1; + height: 1px; + background-color: var(--border); +} + +.\$activity-tl-item { + display: flex; + gap: 0; + margin-bottom: 2px; +} + +.\$activity-tl-left { + width: 54px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 12px; +} + +.\$activity-tl-dot { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + background-color: var(--bg2); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + z-index: 1; +} + +.\$activity-tl-dot--entry { + background-color: rgba(255,115,0,0.1); + border-color: rgba(255,115,0,0.4); + color: var(--rhpz-orange); +} + +.\$activity-tl-dot--news { + background-color: rgba(129,199,132,0.1); + border-color: rgba(129,199,132,0.4); + color: var(--success); +} + +.\$activity-tl-dot--message, .\$activity-tl-dot--thread, .\$activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + border-color: rgba(25,118,210,0.4); + color: var(--info); +} + +.\$activity-tl-line { + width: 1px; + flex: 1; + background-color: var(--border); + margin-top: 4px; + min-height: 16px; +} + +.activity-tl-item:last-of-type .activity-tl-line { display: none; } + +.\$activity-tl-card { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 14px; + margin-bottom: 8px; + text-decoration: none; + transition: border-color 0.15s, background-color 0.1s; + min-width: 0; +} + +.\$activity-tl-card:hover { + border-color: var(--rhpz-orange); + background-color: var(--bg3); + text-decoration: none; +} + +.\$activity-tl-thumb { + width: 52px; + height: 52px; + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.\$activity-tl-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.\$activity-tl-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.\$activity-tl-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + padding: 2px 7px; + width: fit-content; +} + +.\$activity-tl-badge--entry { + background-color: rgba(255,115,0,0.1); + color: var(--rhpz-orange); + border: 1px solid rgba(255,115,0,0.25); +} + +.\$activity-tl-badge--news { + background-color: rgba(129,199,132,0.1); + color: var(--success); + border: 1px solid rgba(129,199,132,0.25); +} + +.\$activity-tl-badge--message, .\$activity-tl-badge--thread, .\$activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + color: var(--info); + border: 1px solid rgba(25,118,210,0.25); +} + +.\$activity-tl-card-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.\$activity-tl-card-description { + font-size: 0.8rem; + color: var(--text2); + white-space: nowrap; + text-overflow: ellipsis; + line-height: 1.3; +} + +.\$activity-tl-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.75rem; + color: var(--text2); + flex-wrap: wrap; +} + +.\$activity-tl-meta span { + display: flex; + align-items: center; + gap: 3px; +} + +.\$activity-tl-time { + font-size: 0.72rem; + color: var(--text2); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} + +.\$activity-tl-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px; + color: var(--text2); + text-align: center; + padding-left: 54px; +} + +@media (max-width: 600px) { + .activity-tl-header { flex-direction: column; align-items: flex-start; } + .activity-tl-thumb { display: none; } + .activity-day-sep { padding-left: 44px; } + .activity-tl-left { width: 44px; } +} + +.\$home-section { + margin-bottom: 30px; +} + +.\$home-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 14px; +} + +.\$home-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.\$home-section-more { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--text2); + border: 1px solid var(--border); + padding: 4px 10px; + text-decoration: none; + transition: color 0.1s, border-color 0.1s; +} + +.\$home-section-more:hover { + color: var(--rhpz-orange); + border-color: var(--rhpz-orange); +} + +.\$news-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; +} + +.\$news-strip-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.news-strip-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.\$news-strip-cover { + height: 110px; + background-color: var(--bg3); + background-size: cover; + background-position: center; + position: relative; + flex-shrink: 0; +} + +.\$news-strip-date { + position: absolute; + bottom: 6px; + left: 8px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255,255,255,0.8); + background: rgba(0,0,0,0.55); + padding: 2px 6px; + border: 1px solid rgba(255,255,255,0.07); +} + +.\$news-strip-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.\$news-strip-badge { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--success); + background: rgba(129,199,132,0.1); + border: 1px solid rgba(129,199,132,0.25); + padding: 1px 6px; + width: fit-content; +} + +.\$news-strip-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; +} + +.\$news-strip-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +.\$featured-entries-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.\$featured-entry-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.featured-entry-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.\$featured-entry-cover { + height: 80px; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + overflow: hidden; +} + +.\$featured-entry-cover img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.85; +} + +.\$featured-entry-star { + position: absolute; + top: 6px; + right: 6px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(255,115,0,0.9); + color: #111; + padding: 2px 6px; + border: 1px solid rgba(255,115,0,0.5); + display: flex; + align-items: center; + gap: 3px; +} + +.\$featured-entry-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.\$featured-entry-platform { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--rhpz-orange); + background: rgba(255,115,0,0.1); + border: 1px solid rgba(255,115,0,0.25); + padding: 1px 6px; + width: fit-content; +} + +.\$featured-entry-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.\$featured-entry-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +@media (max-width: 900px) { + .news-strip { grid-template-columns: repeat(3, 1fr); } + .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 600px) { + .news-strip { grid-template-columns: repeat(2, 1fr); } + .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } + .news-strip-cover { height: 80px; } +} + + /* File: resources/css/layout/content.css */ #main-wrapper { flex-grow: 1; @@ -2180,24 +3559,6 @@ ul { cursor: pointer; } - .\$search-bar { - display: flex; - align-items: center; - background-color: var(--bg); - border: 1px solid var(--border); - border-radius: 2px; - padding: 5px 10px; - width: 300px; - input { - background: none; - border: none; - color: var(--text); - outline: none; - margin-left: 8px; - width: 100%; - } - } - .\$topbar-actions { display: flex; gap: 8px; @@ -2210,6 +3571,24 @@ ul { } +.\$search-bar { + display: flex; + align-items: center; + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 2px; + padding: 5px 10px; + width: 300px; + input { + background: none; + border: none; + color: var(--text); + outline: none; + margin-left: 8px; + width: 100%; + } +} + #content { flex-grow: 1; padding: 30px; @@ -2236,8 +3615,8 @@ ul { gap: 30px; .\$entry-cover { - width: 200px; - height: 280px; + width: 220px; + height: 220px; background-color: var(--bg); border: 1px solid var(--border); display: flex; @@ -2251,7 +3630,8 @@ ul { img { width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; + padding: 8px; } } @@ -2616,12 +3996,18 @@ ul { z-index: 100; .\$menu-header { - padding: 20px; + padding: 10px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); + img { + width: 100%; + height: 100%; + object-fit: contain; + } + .\$menu-logo { width: 32px; height: 32px; @@ -2785,6 +4171,744 @@ ul { } } +#news-container { + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.\$news-header { + width: 100%; + height: 300px; + background-size: cover; + background-position: center; + display: flex; + align-items: flex-end; + padding: 40px 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.\$news-header-content { + position: relative; + z-index: 2; +} + +.\$news-header .\$news-title { + font-size: 2.5rem; + font-weight: 600; + color: var(--text); + margin-bottom: 12px; + text-shadow: 0 2px 4px rgba(0,0,0,0.6); +} + +.\$news-header .\$news-meta { + color: var(--text2); + display: flex; + gap: 20px; + font-size: 0.9rem; + align-items: center; +} + +.\$news-header .\$meta-item { + display: flex; + align-items: center; + gap: 6px; + background-color: rgba(0, 0, 0, 0.4); + padding: 4px 10px; + border-radius: 2px; + border: 1px solid rgba(255,255,255,0.05); +} + +.\$news-layout { + display: flex; + flex-direction: row; + gap: 30px; + padding: 30px; +} + +@media (max-width: 992px) { + .\$news-layout { + flex-direction: column; + } +} + +.\$news-main-content { + flex-grow: 1; + flex-basis: 0; + min-width: 0; +} + +.\$news-body-text { + line-height: 1.75; + color: var(--text); + font-size: 1.05rem; + margin-bottom: 15px; +} + +.\$news-body-text p { + margin-bottom: 20px; +} + +.\$news-sidebar { + width: 320px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 25px; +} + +@media (max-width: 992px) { + .\$news-sidebar { + width: 100%; + } +} + +.\$sidebar-block { + background-color: var(--bg); + border: 1px solid var(--border); + padding: 20px; + border-radius: 4px; +} + +.\$sidebar-title { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text); + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} + +.\$btn-sidebar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 15px; + font-size: 0.95rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + border-radius: 2px; + transition: background-color 0.2s ease, border-color 0.2s ease; + text-align: center; +} + +.\$btn-orange { + background-color: var(--rhpz-orange); + color: #fff; + border: 1px solid transparent; +} + +.\$btn-orange:hover { + background-color: var(--rhpz-orange-hover); +} + +.\$related-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.\$related-card-cover { + width: 100%; + height: 150px; + background-color: var(--bg2); + border: 1px solid var(--border); + overflow: hidden; + border-radius: 2px; +} + +.\$related-card-cover img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 5px; +} + +.\$related-card-info h4 { + font-size: 1.1rem; + color: var(--text); + margin-bottom: 10px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.\$news-sidebar .\$video-thumbnail-wrapper { + position: relative; + width: 100%; + aspect-ratio: 16/9; + background-color: #000; + border: 1px solid var(--border); + cursor: pointer; + overflow: hidden; + border-radius: 2px; +} + +.\$news-sidebar .\$video-thumbnail-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.7; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.\$news-sidebar .\$video-thumbnail-wrapper:hover img { + transform: scale(1.03); + opacity: 0.9; +} + +.\$news-sidebar .\$play-trigger { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50px; + height: 50px; + background-color: rgba(0, 0, 0, 0.75); + border: 2px solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + transition: background-color 0.2s, transform 0.2s ease-out; +} + +.\$news-sidebar .\$video-thumbnail-wrapper:hover .\$play-trigger { + background-color: var(--rhpz-orange); + transform: translate(-50%, -50%) scale(1.1); +} + +.\$news-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + flex-wrap: wrap; +} + +.\$news-header .\$news-actions .\$btn { + background-color: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(4px); + color: var(--text); + transition: background-color 0.15s, border-color 0.15s; +} + +.\$news-header .\$news-actions .\$btn:hover { + background-color: rgba(0, 0, 0, 0.7); + border-color: rgba(255, 255, 255, 0.3); +} + +.\$news-header .\$news-actions .\$btn.success { + background-color: rgba(56, 142, 60, 0.6); + border-color: rgba(129, 199, 132, 0.4); + color: #81c784; +} + +.\$news-header .\$news-actions .\$btn.danger { + background-color: rgba(183, 28, 28, 0.5); + border-color: rgba(229, 115, 115, 0.4); + color: #e57373; +} + +/* ── Hero ────────────────────────────────────────────────── */ +.\$news-hero { + display: block; + position: relative; + width: 100%; + height: 360px; + margin-bottom: 20px; + border: 1px solid var(--border); + overflow: hidden; + text-decoration: none; + transition: border-color 0.2s; +} + +.\$news-hero:hover { + border-color: var(--rhpz-orange); + text-decoration: none; +} + +.\$news-hero-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + background-color: var(--bg3); + transition: transform 0.4s ease; +} + +.\$news-hero:hover .\$news-hero-bg { + transform: scale(1.02); +} + +.\$news-hero-content { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 30px; + z-index: 2; +} + +.\$news-hero-badge { + display: inline-flex; + align-items: center; + gap: 5px; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 4px 10px; + margin-bottom: 12px; +} + +.\$news-hero-title { + font-size: 2rem; + font-weight: 600; + color: #fff; + margin-bottom: 12px; + text-shadow: 0 2px 8px rgba(0,0,0,0.5); + line-height: 1.2; +} + +.\$news-hero-meta { + display: flex; + align-items: center; + gap: 15px; + font-size: 0.85rem; + color: rgba(255,255,255,0.75); +} + +.\$news-hero-meta span { + display: flex; + align-items: center; + gap: 5px; + background-color: rgba(0,0,0,0.4); + padding: 3px 10px; + border: 1px solid rgba(255,255,255,0.08); +} + +.\$news-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.\$news-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s, transform 0.15s; +} + +.\$news-card:hover { + border-color: var(--rhpz-orange); + transform: translateY(-2px); + text-decoration: none; +} + +.\$news-card-cover { + height: 160px; + background-size: cover; + background-position: center; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + transition: transform 0.3s ease; +} + +.\$news-card:hover .\$news-card-cover { + transform: scale(1.03); +} + +.\$news-card-state-badge { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 4px; + background-color: rgba(0,0,0,0.7); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 600; + padding: 3px 8px; + border: 1px solid rgba(255,115,0,0.3); +} + +.\$news-card-body { + padding: 16px; + display: flex; + flex-direction: column; + flex: 1; + gap: 8px; +} + +.\$news-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.\$news-card-excerpt { + font-size: 0.82rem; + color: var(--text2); + line-height: 1.5; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; +} + +.\$news-card-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.75rem; + color: var(--text2); + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.\$news-card-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +@media (max-width: 600px) { + .news-hero { height: 240px; } + .news-hero-title { font-size: 1.4rem; } + .news-grid { grid-template-columns: 1fr; } +} + + +/* File: resources/css/layout/submit.css */ +.\$submit-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + background-color: var(--bg2); + border-bottom: 1px solid var(--border); + padding: 40px 36px 32px; +} + +.\$submit-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 3px 10px; + margin-bottom: 16px; +} + +.\$submit-hero-title { + font-size: 1.9rem; + font-weight: 300; + color: var(--text); + margin-bottom: 10px; + line-height: 1.25; +} + +.\$submit-hero-sub { + font-size: 0.9rem; + color: var(--text2); + max-width: 460px; + line-height: 1.65; +} + +.\$submit-review-note { + font-size: 0.8rem; + color: var(--text2); + border: 1px solid var(--border); + background: var(--bg3); + padding: 14px 18px; + max-width: 210px; + line-height: 1.6; + flex-shrink: 0; +} + +.submit-review-note strong { color: var(--rhpz-orange); } + +.submit-body { padding: 28px 36px 40px; } + +.\$submit-section-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text2); + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.\$submit-section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.\$submit-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 30px; +} + +.\$submit-card { + display: flex; + flex-direction: column; + background: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.\$submit-card:hover { + border-color: var(--card-color); + background: var(--bg3); + text-decoration: none; +} + +.\$submit-card-top { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); +} + +.\$submit-card-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--card-bg); + border: 1px solid var(--card-border); + color: var(--card-color); +} + +.\$submit-card-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.\$submit-card-desc { + font-size: 0.78rem; + color: var(--text2); + line-height: 1.55; +} + +.\$submit-card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; +} + +.\$submit-card-tag { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--card-color); + background: var(--card-bg); + border: 1px solid var(--card-border); + padding: 2px 7px; +} + +.\$submit-card-cta { + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-card:hover .submit-card-cta { color: var(--card-color); } + +.\$submit-news-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 28px; +} + +.\$submit-news-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.\$submit-news-card:hover { + border-color: var(--success); + background: var(--bg3); +} + +.submit-news-card--disabled { opacity: .5; cursor: not-allowed; } +.submit-news-card--disabled:hover { border-color: var(--border); background: var(--bg2); } + +.\$submit-news-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(129,199,132,.1); + border: 1px solid rgba(129,199,132,.3); + color: var(--success); +} + +.\$submit-news-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 3px; +} + +.submit-news-desc { font-size: 0.78rem; color: var(--text2); line-height: 1.55; } + +.\$submit-news-cta { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-news-card:hover .submit-news-cta { color: var(--success); } + +.\$submit-news-staff-note { + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + font-size: 0.8rem; + color: var(--text2); + line-height: 1.65; + display: flex; + flex-direction: column; + gap: 10px; +} + +.\$submit-news-staff-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 2px 8px; + width: fit-content; +} + +.submit-news-staff-note a { color: var(--text2); text-decoration: underline; } +.submit-news-staff-note a:hover { color: var(--text); } + +.\$submit-rules { + display: flex; + gap: 0; + background: var(--bg2); + border: 1px solid var(--border); +} + +.\$submit-rule { + flex: 1; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 22px; + border-right: 1px solid var(--border); + font-size: 0.8rem; + color: var(--text2); + line-height: 1.6; +} + +.submit-rule:last-child { border-right: none; } +.submit-rule strong { color: var(--text); } + +.\$submit-rule-num { + width: 22px; + height: 22px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 700; +} + +@media (max-width: 900px) { + .submit-hero { flex-direction: column; align-items: flex-start; } + .submit-grid { grid-template-columns: repeat(2, 1fr); } + .submit-rules { flex-direction: column; } + .submit-rule { border-right: none; border-bottom: 1px solid var(--border); } + .submit-rule:last-child { border-bottom: none; } +} + +@media (max-width: 600px) { + .submit-hero, .submit-body { padding-left: 20px; padding-right: 20px; } + .submit-grid { grid-template-columns: 1fr; } + .submit-news-row { grid-template-columns: 1fr; } + .submit-review-note { max-width: 100%; } +} + /* File: resources/css/xenforo.css */ .\$xf-menu-user-avatar-fix { diff --git a/public/favicon.ico b/public/favicon.ico index e69de29..e6ee837 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo/plaza-logo-text.png b/public/logo/plaza-logo-text.png new file mode 100644 index 0000000..b41c6e3 Binary files /dev/null and b/public/logo/plaza-logo-text.png differ diff --git a/public/logo/plaza-logo-wide.png b/public/logo/plaza-logo-wide.png new file mode 100644 index 0000000..4bd308a Binary files /dev/null and b/public/logo/plaza-logo-wide.png differ diff --git a/public/logo/plaza-logo.png b/public/logo/plaza-logo.png new file mode 100644 index 0000000..3b05580 Binary files /dev/null and b/public/logo/plaza-logo.png differ diff --git a/public/rom-patcher-js/RomPatcher.webapp.js b/public/rom-patcher-js/RomPatcher.webapp.js index 6e2fedd..3e6ba3e 100644 --- a/public/rom-patcher-js/RomPatcher.webapp.js +++ b/public/rom-patcher-js/RomPatcher.webapp.js @@ -6,19 +6,19 @@ * License: * * MIT License -* +* * Copyright (c) 2016-2025 Marc Robledo -* +* * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: -* +* * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. -* +* * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -35,7 +35,7 @@ - switch to ES6 classes and modules? */ -const ROM_PATCHER_JS_PATH = './rom-patcher-js/'; +const ROM_PATCHER_JS_PATH = '../../rom-patcher-js/'; const RomPatcherWeb = (function () { const SCRIPT_DEPENDENCIES = [ @@ -198,7 +198,6 @@ const RomPatcherWeb = (function () { ZIPManager.unzipEmbededPatches(arrayBuffer, currentEmbededPatches); } else { const parsedPatch = _parseEmbededPatchInfo(embededPatchInfo); - currentEmbededPatches = [parsedPatch]; const option = document.createElement('option'); option.innerHTML = parsedPatch.name; @@ -612,6 +611,7 @@ const RomPatcherWeb = (function () { const containerOptionalPatches = document.createElement('div'); containerOptionalPatches.id = 'rom-patcher-container-optional-patches'; containerOptionalPatches.style.display = 'none'; + containerOptionalPatches.classList.add("form-group", "level", "form-type-of-checkboxes"); htmlSelectPatch.parentElement.appendChild(containerOptionalPatches); } else { const htmlInputFilePatch = htmlElements.get('input-file-patch'); @@ -1415,9 +1415,16 @@ const ZIPManager = (function (romPatcherWeb) { const optionalPatches = []; for (var i = 0; i < filteredEntries.length; i++) { const embededPatchInfo = embededPatchesInfo.find((embededPatchInfo) => embededPatchInfo.file === filteredEntries[i].filename); - if (embededPatchInfo && embededPatchInfo.optional) + if (embededPatchInfo && embededPatchInfo.optional ) optionalPatches.push(filteredEntries[i]); - else + else if( filteredEntries[i].filename.startsWith('optional_') ){ + embededPatchesInfo.push({ + file: filteredEntries[i].filename, + name: filteredEntries[i].filename.replace(/^optional_/, '').replace(/_/g, ' ').replace(/\.[^.]+$/, ''), + optional: true + }); + optionalPatches.push(filteredEntries[i]); + } else selectablePatches.push(filteredEntries[i]); } @@ -1446,6 +1453,7 @@ const ZIPManager = (function (romPatcherWeb) { const embededPatchInfo = embededPatchesInfo.find((embededPatchInfo) => embededPatchInfo.file === optionalPatches[i].filename); const checkbox = document.createElement('input'); + checkbox.classList.add('form-checkbox'); checkbox.type = 'checkbox'; checkbox.value = i; checkbox.checked = false; @@ -2164,4 +2172,4 @@ const ROM_PATCHER_LOCALE = { 'Invalid patch file': '無效的patch檔', 'Using big files is not recommended': '不建議使用大檔。' } -}; \ No newline at end of file +}; diff --git a/resources/css/app.css b/resources/css/app.css index 60437ec..ebd3d5b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,6 +5,8 @@ @import './layout/content.css'; @import './layout/entry.css'; @import './layout/news.css'; +@import './layout/activity.css'; +@import './layout/submit.css'; @import './components/common.css'; @import './components/grid.css'; diff --git a/resources/css/components/forms.css b/resources/css/components/forms.css index 11a7587..94156bd 100644 --- a/resources/css/components/forms.css +++ b/resources/css/components/forms.css @@ -349,6 +349,50 @@ flex-direction: column; gap: 5px; } +.gallery-item { + position: relative; + cursor: grab; + transition: opacity 0.2s, transform 0.15s; + user-select: none; +} + +.gallery-item:active { cursor: grabbing; } + +.gallery-item--dragging { + opacity: 0.4; + transform: scale(0.97); +} + +.gallery-drag-handle { + position: absolute; + top: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.6); + color: #fff; + padding: 3px 4px; + display: flex; + align-items: center; + cursor: grab; + opacity: 0; + transition: opacity 0.15s; +} + +.gallery-item:hover .gallery-drag-handle { opacity: 1; } + +.gallery-order-badge { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 10; + background-color: rgba(0,0,0,0.7); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 2px 6px; + min-width: 20px; + text-align: center; +} .authors-list { display: grid; grid-template-columns: repeat(4, 1fr); diff --git a/resources/css/components/modal.css b/resources/css/components/modal.css index 934b109..ec8e6eb 100644 --- a/resources/css/components/modal.css +++ b/resources/css/components/modal.css @@ -48,6 +48,6 @@ } -.modal-content { +.modal-content, .modal-body { padding: 20px; } diff --git a/resources/css/components/modcp.css b/resources/css/components/modcp.css index e5cddb9..cafe51f 100644 --- a/resources/css/components/modcp.css +++ b/resources/css/components/modcp.css @@ -318,3 +318,232 @@ .modcp-list-item-edit--game .form-input { min-width: 180px; flex: 2; } .modcp-list-item-edit--game .form-select { flex: 1; min-width: 120px; } + +.log-filters { + margin-bottom: 16px; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.log-filters-main { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + flex-wrap: wrap; +} + +.log-search-wrap { + flex: 1; + min-width: 200px; + position: relative; + display: flex; + align-items: center; +} + +.log-search-wrap i { + position: absolute; + left: 10px; + color: var(--text2); + pointer-events: none; +} + +.log-search-wrap .form-input { padding-left: 30px; } + +.log-select { min-width: 130px; } + +.log-filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--rhpz-orange); + flex-shrink: 0; +} + +.log-filters-extra { + border-top: 1px solid var(--border); + padding: 12px 14px; +} + +.log-filters-extra-inner { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.log-filter-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.log-filter-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text2); +} + +.log-transition-enter { transition: all .15s ease; } +.log-transition-leave { transition: all .1s ease; } + +.log-results-bar { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.78rem; + color: var(--text2); + margin-bottom: 10px; + padding: 0 2px; +} + +.log-loading { opacity: 0.5; } + +.log-item { align-items: flex-start; padding: 11px 14px; } +.log-item--open { background-color: var(--bg3); } + +.log-event-dot { + width: 26px; + height: 26px; + flex-shrink: 0; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + border: 1px solid var(--border); + background-color: var(--bg3); + color: var(--text2); +} + +.log-event-dot--created { + background-color: rgba(129,199,132,.1); + border-color: rgba(129,199,132,.35); + color: var(--success); +} + +.log-event-dot--updated { + background-color: rgba(255,115,0,.1); + border-color: rgba(255,115,0,.35); + color: var(--rhpz-orange); +} + +.log-event-dot--deleted { + background-color: rgba(229,115,115,.1); + border-color: rgba(229,115,115,.35); + color: var(--error); +} + +.log-channel-badge { + display: inline-flex; + align-items: center; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + background-color: rgba(255,255,255,.05); + border: 1px solid var(--border); + color: var(--text2); +} + +.log-id { color: var(--text2); font-size: 0.78rem; } +.log-sep { color: var(--border); } + +.log-item-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; +} + +.log-timestamp { + font-size: 0.75rem; + color: var(--text2); + white-space: nowrap; +} + +.log-expand-btn { padding: 4px 7px; } + +.log-properties { + background-color: var(--bg); + border-bottom: 1px solid var(--border); + padding: 14px 14px 14px 54px; +} + +.log-diff-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--text2); + margin-bottom: 8px; +} + +.log-diff { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.log-diff th { + text-align: left; + padding: 5px 10px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text2); + border-bottom: 1px solid var(--border); +} + +.log-diff td { + padding: 5px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; + max-width: 300px; + overflow-wrap: break-word; +} + +.log-diff tr:last-child td { border-bottom: none; } + +.log-diff-key { + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + width: 160px; + white-space: nowrap; +} + +.log-diff-old-head { color: var(--error) !important; } +.log-diff-new-head { color: var(--success) !important; } + +.log-diff-old { + color: var(--error); + background-color: rgba(229,115,115,.05); +} + +.log-diff-new { + color: var(--success); + background-color: rgba(129,199,132,.05); +} + +.log-raw { + font-family: monospace; + font-size: 0.78rem; + color: var(--text2); + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 12px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.log-pagination { + padding: 14px 0 4px; + border-top: 1px solid var(--border); +} diff --git a/resources/css/layout/activity.css b/resources/css/layout/activity.css new file mode 100644 index 0000000..34e71cf --- /dev/null +++ b/resources/css/layout/activity.css @@ -0,0 +1,485 @@ + +.activity-hero-excerpt { + font-size: 0.9rem; + color: rgba(255,255,255,0.75); + margin-bottom: 12px; + line-height: 1.5; + max-width: 600px; +} + +.activity-tl-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); + gap: 15px; + flex-wrap: wrap; +} + +.activity-tl-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.15rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.activity-tl-filters { + display: flex; + gap: 5px; + flex-wrap: wrap; +} + +.activity-tl-filter { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + background: none; + border: 1px solid var(--border); + color: var(--text2); + font-size: 0.8rem; + cursor: pointer; + font-family: var(--typography); + transition: all 0.1s; +} + +.activity-tl-filter:hover { background-color: var(--bg3); color: var(--text); } +.activity-tl-filter.active { + background-color: var(--bg3); + border-color: var(--rhpz-orange); + color: var(--rhpz-orange); +} + +.activity-day-sep { + display: flex; + align-items: center; + gap: 10px; + padding-left: 54px; + margin: 20px 0 12px; +} + +.activity-day-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.8px; + white-space: nowrap; +} + +.activity-day-line { + flex: 1; + height: 1px; + background-color: var(--border); +} + +.activity-tl-item { + display: flex; + gap: 0; + margin-bottom: 2px; +} + +.activity-tl-left { + width: 54px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 12px; +} + +.activity-tl-dot { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + background-color: var(--bg2); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + z-index: 1; +} + +.activity-tl-dot--entry { + background-color: rgba(255,115,0,0.1); + border-color: rgba(255,115,0,0.4); + color: var(--rhpz-orange); +} + +.activity-tl-dot--news { + background-color: rgba(129,199,132,0.1); + border-color: rgba(129,199,132,0.4); + color: var(--success); +} + +.activity-tl-dot--message, .activity-tl-dot--thread, .activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + border-color: rgba(25,118,210,0.4); + color: var(--info); +} + +.activity-tl-line { + width: 1px; + flex: 1; + background-color: var(--border); + margin-top: 4px; + min-height: 16px; +} + +.activity-tl-item:last-of-type .activity-tl-line { display: none; } + +.activity-tl-card { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + background-color: var(--bg2); + border: 1px solid var(--border); + padding: 10px 14px; + margin-bottom: 8px; + text-decoration: none; + transition: border-color 0.15s, background-color 0.1s; + min-width: 0; +} + +.activity-tl-card:hover { + border-color: var(--rhpz-orange); + background-color: var(--bg3); + text-decoration: none; +} + +.activity-tl-thumb { + width: 52px; + height: 52px; + flex-shrink: 0; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg3); + border: 1px solid var(--border); +} + +.activity-tl-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.activity-tl-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.activity-tl-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + padding: 2px 7px; + width: fit-content; +} + +.activity-tl-badge--entry { + background-color: rgba(255,115,0,0.1); + color: var(--rhpz-orange); + border: 1px solid rgba(255,115,0,0.25); +} + +.activity-tl-badge--news { + background-color: rgba(129,199,132,0.1); + color: var(--success); + border: 1px solid rgba(129,199,132,0.25); +} + +.activity-tl-badge--message, .activity-tl-badge--thread, .activity-tl-dot--club { + background-color: rgba(25,118,210,0.1); + color: var(--info); + border: 1px solid rgba(25,118,210,0.25); +} + +.activity-tl-card-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.activity-tl-card-description { + font-size: 0.8rem; + color: var(--text2); + white-space: nowrap; + text-overflow: ellipsis; + line-height: 1.3; +} + +.activity-tl-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.75rem; + color: var(--text2); + flex-wrap: wrap; +} + +.activity-tl-meta span { + display: flex; + align-items: center; + gap: 3px; +} + +.activity-tl-time { + font-size: 0.72rem; + color: var(--text2); + white-space: nowrap; + flex-shrink: 0; + align-self: center; +} + +.activity-tl-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px; + color: var(--text2); + text-align: center; + padding-left: 54px; +} + +@media (max-width: 600px) { + .activity-tl-header { flex-direction: column; align-items: flex-start; } + .activity-tl-thumb { display: none; } + .activity-day-sep { padding-left: 44px; } + .activity-tl-left { width: 44px; } +} + +.home-section { + margin-bottom: 30px; +} + +.home-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 14px; +} + +.home-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.home-section-more { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--text2); + border: 1px solid var(--border); + padding: 4px 10px; + text-decoration: none; + transition: color 0.1s, border-color 0.1s; +} + +.home-section-more:hover { + color: var(--rhpz-orange); + border-color: var(--rhpz-orange); +} + +.news-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; +} + +.news-strip-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.news-strip-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.news-strip-cover { + height: 110px; + background-color: var(--bg3); + background-size: cover; + background-position: center; + position: relative; + flex-shrink: 0; +} + +.news-strip-date { + position: absolute; + bottom: 6px; + left: 8px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255,255,255,0.8); + background: rgba(0,0,0,0.55); + padding: 2px 6px; + border: 1px solid rgba(255,255,255,0.07); +} + +.news-strip-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.news-strip-badge { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--success); + background: rgba(129,199,132,0.1); + border: 1px solid rgba(129,199,132,0.25); + padding: 1px 6px; + width: fit-content; +} + +.news-strip-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; +} + +.news-strip-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +.featured-entries-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.featured-entry-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s; +} + +.featured-entry-card:hover { border-color: var(--rhpz-orange); text-decoration: none; } + +.featured-entry-cover { + height: 80px; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + overflow: hidden; +} + +.featured-entry-cover img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.85; +} + +.featured-entry-star { + position: absolute; + top: 6px; + right: 6px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + background: rgba(255,115,0,0.9); + color: #111; + padding: 2px 6px; + border: 1px solid rgba(255,115,0,0.5); + display: flex; + align-items: center; + gap: 3px; +} + +.featured-entry-body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.featured-entry-platform { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--rhpz-orange); + background: rgba(255,115,0,0.1); + border: 1px solid rgba(255,115,0,0.25); + padding: 1px 6px; + width: fit-content; +} + +.featured-entry-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.featured-entry-meta { + font-size: 0.72rem; + color: var(--text2); + margin-top: auto; +} + +@media (max-width: 900px) { + .news-strip { grid-template-columns: repeat(3, 1fr); } + .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 600px) { + .news-strip { grid-template-columns: repeat(2, 1fr); } + .featured-entries-grid { grid-template-columns: repeat(2, 1fr); } + .news-strip-cover { height: 80px; } +} diff --git a/resources/css/layout/menu.css b/resources/css/layout/menu.css index 564c25d..e7f3e47 100644 --- a/resources/css/layout/menu.css +++ b/resources/css/layout/menu.css @@ -9,12 +9,18 @@ z-index: 100; .menu-header { - padding: 20px; + padding: 10px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); + img { + width: 100%; + height: 100%; + object-fit: contain; + } + .menu-logo { width: 32px; height: 32px; diff --git a/resources/css/layout/news.css b/resources/css/layout/news.css index eb73bc6..e5159e1 100644 --- a/resources/css/layout/news.css +++ b/resources/css/layout/news.css @@ -46,3 +46,441 @@ margin-bottom: 20px; } } + +#news-container { + background-color: var(--bg2); + border: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.news-header { + width: 100%; + height: 300px; + background-size: cover; + background-position: center; + display: flex; + align-items: flex-end; + padding: 40px 30px; + border-bottom: 1px solid var(--border); + position: relative; +} + +.news-header-content { + position: relative; + z-index: 2; +} + +.news-header .news-title { + font-size: 2.5rem; + font-weight: 600; + color: var(--text); + margin-bottom: 12px; + text-shadow: 0 2px 4px rgba(0,0,0,0.6); +} + +.news-header .news-meta { + color: var(--text2); + display: flex; + gap: 20px; + font-size: 0.9rem; + align-items: center; +} + +.news-header .meta-item { + display: flex; + align-items: center; + gap: 6px; + background-color: rgba(0, 0, 0, 0.4); + padding: 4px 10px; + border-radius: 2px; + border: 1px solid rgba(255,255,255,0.05); +} + +.news-layout { + display: flex; + flex-direction: row; + gap: 30px; + padding: 30px; +} + +@media (max-width: 992px) { + .news-layout { + flex-direction: column; + } +} + +.news-main-content { + flex-grow: 1; + flex-basis: 0; + min-width: 0; +} + +.news-body-text { + line-height: 1.75; + color: var(--text); + font-size: 1.05rem; + margin-bottom: 15px; +} + +.news-body-text p { + margin-bottom: 20px; +} + +.news-sidebar { + width: 320px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 25px; +} + +@media (max-width: 992px) { + .news-sidebar { + width: 100%; + } +} + +.sidebar-block { + background-color: var(--bg); + border: 1px solid var(--border); + padding: 20px; + border-radius: 4px; +} + +.sidebar-title { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text); + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; +} + +.btn-sidebar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 15px; + font-size: 0.95rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + border-radius: 2px; + transition: background-color 0.2s ease, border-color 0.2s ease; + text-align: center; +} + +.btn-orange { + background-color: var(--rhpz-orange); + color: #fff; + border: 1px solid transparent; +} + +.btn-orange:hover { + background-color: var(--rhpz-orange-hover); +} + +.related-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.related-card-cover { + width: 100%; + height: 150px; + background-color: var(--bg2); + border: 1px solid var(--border); + overflow: hidden; + border-radius: 2px; +} + +.related-card-cover img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 5px; +} + +.related-card-info h4 { + font-size: 1.1rem; + color: var(--text); + margin-bottom: 10px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.news-sidebar .video-thumbnail-wrapper { + position: relative; + width: 100%; + aspect-ratio: 16/9; + background-color: #000; + border: 1px solid var(--border); + cursor: pointer; + overflow: hidden; + border-radius: 2px; +} + +.news-sidebar .video-thumbnail-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.7; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.news-sidebar .video-thumbnail-wrapper:hover img { + transform: scale(1.03); + opacity: 0.9; +} + +.news-sidebar .play-trigger { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50px; + height: 50px; + background-color: rgba(0, 0, 0, 0.75); + border: 2px solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + transition: background-color 0.2s, transform 0.2s ease-out; +} + +.news-sidebar .video-thumbnail-wrapper:hover .play-trigger { + background-color: var(--rhpz-orange); + transform: translate(-50%, -50%) scale(1.1); +} + +.news-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 15px; + flex-wrap: wrap; +} + +.news-header .news-actions .btn { + background-color: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(4px); + color: var(--text); + transition: background-color 0.15s, border-color 0.15s; +} + +.news-header .news-actions .btn:hover { + background-color: rgba(0, 0, 0, 0.7); + border-color: rgba(255, 255, 255, 0.3); +} + +.news-header .news-actions .btn.success { + background-color: rgba(56, 142, 60, 0.6); + border-color: rgba(129, 199, 132, 0.4); + color: #81c784; +} + +.news-header .news-actions .btn.danger { + background-color: rgba(183, 28, 28, 0.5); + border-color: rgba(229, 115, 115, 0.4); + color: #e57373; +} + +/* ── Hero ────────────────────────────────────────────────── */ +.news-hero { + display: block; + position: relative; + width: 100%; + height: 360px; + margin-bottom: 20px; + border: 1px solid var(--border); + overflow: hidden; + text-decoration: none; + transition: border-color 0.2s; +} + +.news-hero:hover { + border-color: var(--rhpz-orange); + text-decoration: none; +} + +.news-hero-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + background-color: var(--bg3); + transition: transform 0.4s ease; +} + +.news-hero:hover .news-hero-bg { + transform: scale(1.02); +} + +.news-hero-content { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 30px; + z-index: 2; +} + +.news-hero-badge { + display: inline-flex; + align-items: center; + gap: 5px; + background-color: var(--rhpz-orange); + color: #111; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 4px 10px; + margin-bottom: 12px; +} + +.news-hero-title { + font-size: 2rem; + font-weight: 600; + color: #fff; + margin-bottom: 12px; + text-shadow: 0 2px 8px rgba(0,0,0,0.5); + line-height: 1.2; +} + +.news-hero-meta { + display: flex; + align-items: center; + gap: 15px; + font-size: 0.85rem; + color: rgba(255,255,255,0.75); +} + +.news-hero-meta span { + display: flex; + align-items: center; + gap: 5px; + background-color: rgba(0,0,0,0.4); + padding: 3px 10px; + border: 1px solid rgba(255,255,255,0.08); +} + +.news-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} + +.news-card { + display: flex; + flex-direction: column; + background-color: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + overflow: hidden; + transition: border-color 0.15s, transform 0.15s; +} + +.news-card:hover { + border-color: var(--rhpz-orange); + transform: translateY(-2px); + text-decoration: none; +} + +.news-card-cover { + height: 160px; + background-size: cover; + background-position: center; + background-color: var(--bg3); + position: relative; + flex-shrink: 0; + transition: transform 0.3s ease; +} + +.news-card:hover .news-card-cover { + transform: scale(1.03); +} + +.news-card-state-badge { + position: absolute; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 4px; + background-color: rgba(0,0,0,0.7); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 600; + padding: 3px 8px; + border: 1px solid rgba(255,115,0,0.3); +} + +.news-card-body { + padding: 16px; + display: flex; + flex-direction: column; + flex: 1; + gap: 8px; +} + +.news-card-title { + font-size: 1rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.news-card-excerpt { + font-size: 0.82rem; + color: var(--text2); + line-height: 1.5; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; +} + +.news-card-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.75rem; + color: var(--text2); + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.news-card-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +@media (max-width: 600px) { + .news-hero { height: 240px; } + .news-hero-title { font-size: 1.4rem; } + .news-grid { grid-template-columns: 1fr; } +} diff --git a/resources/css/layout/submit.css b/resources/css/layout/submit.css new file mode 100644 index 0000000..92c22bf --- /dev/null +++ b/resources/css/layout/submit.css @@ -0,0 +1,297 @@ +.submit-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + background-color: var(--bg2); + border-bottom: 1px solid var(--border); + padding: 40px 36px 32px; +} + +.submit-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 3px 10px; + margin-bottom: 16px; +} + +.submit-hero-title { + font-size: 1.9rem; + font-weight: 300; + color: var(--text); + margin-bottom: 10px; + line-height: 1.25; +} + +.submit-hero-sub { + font-size: 0.9rem; + color: var(--text2); + max-width: 460px; + line-height: 1.65; +} + +.submit-review-note { + font-size: 0.8rem; + color: var(--text2); + border: 1px solid var(--border); + background: var(--bg3); + padding: 14px 18px; + max-width: 210px; + line-height: 1.6; + flex-shrink: 0; +} + +.submit-review-note strong { color: var(--rhpz-orange); } + +.submit-body { padding: 28px 36px 40px; } + +.submit-section-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text2); + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.submit-section-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.submit-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 30px; +} + +.submit-card { + display: flex; + flex-direction: column; + background: var(--bg2); + border: 1px solid var(--border); + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.submit-card:hover { + border-color: var(--card-color); + background: var(--bg3); + text-decoration: none; +} + +.submit-card-top { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 20px 16px; + border-bottom: 1px solid var(--border); +} + +.submit-card-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--card-bg); + border: 1px solid var(--card-border); + color: var(--card-color); +} + +.submit-card-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.submit-card-desc { + font-size: 0.78rem; + color: var(--text2); + line-height: 1.55; +} + +.submit-card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; +} + +.submit-card-tag { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--card-color); + background: var(--card-bg); + border: 1px solid var(--card-border); + padding: 2px 7px; +} + +.submit-card-cta { + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-card:hover .submit-card-cta { color: var(--card-color); } + +.submit-news-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 28px; +} + +.submit-news-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + text-decoration: none; + transition: border-color .15s, background .1s; +} + +.submit-news-card:hover { + border-color: var(--success); + background: var(--bg3); +} + +.submit-news-card--disabled { opacity: .5; cursor: not-allowed; } +.submit-news-card--disabled:hover { border-color: var(--border); background: var(--bg2); } + +.submit-news-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(129,199,132,.1); + border: 1px solid rgba(129,199,132,.3); + color: var(--success); +} + +.submit-news-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin-bottom: 3px; +} + +.submit-news-desc { font-size: 0.78rem; color: var(--text2); line-height: 1.55; } + +.submit-news-cta { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--text2); + display: flex; + align-items: center; + gap: 4px; + transition: color .15s; +} + +.submit-news-card:hover .submit-news-cta { color: var(--success); } + +.submit-news-staff-note { + background: var(--bg2); + border: 1px solid var(--border); + padding: 18px 22px; + font-size: 0.8rem; + color: var(--text2); + line-height: 1.65; + display: flex; + flex-direction: column; + gap: 10px; +} + +.submit-news-staff-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--rhpz-orange); + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + padding: 2px 8px; + width: fit-content; +} + +.submit-news-staff-note a { color: var(--text2); text-decoration: underline; } +.submit-news-staff-note a:hover { color: var(--text); } + +.submit-rules { + display: flex; + gap: 0; + background: var(--bg2); + border: 1px solid var(--border); +} + +.submit-rule { + flex: 1; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 22px; + border-right: 1px solid var(--border); + font-size: 0.8rem; + color: var(--text2); + line-height: 1.6; +} + +.submit-rule:last-child { border-right: none; } +.submit-rule strong { color: var(--text); } + +.submit-rule-num { + width: 22px; + height: 22px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255,115,0,.1); + border: 1px solid rgba(255,115,0,.3); + color: var(--rhpz-orange); + font-size: 0.72rem; + font-weight: 700; +} + +@media (max-width: 900px) { + .submit-hero { flex-direction: column; align-items: flex-start; } + .submit-grid { grid-template-columns: repeat(2, 1fr); } + .submit-rules { flex-direction: column; } + .submit-rule { border-right: none; border-bottom: 1px solid var(--border); } + .submit-rule:last-child { border-bottom: none; } +} + +@media (max-width: 600px) { + .submit-hero, .submit-body { padding-left: 20px; padding-right: 20px; } + .submit-grid { grid-template-columns: 1fr; } + .submit-news-row { grid-template-columns: 1fr; } + .submit-review-note { max-width: 100%; } +} diff --git a/resources/js/PlayOnlineAndPatcher.js b/resources/js/PlayOnlineAndPatcher.js new file mode 100644 index 0000000..30fe09f --- /dev/null +++ b/resources/js/PlayOnlineAndPatcher.js @@ -0,0 +1,85 @@ +import { RomPatcher } from './RomPatcher.js'; + +window.PlayOnline = function( initialPatches = {}, emulatorJsConfig = {} ){ + + const parent = RomPatcher( initialPatches ); + + return { + + ...parent, + + currentBlobUrl: null, + emuConfig: emulatorJsConfig, + launchGame: false, + + init(){ + parent.init({ + language: 'en', + requireValidation: false, + onpatch: this.handlePatchedRomFile.bind(this), + }); + }, + + cleanEmulatorJsVars() { + ['EJS_player','EJS_core','EJS_gameUrl','EJS_pathtodata', + 'EJS_startOnLoaded','EJS_threads'] + .forEach(k => delete window[k]); + }, + + prepareEmulatorJs(){ + window.EJS_player = '#game'; + window.EJS_core = this.emuConfig.core; + window.EJS_gameUrl = this.currentBlobUrl; + window.EJS_pathtodata = "https://cdn.emulatorjs.org/stable/data/"; + window.EJS_startOnLoaded = true; + window.EJS_threads = this.emuConfig.threads ?? false; + }, + + launchEmulatorJs(){ + if(!this.currentBlobUrl){ + console.error("EmulatorJS: Empty Blob field"); + return; + } + + console.log(this.currentBlobUrl); + + this.cleanEmulatorJsVars(); + this.prepareEmulatorJs(); + + const script = document.createElement('script'); + script.id = 'ejs-loader'; + script.src = 'https://cdn.emulatorjs.org/stable/data/loader.js'; + document.body.appendChild(script); + + this.launchGame = true; + + }, + + /** + * @param {BinFile} patchedRomFile + */ + handlePatchedRomFile( patchedRomFile ){ + + patchedRomFile.save = function(){ + // Remove save. + return; + } + + const u8 = patchedRomFile._u8array; + if( !u8 || u8.byteLength === 0 ){ + console.error("Patch error: Empty ROM file"); + return; + } + + if(this.currentBlobUrl){ + URL.revokeObjectURL(this.currentBlobUrl); + } + + const blob = new Blob([u8], { type: 'application/octet-stream' }); + this.currentBlobUrl = URL.createObjectURL(blob); + + this.launchEmulatorJs() + } + } + +} diff --git a/resources/js/RomPatcher.js b/resources/js/RomPatcher.js index 185d0dc..63c7e54 100644 --- a/resources/js/RomPatcher.js +++ b/resources/js/RomPatcher.js @@ -1,4 +1,4 @@ -export function RomPatcher( initialPatches = {} ) { +export const RomPatcher = function( initialPatches = {} ) { let patchesArray = []; if (initialPatches) { @@ -39,9 +39,9 @@ export function RomPatcher( initialPatches = {} ) { patchesData: patchesArray, hasEmbedded: patchesArray.length > 0, - init() { + init( config = {language: 'en', requireValidation: false} ) { - const CONFIG = {language: 'en', requireValidation: false}; + const CONFIG = config; if (!RomPatcherWeb.isInitialized()){ if (this.hasEmbedded) { @@ -112,3 +112,7 @@ export function RomPatcher( initialPatches = {} ) { } } } + +window.RomPatcher = RomPatcher; + + diff --git a/resources/js/SubmissionsClass/FSFileData.js b/resources/js/SubmissionsClass/FSFileData.js index a44b3c5..6c4fc13 100644 --- a/resources/js/SubmissionsClass/FSFileData.js +++ b/resources/js/SubmissionsClass/FSFileData.js @@ -2,6 +2,10 @@ export const CHUNK_SIZE = 8192; +const PATCH_EXTENSIONS = new Set([ + 'ips', 'bps', 'ups', 'aps', 'ppf', 'xdelta', "zip" +]); + /** * An uploaded file instance. * Create a new file data. @@ -12,6 +16,8 @@ export const CHUNK_SIZE = 8192; */ export function FSFileData(name, totalChunks, rawFile ) { + const extension = name.split('.').pop().toLowerCase(); + return { /** @@ -66,6 +72,8 @@ export function FSFileData(name, totalChunks, rawFile ) { */ state: 'public', + can_be_online_patched: PATCH_EXTENSIONS.has(extension), + /** * If the online patcher is enabled */ @@ -76,6 +84,21 @@ export function FSFileData(name, totalChunks, rawFile ) { */ meta_secondary_online_patcher: false, + /** + * If this patch can be played online. + */ + meta_play_online: false, + + /** + * Selected core for play online + */ + meta_play_online_core: null, + + /** + * If the threads are enabled for playing online. + */ + meta_play_online_threads: null, + /** * Look if this file is currently uploading. * @returns {boolean} @@ -164,6 +187,6 @@ export function FSFileData(name, totalChunks, rawFile ) { } } -} + } } diff --git a/resources/js/SubmissionsClass/GalleryManager.js b/resources/js/SubmissionsClass/GalleryManager.js index 1f680c2..3cd11e6 100644 --- a/resources/js/SubmissionsClass/GalleryManager.js +++ b/resources/js/SubmissionsClass/GalleryManager.js @@ -12,6 +12,8 @@ export function GalleryManager() { */ images: [], + dragSrcI: null, + /** * Forward to this.images.length * @returns {number} @@ -123,6 +125,25 @@ export function GalleryManager() { handleRemoveFile(index){ this.images[index].handleRemoveFile(null); this.images.splice(index, 1); + }, + + dragStart(index){ + this.dragSrcI = index; + }, + + dragOver(e, index){ + e.preventDefault(); + + if( this.dragSrcI === null || this.dragSrcI === index ) + return; + + const moved = this.images.splice(this.dragSrcI, 1)[0]; + this.images.splice(index, 0, moved); + this.dragSrcI = index; + }, + + dragEnd(){ + this.dragSrcI = null; } } } diff --git a/resources/js/app.js b/resources/js/app.js index 068c39a..4cd824a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -7,7 +7,6 @@ import hovercard from "./hovercard.js"; import notifications from "./notifications.js"; import conversations from "./conversations.js"; import settings from "./settings.js"; -import {RomPatcher} from "./RomPatcher.js"; /** * Get config defined in meta.blade.php @@ -15,7 +14,7 @@ import {RomPatcher} from "./RomPatcher.js"; * @return {string|null} */ window.getConfig = function( key ){ - return document.querySelector('meta[name="config-' + key + '"]').getAttribute('content') ?? null; + return document.querySelector('meta[name="config-' + key + '"]')?.getAttribute('content') ?? null; } // Lucide icons. @@ -44,6 +43,3 @@ Alpine.store('conversations', conversations() ); // Settings Alpine.store('settings', settings() ); - -// ROMPatcher -window.RomPatcher = RomPatcher; diff --git a/resources/js/news-submissions.js b/resources/js/news-submissions.js new file mode 100644 index 0000000..905f150 --- /dev/null +++ b/resources/js/news-submissions.js @@ -0,0 +1,169 @@ +import { GalleryManager } from "./SubmissionsClass/GalleryManager.js"; + +/** + * If there is some server side errors. + * We may need reload some things. + * @type {boolean} + */ +const SERVER_SIDE_ERRORS = document.querySelector('meta[name="submission-has-errors"]')?.content === '1'; + +/** + * Object map of errors messages + * @type {Object} + */ +const ERROR_TABLE = { + noDescription: "Please provide a description.", + noGalleryImages: "Please select at least a gallery image.", + isSubmitting: "The entry is already during submission." +} + +window.GalleryManager = GalleryManager; + +/** + * Verify if an EasyMDE field is filled. + * + * @param {string} fieldName + * @returns {boolean} + */ +function verifyMDE( fieldName ){ + const textarea = document.querySelector('#field_' + fieldName); + if( textarea && textarea.value.trim().length > 0 ) { + return true; + } + + const field = window['mde_' + fieldName] || null; + return field && typeof field.value === 'function' && field.value().trim().length > 0; +} + +window.SubmissionVerifications = { + + /** + * Verify if the description field has at least one character. + * @returns {boolean} + */ + step1_VerifyDescription: function(){ + return verifyMDE('description'); + }, + + /** + * Verify if at least one image is uploaded in the gallery. + * @param element this.$el + * @return {boolean} + */ + step2_verifyGallery: function( element){ + let GalleryData = element.querySelector('[x-data="GalleryManager()"]'); + GalleryData = GalleryData ? Alpine.$data(GalleryData) : null; + + if( ! GalleryData ){ + return false; + } + + return GalleryData.number > 0 && GalleryData.allUploaded; + } + +} + +/** + * Handle entire submission process. + */ +window.NewsSubmission = function(){ + return { + + /** + * If the script is during a try of submission process. + * @type {boolean} + */ + duringSubmissionProcess: false, + + /** + * Error checked. + * @type {string|null} + */ + errorKey: null, + + /** + * Return error message. + * @return {string} + */ + get errorMessage(){ + return ERROR_TABLE[this.errorKey] ?? "Unknown error"; + }, + + init(){ + }, + + /** + * Do each form verifications. + * Update also this.errorKey. + * + * @returns {boolean} + */ + verifyForm(){ + + console.log( "Step 1" ); + if( !SubmissionVerifications.step1_VerifyDescription() ){ + this.errorKey = "noDescription"; + return false; + } + + console.log( "Step 2" ); + if( !SubmissionVerifications.step2_verifyGallery( this.$el )){ + this.errorKey = "noGalleryImages"; + return false; + } + + return true; + + }, + + /** + * Scroll to the specific error field. + */ + scrollToError(){ + const refMap = { + noDescription: 'descriptionField', + noGalleryImages: 'gallery-field', + isSubmitting: 'submitButton' + }; + + const target = this.$refs[refMap[this.errorKey]] + || this.$el.querySelector('.upload-list') + || this.$el.querySelector('.form-upload'); + + if (target) { + target.scrollIntoView({behavior: 'smooth', block: 'center'}); + return; + } + }, + + /** + * If you want to submit the form. + * @param {Event} e + */ + submitForm( e ){ + + if( this.duringSubmissionProcess ) + return; // Don't submit two times. + + this.errorKey = null; // Reset. + this.duringSubmissionProcess = true; + + const STATE = document.querySelector('select[name="submit-state"]')?.value; + if( STATE === 'draft' ){ + e.target.submit(); + return; + } + + if( !this.verifyForm() ){ + + this.scrollToError(); + + this.duringSubmissionProcess = false; + return; + } + + e.target.submit(); + } + + } +} diff --git a/resources/js/settings.js b/resources/js/settings.js index 51194fb..56cda37 100644 --- a/resources/js/settings.js +++ b/resources/js/settings.js @@ -14,20 +14,15 @@ export default function settings() { */ xfUrls: {}, - /** - * @type {number[]} - */ - entriesPerPage: [ 12, 30, 48 ], - /** * @type {string} */ - currentTheme: Cookies.get("theme") ?? 'default', + currentTheme: 'default', /** - * @type {number} + * @type {list|null} */ - currentEntriesPerPage: Cookies.get("entries_per_page") ?? 30, + currentActivityFilters: null, /** * @@ -43,7 +38,7 @@ export default function settings() { this.currentTheme = newTheme; document.documentElement.classList.toggle('light-mode', this.currentTheme === 'alternate'); - Cookies.set('theme', this.currentTheme, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); + // Cookies.set('theme', this.currentTheme, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); this.syncXF(); }, @@ -62,22 +57,48 @@ export default function settings() { this.themeChanged(this.currentTheme === 'default' ? 'alternate' : 'default'); }, - /** - * - * @param n - */ - entriesPerPageChanged( n ){ - if( !this.entriesPerPage.includes(n) ) - return; - - this.entriesPerPage = n; - Cookies.set('entries_per_page', this.entriesPerPage, { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); - if( window.Livewire ){ - Livewire.dispatch('entriesPerPageChanged', {n}); - } - }, - open(){ this.start = !this.start; }, close(){ this.start = false; }, + + async toggleActivityFilter( type ){ + + if( this.currentActivityFilters === null ) + return; + + const i = this.currentActivityFilters.indexOf( type ); + if( i !== -1 && this.currentActivityFilters.length === 1) + return; + + if( i === -1 ) + this.currentActivityFilters.push( type ); + else + this.currentActivityFilters.splice( i, 1 ); + + Cookies.set( 'activity_filters', JSON.stringify(this.currentActivityFilters), { expires: 365, path: '/', domain: window.getConfig('session-domain') } ); + await this.syncTimeline(); + }, + + async syncTimeline(){ + + const tl = document.getElementById('activity-timeline'); + if( !tl ) + return; + + tl.style.opacity = 0.5; + + const params = this.currentActivityFilters.join(','); + const response = await fetch(`/api/dynamic/activity/feed?filters=${params}`); + const data = await response.json(); + + if( !data.html ) + return; + + tl.innerHTML = data.html; + tl.style.opacity = 1; + + refreshIcons(tl); + + } + } } diff --git a/resources/js/submissions.js b/resources/js/submissions.js index 7931261..ae70c19 100644 --- a/resources/js/submissions.js +++ b/resources/js/submissions.js @@ -38,6 +38,7 @@ const ERROR_TABLE = { * @constructor */ const SECTION = () => document.querySelector("meta[name='fs-section']")?.content ?? ''; +const CSRF = () => document.querySelector("meta[name='csrf-token']")?.content ?? ''; window.FSUploader = FSUploader; window.HashesManager = HashesManager; @@ -403,6 +404,31 @@ window.Submission = function(){ } e.target.submit(); + }, + + async requestFeatured( entryId ){ + const csrf = CSRF(); + + const response = await fetch(`/api/entry/${entryId}/featured`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf + } + }); + const json = await response.json(); + + const entry_featured_button = document.querySelector('#entry-featured-button'); + const entry_featured_body = document.querySelector('#entry-featured-body'); + + if( json.success ){ + entry_featured_body.innerHTML = '

Request submitted

'; + entry_featured_button.style.display = 'none'; + } else { + entry_featured_body.innerHTML = '

Request failed. Please refresh the page and retry.

'; + entry_featured_button.style.display = 'none'; + } + } } diff --git a/resources/views/activity/featured-entries.blade.php b/resources/views/activity/featured-entries.blade.php new file mode 100644 index 0000000..b518475 --- /dev/null +++ b/resources/views/activity/featured-entries.blade.php @@ -0,0 +1,40 @@ + +@if($featuredEntries->isNotEmpty()) +
+
+

+ + Featured entries +

+
+ + +
+@endif diff --git a/resources/views/activity/latest-news.blade.php b/resources/views/activity/latest-news.blade.php new file mode 100644 index 0000000..6d13d74 --- /dev/null +++ b/resources/views/activity/latest-news.blade.php @@ -0,0 +1,34 @@ + +
+
+

+ + Latest news +

+ + See all + +
+ + +
diff --git a/resources/views/activity/timeline.blade.php b/resources/views/activity/timeline.blade.php new file mode 100644 index 0000000..a9aa66e --- /dev/null +++ b/resources/views/activity/timeline.blade.php @@ -0,0 +1,103 @@ +@php $currentDay = null; @endphp +
+ @forelse($items as $item) + + @php + $day = $item->date->format('Y-m-d'); + $dayLabel = $item->date->isToday() ? 'Today' + : ($item->date->isYesterday() ? 'Yesterday' + : $item->date->format('M d, Y')); + @endphp + + @if($day !== $currentDay) + @php $currentDay = $day; @endphp +
+ {{ $dayLabel }} +
+
+ @endif + + + + @empty +
+ +

No recent activity.

+
+ @endforelse +
diff --git a/resources/views/components/gallery-field.blade.php b/resources/views/components/gallery-field.blade.php index bc8e932..0bedff0 100644 --- a/resources/views/components/gallery-field.blade.php +++ b/resources/views/components/gallery-field.blade.php @@ -15,8 +15,14 @@