'', 'excerpt' => '', 'content' => '', 'slug' => '', 'translation_id' => null]; // In case fields are left empty (concept post) public $localeInit; public $locale; public $localesOptions = []; public $language; public bool $localeIsLocked = false; // True when category type defines specific locale public $title; public $content; public $from; // x-date-time-picker and x-select do not entangle if they do not exist beforehand public $till; // x-date-time-picker and x-select do not entangle if they do not exist beforehand public $modalStopPublication = false; public $modalStartPublication = false; public $selectedTranslationId = null; // Needed for the stop/startPublicationModal public string $confirmString = ''; public bool $isPrinciplesPost = false; public $image; public bool $imagePreviewable; public $mediaOwner; public $mediaCaption; public $media; public $meetingShow = false; public $meeting; public $meetingVenue; public $meetingAddress; public $meetingDistrict; public $meetingCity; public $meetingCountry; public $meetingFrom; public $meetingTill; public $amount; public $hours; public $minutes; public $basedOnQuantity; public $transactionTypeId; public $organizerOptions; public $organizer = ['id' => null, 'type' => null]; // In case fields are left empty (concept post) public $author = ['id' => null, 'type' => null]; // In case fields are left empty (concept post) public $perPage = 10; public $page; public string $sortField = 'updated_at'; // Default sort field public string $sortDirection = 'desc'; // Default sort direction protected $listeners = [ 'categorySelected', 'localeSelected', 'quillEditor', 'uploadImage', 'organizerSelected', 'authorSelected', 'amount', 'openCreateModal' => 'create', 'countryToParent', 'cityToParent', 'districtToParent', 'refreshPostsTable' => '$refresh', ]; public function mount() { // Admin Authorization - Prevent IDOR attacks and cross-guard access $activeProfileType = session('activeProfileType'); $activeProfileId = session('activeProfileId'); if (!$activeProfileType || !$activeProfileId) { abort(403, __('No active profile selected')); } $profile = $activeProfileType::find($activeProfileId); if (!$profile) { abort(403, __('Active profile not found')); } // Validate profile ownership using ProfileAuthorizationHelper (prevents cross-guard attacks) \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Verify admin or central bank permissions if ($profile instanceof \App\Models\Admin) { // Admin access OK } elseif ($profile instanceof \App\Models\Bank) { // Only central bank (level 0) can access post management if ($profile->level !== 0) { abort(403, __('Central bank access required for post management')); } } else { abort(403, __('Admin or central bank access required')); } // Log admin access for security monitoring \Log::info('Posts management access', [ 'component' => 'Posts\\Manage', 'profile_id' => $profile->id, 'profile_type' => get_class($profile), 'authenticated_guard' => \Auth::getDefaultDriver(), 'ip_address' => request()->ip(), ]); // Load post type filter from session if not set via URL if (empty($this->postTypeFilter) && !request()->has('postTypeFilter')) { $this->postTypeFilter = session('posts.manage.typeFilter', ''); } } public function updatedPostTypeFilter($value) { // Save post type filter to session session(['posts.manage.typeFilter' => $value]); $this->resetPage(); } protected function rules() { // Note that most fields are not required, this is to store concept posts return [ 'categoryId' => 'required|integer', 'locale' => 'required|string|min:2|max:3', 'post.slug' => array_merge( timebank_config('posts.slug_rule'), [Rule::unique('post_translations', 'slug')->ignore($this->post['translation_id'] ?? null, 'id')] ), 'post.title' => timebank_config('posts.title_rule'), 'post.excerpt' => timebank_config('posts.excerpt_rule'), 'content' => ['nullable', 'string', new MaxLengthWithoutHtml(timebank_config('posts.content_max_input', 2000))], 'from' => 'date|nullable', 'till' => 'date|nullable', 'image' => timebank_config('posts.image_rule'), 'mediaOwner' => timebank_config('posts.media_owner_rule'), 'mediaCaption' => timebank_config('posts.media_caption_rule'), 'meetingFrom' => 'date|nullable', 'meetingTill' => 'date|nullable', 'meeting.venue' => timebank_config('posts.meeting_venue_rule'), 'meeting.address' => timebank_config('posts.meeting_address_rule'), 'organizer.id' => 'integer|nullable', 'organizer.type' => 'string|nullable', 'amount' => 'integer|nullable|min:0', 'basedOnQuantity' => 'integer|nullable|min:0', 'transactionTypeId' => 'integer|nullable|exists:transaction_types,id', 'confirmString' => [ 'required_if:isPrinciplesPost,true', function ($attribute, $value, $fail) { // Only check if value matches expected when isPrinciplesPost is true if ($this->isPrinciplesPost && !empty($value)) { $expected = __('messages.confirm_input_string'); if ($value !== $expected) { $fail(__('The confirmation keyword is incorrect.')); } } }, ], ]; } /** * Check if the current post/category is a Principles post * * @return bool */ protected function checkIfPrinciplesPost(): bool { if (!$this->categoryId) { return false; } $category = Category::find($this->categoryId); if (!$category) { return false; } return $category->type === 'SiteContents\Static\Principles'; } // Add the queryString property to sync pagination with URL protected $queryString = [ 'search' => ['except' => ''], 'postTypeFilter' => ['except' => ''], 'categoryFilter' => ['except' => ''], 'languageFilter' => ['except' => ''], 'publicationStatusFilter' => ['except' => ''], 'perPage' => ['except' => 10], 'page' => ['except' => 1] ]; public function categorySelected($categoryId) { $this->categoryId = $categoryId; // Check if category type has locale-specific suffix $this->checkAndSetLocaleFromCategory(); $this->getLocalesOptions(); $isMeeting = Category::where('id', $this->categoryId)->where('type', Meeting::class)->exists(); if ($isMeeting) { if ($this->postId) { // Check if we are editing an existing post or if we are creating a new one $this->getMeeting(); $this->meetingShow = true; } $this->meetingShow = true; } else { $this->meetingShow = false; } // Check if this is a Principles post to show warning in modal $this->isPrinciplesPost = $this->checkIfPrinciplesPost(); } /** * Check if category type ends with a locale suffix and lock locale if it does * * @return void */ protected function checkAndSetLocaleFromCategory() { if (!$this->categoryId) { $this->localeIsLocked = false; return; } $category = Category::find($this->categoryId); if (!$category) { $this->localeIsLocked = false; return; } // Get supported locales from Laravel Localization config $supportedLocales = array_keys(config('laravellocalization.supportedLocales', [])); // Check if category type ends with \locale (e.g., App\Models\ImagePost\en) $categoryType = $category->type; foreach ($supportedLocales as $locale) { if (str_ends_with($categoryType, '\\' . $locale)) { // Lock locale to this specific language $this->locale = $locale; $this->localeIsLocked = true; $this->setLanguageName(); return; } } // Category type doesn't end with locale - allow free selection $this->localeIsLocked = false; } /** * Get available language options for the language select-box * * @return void */ public function getLocalesOptions() { // Ensure categoryId is set if (!$this->categoryId) { $this->localesOptions = []; return; } // Get available translations for the selected category $localesOptions = Category::with(['translations' => function ($query) { $query->select('category_id', 'locale'); }])->find($this->categoryId); // Edit a post: exclude existing translations but include initial locale if ($this->postId) { $localesExclude = Post::find($this->postId)->translations()->whereNot('locale', $this->localeInit)->pluck('locale'); } else { // Create a post $localesExclude = []; } $localesExclude = collect($localesExclude); if ($localesOptions) { $localesOptions = $localesOptions->translations()->pluck('locale'); $this->localesOptions = $localesOptions->diff($localesExclude); } else { $this->localesOptions = []; } $this->dispatch('updateLocalesOptions', $this->localesOptions); } public function localeSelected($locale) { // Prevent locale changes when locked by category if ($this->localeIsLocked) { return; } // Edit the initial post if ($locale === $this->localeInit) { $this->locale = $locale; $this->post['translation_id'] = Post::find($this->postId)->translations->first()->id; // No new post, so restore post['translation_id] to ignore unique slug validation $this->createTranslation = false; } elseif ($locale !== $this->localeInit) { // Add a new translation to the initial post $this->locale = $locale; $this->post['translation_id'] = null; // New post, so reset post['translation_id'] for unique slug validation $this->createTranslation = true; } $this->setLanguageName(); } public function setLanguageName() { if ($this->locale) { $this->language = DB::table('languages')->where('lang_code', $this->locale)->first()->name; } } public function organizerSelected($value) { $this->organizer = $value; } public function authorSelected($value) { $this->author = $value; } public function amount($value) { $this->amount = $value; } public function updatedTitle($value) { $this->post['title'] = $value; $this->post['slug'] = SlugService::createSlug(PostTranslation::class, 'slug', $value); } public function edit($translationId) { // CRITICAL: Authorize admin access for editing posts $this->authorizeAdminAccess(); // Reset validation errors from previous edits $this->resetValidation(); $this->resetErrorBag(); $this->meetingShow = false; // Hide the event details unless an event category is selected $this->createTranslation = false; $this->postId = PostTranslation::find($translationId)->post_id; $post = Post::with([ 'translations' => function ($query) use ($translationId) { $query->where('id', $translationId); }, 'category' => function ($query) { $query->with('translations'); }, 'meeting', 'author', ])->find($this->postId); $this->post = [ 'category_id' => $post->category_id, 'translation_id' => $post->translations->first()->id, 'locale' => $post->translations->first()->locale, 'title' => $post->translations->first()->title, 'slug' => $post->translations->first()->slug, 'excerpt' => $post->translations->first()->excerpt, 'content' => $post->translations->first()->content, ]; if ($post->meeting) { $this->getMeeting(); $this->dispatchLocationToChildren(); } $this->title = $this->post['title']; $this->content = $this->post['content']; $this->localeInit = $this->post['locale']; $this->locale = $this->post['locale']; $this->setLanguageName(); $this->categoryId = $post->category_id; // Check if category type has locale-specific suffix $this->checkAndSetLocaleFromCategory(); $this->getLocalesOptions(); $this->meetingShow = Category::where('id', $post->category_id)->where('type', Meeting::class)->exists(); // Toggle meeting section based on category type // Check if this is a Principles post to show warning in modal $this->isPrinciplesPost = $this->checkIfPrinciplesPost(); $this->from = $post->translations->first()->from; // x-date-time-picker and x-select need a separate public property, see start of this file $this->till = $post->translations->first()->till; // x-date-time-picker and x-select need a separate public property, see start of this file // Dispatch event to notify Quill editor to reload content $this->dispatch('postLoaded'); // if ($post->media->count() > 0) { // $this->media = $post->getFirstMediaUrl('posts'); // Do not use responsive media in livewire pages that have multiple update cycles as the placeholder img show after an update // } // Retrieve existing media caption $mediaItem = $post->getFirstMedia('posts'); if ($mediaItem) { $this->media = $post->getFirstMediaUrl('posts'); // Do not use responsive media in livewire pages that have multiple update cycles as the placeholder img show after an update $this->mediaOwner = $mediaItem->getCustomProperty('owner'); $this->mediaCaption = $mediaItem->getCustomProperty('caption-' . $this->locale); } else { $this->mediaOwner = null; $this->mediaCaption = null; } // Load author data if it exists if ($post->author_id) { $this->author['id'] = $post->author_id; $this->author['type'] = $post->author_model; } $this->showModal = true; } public function dispatchLocationToChildren() { $this->dispatch('countryToChildren', $this->meetingCountry); $this->dispatch('cityToChildren', $this->meetingCity); $this->dispatch('districtToChildren', $this->meetingDistrict); } public function countryToParent($value) { $this->meetingCountry = $value; } public function cityToParent($value) { $this->meetingCity = $value; } public function districtToParent($value) { $this->meetingDistrict = $value; } public function create() { // CRITICAL: Authorize admin access for creating posts $this->authorizeAdminAccess(); $this->reset(); $this->resetValidation(); $this->resetErrorBag(); $this->dispatch('showModal'); $this->showModal = true; } public function save() { // CRITICAL: Authorize admin access for saving posts $this->authorizeAdminAccess(); // Debug: Log the content being saved \Log::info('Save called with content:', ['content' => $this->content, 'length' => strlen($this->content ?? '')]); // Check if this is a Principles post for validation $this->isPrinciplesPost = $this->checkIfPrinciplesPost(); // Add translation to post if (!is_null($this->postId)) { $this->validate(); if ($this->createTranslation === true) { $post = Post::find($this->postId); $postTranslation = new PostTranslation([ 'slug' => $this->post['slug'], 'locale' => $this->locale, 'title' => $this->post['title'], 'excerpt' => $this->post['excerpt'], 'content' => $this->content, 'updated_by_user_id' => Auth::guard('web')->id(), 'from' => $this->from, 'till' => $this->till, ]); $post->translations()->save($postTranslation); // Save author data to the post $post->author_id = $this->author['id'] ?? null; $post->author_model = $this->author['type'] ?? null; $post->save(); if ($this->meetingShow) { $postMeeting = [ 'post_id' => $this->postId, 'venue' => $this->meetingVenue ?? null, 'address' => $this->meetingAddress ?? null, 'price' => $this->amount ?? null, 'based_on_quantity' => $this->basedOnQuantity ?? null, 'transaction_type_id' => $this->transactionTypeId ?? null, 'meetingable_id' => $this->organizer['id'], 'meetingable_type' => $this->organizer['type'], 'from' => $this->meetingFrom, 'till' => $this->meetingTill ]; $meeting = Meeting::updateOrCreate(['post_id' => $this->postId], $postMeeting); // Update or create the location for the meeting $location = $meeting->location; if ($location) { // Update existing location $location->update([ 'country_id' => $this->meetingCountry, 'city_id' => $this->meetingCity, 'district_id' => $this->meetingDistrict, ]); } else { // Create new location and associate with meeting $location = new Location([ 'country_id' => $this->meetingCountry, 'city_id' => $this->meetingCity, 'district_id' => $this->meetingDistrict, ]); $meeting->location()->save($location); } } $this->saveMedia($post); // WireUI notification if ($post) { $this->notification()->success( $title = __('Saved'), $description = __('Post') . ' ' . __('is saved successfully') ); } else { $this->notification()->error( $title = __('Error!'), $description = __('Oops, we have an error: the post was not saved!') ); return back(); } } else { // Update a post $this->validate(); $post = Post::find($this->postId); $postTranslation = [ 'title' => $this->post['title'], 'slug' => $this->post['slug'], 'excerpt' => $this->post['excerpt'] ?? null, 'content' => $this->content ?? null, 'updated_by_user_id' => Auth::guard('web')->id(), 'from' => $this->from ?? null, 'till' => $this->till ?? null, ]; // Update locale if it's locked by category if ($this->localeIsLocked) { $postTranslation['locale'] = $this->locale; } $post->translations()->where('id', $this->post['translation_id'])->update($postTranslation); $post->category_id = $this->categoryId; $post->postable_id = Session('activeProfileId'); $post->postable_type = Session('activeProfileType'); // Save author data to the post $post->author_id = $this->author['id'] ?? null; $post->author_model = $this->author['type'] ?? null; $post->save(); if ($this->meetingShow) { $postMeeting = [ 'post_id' => $this->postId, 'venue' => $this->meetingVenue ?? null, 'address' => $this->meetingAddress ?? null, 'price' => $this->amount ?? null, 'based_on_quantity' => $this->basedOnQuantity ?? null, 'transaction_type_id' => $this->transactionTypeId ?? null, 'meetingable_id' => $this->organizer['id'] ?? null, 'meetingable_type' => $this->organizer['type'] ?? null, 'from' => $this->meetingFrom ?? null, 'till' => $this->meetingTill ?? null ]; $meeting = Meeting::updateOrCreate(['post_id' => $this->postId], $postMeeting); // Update or create the location for the meeting $location = $meeting->location; if ($location) { // Update existing location $location->update([ 'country_id' => $this->meetingCountry, 'city_id' => $this->meetingCity, 'district_id' => $this->meetingDistrict, ]); } else { // Create new location and associate with meeting $location = new Location([ 'country_id' => $this->meetingCountry, 'city_id' => $this->meetingCity, 'district_id' => $this->meetingDistrict, ]); $meeting->location()->save($location); } } $post->save(); $post->searchable(); $this->saveMedia($post); // WireUI notification if ($post) { $this->notification()->success( $title = __('Saved'), $description = __('Post is saved successfully') ); } else { $this->notification()->error( $title = __('Error!'), $description = __('Oops, we have an error: the post was not saved!') ); return back(); } } } else { // Create a new post $this->post['translation_id'] = 0; // for unique validation on slug: do not ignore non-existing translation_id $this->validate(); if (timebank_config('posts.postable_is_auth_user')) { // Authenicated users are stored as postables $post = new Post([ 'postable_id' => Auth::guard('web')->id(), // Store creator (article writer) id 'postable_type' => get_class(Auth::guard('web')->user()), // Store creator (article writer) type. I.e. "App\Models\User" ]); } else { // Active profiles are stored as postables $post = new Post([ 'postable_id' => session('activeProfileId'), 'postable_type' => session('activeProfileType'), ]); } $post['category_id'] = $this->categoryId; $post->save(); $translation = new PostTranslation([ 'slug' => $this->post['slug'], 'locale' => $this->locale, 'title' => $this->post['title'], 'excerpt' => $this->post['excerpt'], 'content' => $this->content, 'updated_by_user_id' => Auth::guard('web')->id(), 'from' => $this->from, 'till' => $this->till, ]); $post->translations()->save($translation); // Save author data to the post $post->author_id = $this->author['id'] ?? null; $post->author_model = $this->author['type'] ?? null; $post->save(); if ($this->meetingShow) { $postMeeting = [ 'venue' => $this->meetingVenue ?? null, 'address' => $this->meetingAddress ?? null, 'price' => $this->amount ?? null, 'based_on_quantity' => $this->basedOnQuantity ?? null, 'transaction_type_id' => $this->transactionTypeId ?? null, 'meetingable_id' => $this->organizer['id'], 'meetingable_type' => $this->organizer['type'], 'from' => $this->meetingFrom, 'till' => $this->meetingTill ]; $meeting = Meeting::updateOrCreate(['post_id' => $post->id], $postMeeting); // Create new location and associate with meeting $location = new Location([ 'country_id' => $this->meetingCountry, 'city_id' => $this->meetingCity, 'district_id' => $this->meetingDistrict, ]); $meeting->location()->save($location); } $this->saveMedia($post); // WireUI notification if ($post) { $this->notification()->success( $title = __('Saved'), $description = __('Post is saved successfully!') ); } else { $this->notification()->error( $title = __('Error!'), $description = __('Oops, we have an error: the post was not saved!') ); return back(); } } $this->close(); } public function saveMedia($post) { if ($this->image) { // If a new image is uploaded $post->clearMediaCollection('posts'); $post->addMedia($this->image->getRealPath()) ->withCustomProperties([ 'owner' => $this->mediaOwner, 'caption-' . $this->locale => $this->mediaCaption, ]) ->toMediaCollection('posts'); } else { // No new image uploaded – update the caption of existing media $mediaItem = $post->getFirstMedia('posts'); if ($mediaItem) { $mediaItem->setCustomProperty('owner', $this->mediaOwner); $mediaItem->setCustomProperty('caption-' . $this->locale, $this->mediaCaption); $mediaItem->save(); } } } /** * Receives value from livewire quill-editor component * * @param mixed $value * @return void */ public function quillEditor($content = null) { $this->content = $content; } public function updatedImage() { $this->validateOnly('image'); } public function updatingImage($newValue) { // If there's no file, just return if (!$newValue) { return; } // Check extension before storing it in $this->image $ext = strtolower($newValue->getClientOriginalExtension() ?? ''); // Disallow non-image extensions if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { $this->image = null; $this->media = null; $this->addError('image', 'Unsupported file type: ' . $ext); $this->imagePreviewable = false; } else { $this->imagePreviewable = true; } } public function removeImage() { // Clear the current upload preview $this->image = null; // If editing an existing post, remove any saved media if ($this->postId) { $post = Post::find($this->postId); if ($post) { $post->clearMediaCollection('posts'); // update the preview in the modal $this->image = null; $this->media = null; } } } public function updatedBulkSelected() { if (count($this->bulkSelected) > 0) { $this->bulkDisabled = false; } else { $this->bulkDisabled = true; } // Update selectAll state based on current selection $this->updateSelectAllState(); // Notify BackupRestore component of selection change $this->dispatch('updateSelectedTranslationIds', $this->bulkSelected)->to('posts.backup-restore'); } /** * Handle select all checkbox toggle. */ public function updatedSelectAll($value) { if ($value) { // Select all translation IDs on current page $this->bulkSelected = $this->getCurrentPageTranslationIds(); } else { // Deselect all $this->bulkSelected = []; } $this->bulkDisabled = count($this->bulkSelected) === 0; // Notify BackupRestore component of selection change $this->dispatch('updateSelectedTranslationIds', $this->bulkSelected)->to('posts.backup-restore'); } /** * Get all translation IDs on the current page. */ private function getCurrentPageTranslationIds(): array { // Re-run the same query logic from render() to get current page posts $query = Post::with(['translations', 'category.translations']); if ($this->publicationStatusFilter === 'deleted') { $query->onlyTrashed()->with(['translations' => fn($q) => $q->onlyTrashed()]); } if ($this->postTypeFilter) { $query->whereHas('category', fn($q) => $q->where('type', $this->postTypeFilter)); } if ($this->categoryFilter) { $query->where('category_id', $this->categoryFilter); } if ($this->languageFilter) { $query->whereHas('translations', fn($q) => $q->where('locale', $this->languageFilter)); } if ($this->publicationStatusFilter && $this->publicationStatusFilter !== 'deleted') { $now = now(); if ($this->publicationStatusFilter === 'published') { $query->whereHas('translations', fn($q) => $q->where(function ($q) use ($now) { $q->where('from', '<=', $now)->where(fn($q) => $q->whereNull('till')->orWhere('till', '>', $now)); })); } elseif ($this->publicationStatusFilter === 'scheduled') { $query->whereHas('translations', fn($q) => $q->where('from', '>', $now)); } elseif ($this->publicationStatusFilter === 'ended') { $query->whereHas('translations', fn($q) => $q->where('till', '<=', $now)); } elseif ($this->publicationStatusFilter === 'draft') { $query->whereHas('translations', fn($q) => $q->whereNull('from')); } } if ($this->search) { $searchTerm = '%' . $this->search . '%'; $query->where(function ($q) use ($searchTerm) { $q->whereHas('translations', fn($q) => $q->where('title', 'like', $searchTerm) ->orWhere('excerpt', 'like', $searchTerm) ->orWhere('content', 'like', $searchTerm)); }); } $posts = $query->orderBy($this->sortField, $this->sortDirection) ->paginate($this->perPage); $ids = []; foreach ($posts as $post) { foreach ($post->translations as $translation) { $ids[] = (string) $translation->id; } } return $ids; } /** * Update the selectAll checkbox state based on current selection. */ private function updateSelectAllState(): void { $currentPageIds = $this->getCurrentPageTranslationIds(); if (empty($currentPageIds)) { $this->selectAll = false; return; } // Check if all items on current page are selected $allSelected = count(array_intersect($this->bulkSelected, $currentPageIds)) === count($currentPageIds); $this->selectAll = $allSelected; } public function deleteSelected() { // CRITICAL: Authorize admin access for deleting posts $this->authorizeAdminAccess(); // Get the selected translations $selectedTranslations = PostTranslation::whereIn('id', $this->bulkSelected)->get(); // Update the 'till' attribute to prevent immediate publication of restored posts $selectedTranslations->each(function ($translation) { $translation->update(['updated_by_user_id' => Auth::guard('web')->id(), 'till' => now()]); }); // Delete the selected translations PostTranslation::whereIn('id', $this->bulkSelected)->delete(); // Check if any posts have no remaining translations and if so, delete those posts $postIds = $selectedTranslations->pluck('post_id')->unique(); foreach ($postIds as $postId) { $post = Post::withTrashed()->find($postId); if ($post && $post->translations()->count() === 0) { $post->delete(); $post->searchable(); // re-index search } } $this->resetPage(); // Reset the bulk selection $this->bulkSelected = []; $this->bulkDisabled = true; $this->selectAll = false; } public function undeleteSelected() { // CRITICAL: Authorize admin access for restoring posts $this->authorizeAdminAccess(); // Get the selected translations from soft-deleted posts $selectedTranslations = PostTranslation::onlyTrashed()->whereIn('id', $this->bulkSelected)->get(); // Update the 'till' attribute for translations that have till > now() or null $selectedTranslations->each(function ($translation) { $now = now(); if (is_null($translation->till) || $translation->till > $now) { $translation->till = $now; $translation->updated_by_user_id = Auth::guard('web')->id(); $translation->save(); } }); // Restore the selected translations PostTranslation::onlyTrashed()->whereIn('id', $this->bulkSelected)->restore(); // Check if any posts need to be restored and restore them $postIds = $selectedTranslations->pluck('post_id')->unique(); foreach ($postIds as $postId) { $post = Post::onlyTrashed()->find($postId); if ($post) { $post->restore(); $post->searchable(); // re-index search } } $this->resetPage(); // Reset the bulk selection $this->bulkSelected = []; $this->bulkDisabled = true; $this->selectAll = false; } /** * Close the Edit post modal * * @return void */ public function close() { $this->showModal = false; $this->resetForm(); } public function resetForm() { $this->reset(); $this->resetValidation(); $this->resetErrorBag(); } /** * Get meeting details for the post * * @return void */ public function getMeeting() { $meeting = Meeting::where('post_id', $this->postId) ->with('location') ->first(); if ($meeting) { $this->meeting = $meeting; $this->meetingVenue = $meeting->venue; $this->meetingAddress = $meeting->address; $this->amount = $meeting->price; $this->basedOnQuantity = $meeting->based_on_quantity; $this->transactionTypeId = $meeting->transaction_type_id; // Calculate hours and minutes from amount (price in minutes) if ($meeting->price) { $this->hours = intdiv($meeting->price, 60); $this->minutes = $meeting->price % 60; } // Get the location (if any) $location = $meeting->location; $this->meetingDistrict = $location ? $location->district_id : null; $this->meetingCity = $location ? $location->city_id : null; $this->meetingCountry = $location ? $location->country_id : null; $this->meetingFrom = $meeting->from; $this->meetingTill = $meeting->till; $this->organizer['id'] = $meeting->meetingable_id; $this->organizer['type'] = $meeting->meetingable_type; if ($this->organizer['id']) { $this->dispatch('organizerExists', $meeting); } } } public function openStopPublicationModal($translationId) { $this->selectedTranslationId = $translationId; $this->modalStopPublication = true; } /** * Stop publication of the post * * @param mixed $translationId * @return void */ public function stopPublication($translationId) { // CRITICAL: Authorize admin access for unpublishing posts $this->authorizeAdminAccess(); $translation = PostTranslation::find($translationId); if ($translation) { $translation->till = now(); $translation->save(); // Get a fresh Post instance and force synchronous update $freshPost = Post::with(['translations', 'category.translations']) ->find($translation->post_id); if ($freshPost) { // Force remove from index first $freshPost->unsearchable(); // Then add back with fresh data $freshPost->searchable(); } $this->resetForm(); } $this->modalStopPublication = false; } /** * Start publication of the post * * @param mixed $translationId * @return void */ public function startPublication($translationId) { // CRITICAL: Authorize admin access for publishing posts $this->authorizeAdminAccess(); $translation = PostTranslation::find($translationId); if ($translation) { // Set the start date to now and ensure the end date is cleared. $translation->from = now(); $translation->till = null; $translation->save(); // Get a fresh Post instance and force synchronous update $freshPost = Post::with(['translations', 'category.translations']) ->find($translation->post_id); if ($freshPost) { // Force remove from index first $freshPost->unsearchable(); // Then add back with fresh data $freshPost->searchable(); } $this->resetForm(); } $this->modalStartPublication = false; } public function openStartPublicationModal($translationId) { $this->selectedTranslationId = $translationId; $this->modalStartPublication = true; } public function handleSearchEnter() { if (!$this->showModal) { $this->searchPosts(); } } public function updatedPage() { $this->dispatch('scroll-to-top'); } public function updatedPerPage($value) { $this->resetPage(); $this->resetBulkSelection(); $this->dispatch('scroll-to-top'); } public function updatingSearch() { $this->resetPage(); $this->resetBulkSelection(); } public function searchPosts() { $this->resetPage(); $this->resetBulkSelection(); $this->dispatch('scroll-to-top'); } public function resetSearch() { $this->search = ''; $this->resetPage(); $this->resetBulkSelection(); $this->dispatch('scroll-to-top'); } public function resetCategoryFilter() { $this->categoryFilter = ''; $this->resetPage(); $this->resetBulkSelection(); $this->dispatch('scroll-to-top'); } /** * Reset bulk selection state. */ private function resetBulkSelection(): void { $this->bulkSelected = []; $this->bulkDisabled = true; $this->selectAll = false; // Notify BackupRestore component of selection change $this->dispatch('updateSelectedTranslationIds', [])->to('posts.backup-restore'); } public function getCategoriesProperty() { // Load all translations so the translation accessor can fallback to base locale if needed return Category::with('translations') ->where('type', '!=', 'App\Models\Tag') ->get() ->map(function ($category) { $name = $category->translation ? $category->translation->name : __('Untitled category'); // Remove any newlines/carriage returns that could break JavaScript $name = str_replace(["\r\n", "\r", "\n"], ' ', $name); return [ 'id' => $category->id, 'name' => trim($name) ]; })->sortBy('name'); } public function resetLanguageFilter() { $this->languageFilter = ''; $this->resetPage(); $this->resetBulkSelection(); $this->dispatch('scroll-to-top'); } public function getLanguagesProperty() { return DB::table('post_translations') ->select('locale') ->distinct() ->orderBy('locale') ->get() ->map(function ($item) { return [ 'id' => $item->locale, 'name' => __('messages.' . $item->locale) // Using Laravel's language translation ]; }); } public function resetPublicationStatusFilter() { $this->publicationStatusFilter = ''; $this->resetPage(); $this->resetBulkSelection(); $this->dispatch('scroll-to-top'); } public function getPublicationStatusOptionsProperty() { return collect([ [ 'id' => 'draft', 'name' => __('Drafts') ], [ 'id' => 'scheduled', 'name' => __('Scheduled') ], [ 'id' => 'published', 'name' => __('Published') ], [ 'id' => 'due', 'name' => __('Due') ], [ 'id' => 'deleted', 'name' => __('Deleted') ] ]); } public function resetPostTypeFilter() { $this->postTypeFilter = ''; session(['posts.manage.typeFilter' => '']); $this->resetPage(); $this->resetBulkSelection(); $this->dispatch('scroll-to-top'); } public function getPostTypeOptionsProperty() { return collect([ [ 'id' => 'other', 'name' => __('Posts') ], [ 'id' => 'site_contents', 'name' => __('Site content') ], [ 'id' => 'admin_contents', 'name' => __('Admin content') ] ]); } public function sortBy(string $field): void { if ($this->sortField === $field) { $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; } else { $this->sortDirection = 'asc'; } $this->sortField = $field; $this->resetPage(); } public function render() { $posts = Post::when($this->publicationStatusFilter === 'deleted', function ($query) { // Include soft-deleted posts when showing deleted filter $query->withTrashed(); }) ->whereHas('category', function ($query) { // Always exclude Tag posts $query->where('type', '!=', 'App\Models\Tag'); }) ->with([ 'postable:id,name,email', 'category.translations' => function ($query) { // CategoryTranslation doesn't use SoftDeletes, so just filter by locale $query->where('locale', App::getLocale()); }, 'translations' => function ($query) { $query->with('updated_by_user:id,name,full_name,profile_photo_path'); // Include trashed translations when showing deleted posts if ($this->publicationStatusFilter === 'deleted') { $query->withTrashed(); } // Filter translations by language if language filter is active if ($this->languageFilter) { $query->where('locale', $this->languageFilter); } }, ]) ->when($this->search, function ($query) { $query->where(function ($query) { // For deleted posts, search in trashed translations too if ($this->publicationStatusFilter === 'deleted') { $query->whereHas('translations', function ($query) { $query->withTrashed() ->where(function ($query) { $query->where('title', 'like', '%' . $this->search . '%') ->orWhere('content', 'like', '%' . $this->search . '%'); }); }) ->orWhereHas('category.translations', function ($query) { $query->where('name', 'like', '%' . $this->search . '%'); }) ->orWhereHas('translations.updated_by_user', function ($query) { $query->withTrashed()->where('name', 'like', '%' . $this->search . '%'); }) ->orWhere('id', $this->search); } else { $query->whereHas('translations', function ($query) { $query ->where(function ($query) { $query->where('title', 'like', '%' . $this->search . '%') ->orWhere('content', 'like', '%' . $this->search . '%'); }); }) ->orWhereHas('category.translations', function ($query) { $query ->where('name', 'like', '%' . $this->search . '%'); }) ->orWhereHas('translations.updated_by_user', function ($query) { $query->where('name', 'like', '%' . $this->search . '%'); }) ->orWhere('id', $this->search); } }); }) ->when($this->postTypeFilter, function ($query) { $query->whereHas('category', function ($q) { if ($this->postTypeFilter === 'site_contents') { // Show only SiteContents posts (excluding Manage) $q->where('type', 'like', 'SiteContents\\\\%') ->where('type', 'not like', 'SiteContents\\\\Manage\\\\%'); } elseif ($this->postTypeFilter === 'admin_contents') { // Show only SiteContents\Manage posts $q->where('type', 'like', 'SiteContents\\\\Manage\\\\%'); } elseif ($this->postTypeFilter === 'other') { // Show other posts (excluding SiteContents and Tag) $q->where('type', 'not like', 'SiteContents\\\\%') ->where('type', '!=', 'App\Models\Tag'); } }); }) ->when($this->categoryFilter, function ($query) { $query->where('category_id', $this->categoryFilter); }) ->when($this->languageFilter, function ($query) { if ($this->publicationStatusFilter === 'deleted') { $query->whereHas('translations', function ($query) { $query->withTrashed()->where('locale', $this->languageFilter); }); } else { $query->whereHas('translations', function ($query) { $query->where('locale', $this->languageFilter); }); } }) ->when($this->publicationStatusFilter, function ($query) { $now = now(); switch ($this->publicationStatusFilter) { case 'draft': $query->whereHas('translations', function ($query) { $query->whereNull('from'); }); break; case 'scheduled': $query->whereHas('translations', function ($query) use ($now) { $query->where('from', '>', $now); }); break; case 'published': $query->whereHas('translations', function ($query) use ($now) { $query->where('from', '<', $now) ->where(function ($query) use ($now) { $query->where('till', '>', $now) ->orWhereNull('till'); }); }); break; case 'due': $query->whereHas('translations', function ($query) use ($now) { $query->whereNotNull('till')->where('till', '<', $now); }); break; case 'deleted': $query->where(function ($query) { // Posts that are soft-deleted themselves $query->whereNotNull('posts.deleted_at') // OR posts that have soft-deleted translations ->orWhereHas('translations', function ($subQuery) { $subQuery->onlyTrashed(); }); }); break; } }); // Apply sorting based on selected field switch ($this->sortField) { case 'id': $posts->orderBy('posts.id', $this->sortDirection); break; case 'category_id': $posts->join('categories', 'posts.category_id', '=', 'categories.id') ->leftJoin('category_translations', function ($join) { $join->on('categories.id', '=', 'category_translations.category_id') ->where('category_translations.locale', App::getLocale()); }) ->orderBy('category_translations.name', $this->sortDirection) ->select('posts.*'); break; case 'locale': case 'title': case 'from': case 'till': case 'deleted_at': // For translation fields, we need to join and order by the translation table $posts->join('post_translations', 'posts.id', '=', 'post_translations.post_id') ->when($this->publicationStatusFilter === 'deleted', function ($query) { $query->withTrashed(); }) ->when($this->languageFilter, function ($query) { $query->where('post_translations.locale', $this->languageFilter); }) ->orderBy('post_translations.' . $this->sortField, $this->sortDirection) ->select('posts.*') ->distinct(); break; case 'updated_at': default: $posts->orderBy('posts.updated_at', $this->sortDirection); break; } $posts = $posts->paginate($this->perPage); return view('livewire.posts.manage', [ 'posts' => $posts ]); } /** * Gets the label for the "title" input field, including a character counter if applicable. * The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`. * * @return string The label for the "about" field, optionally including the remaining character count. */ public function getTitleLabelProperty() { $maxInput = timebank_config('posts.title_max_input'); $baseLabel = __('Title') . ' '; $counter = $this->characterLeftCounter($this->post['title'] ?? '', $maxInput); return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel; } /** * Gets the label for the "Intro" input field, including a character counter if applicable. * The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`. * * @return string The label for the "about" field, optionally including the remaining character count. */ public function getIntroLabelProperty() { $maxInput = timebank_config('posts.excerpt_max_input', 500); $baseLabel = __('Intro') . ' '; $counter = $this->characterLeftCounter($this->post['excerpt'] ?? '', $maxInput); return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel; } /** * Gets the label for the "Content" input field, including a character counter if applicable. * The character counter uses the `characterLeftCounterWithoutHtml` method from the `FormHelpersTrait` * to count only visible text characters (HTML tags are stripped before counting). * * @return string The label for the "Content" field, optionally including the remaining character count. */ public function getContentLabelProperty() { $maxInput = timebank_config('posts.content_max_input', 1000); $baseLabel = __('Content') . ' '; $counter = $this->characterLeftCounterWithoutHtml($this->content ?? '', $maxInput); return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel; } /** * Gets the label for the "Media caption" input field, including a character counter if applicable. * The character counter needs to use the `characterLeftCounter` method from the `FormHelpersTrait`. * * @return string The label for the "about" field, optionally including the remaining character count. */ public function getMediaCaptionLabelProperty() { $maxInput = timebank_config('posts.media_caption_max_input', 300); $baseLabel = __('Image caption') . ' '; $counter = $this->characterLeftCounter($this->mediaCaption ?? '', $maxInput); return $counter ? $baseLabel . ' (' . $counter . ')' : $baseLabel; } /** * Get transaction types for dropdown * * @return array */ public function getTransactionTypesProperty() { $allowedTypeIds = timebank_config('posts.meeting_transaction_types', []); return TransactionType::whereIn('id', $allowedTypeIds)->get()->map(function ($type) { return [ 'id' => $type->id, 'name' => ucfirst(__('messages.posts.transaction_types.' . strtolower($type->name))) ]; })->toArray(); } /** * Get cached localized URLs for view post buttons. * Pre-computes URLs for all locales to avoid repeated getLocalizedURL() calls in blade. * * @return array */ public function getLocalizedUrlsProperty() { $locales = array_keys(config('laravellocalization.supportedLocales', [])); $urls = []; foreach ($locales as $locale) { $urls[$locale] = [ 'pay' => \LaravelLocalization::getLocalizedURL($locale, route('pay', [], false)), 'transactions' => \LaravelLocalization::getLocalizedURL($locale, route('transactions', [], false)), 'account_usage' => \LaravelLocalization::getLocalizedURL($locale, route('transactions', ['openAccountUsageInfo' => 'true'], false)), 'search_info' => \LaravelLocalization::getLocalizedURL($locale, route('search.show', ['openSearchInfo' => 'true'], false)), 'reports' => \LaravelLocalization::getLocalizedURL($locale, route('reports', [], false)), 'events' => \LaravelLocalization::getLocalizedURL($locale, route('static-events', [], false)), 'principles' => \LaravelLocalization::getLocalizedURL($locale, route('static-principles', [], false)), ]; } return $urls; } /** * Get localized URL for a post based on its category type. * * @param string $locale * @param string|null $categoryType * @param int $postId * @return string|null */ public function getPostViewUrl($locale, $categoryType, $postId) { $urls = $this->localizedUrls; if (!isset($urls[$locale])) { return null; } if ($categoryType && str_starts_with($categoryType, 'SiteContents\Pay')) { return $urls[$locale]['pay']; } elseif ($categoryType && str_starts_with($categoryType, 'SiteContents\Transactions')) { return $urls[$locale]['transactions']; } elseif ($categoryType && str_starts_with($categoryType, 'SiteContents\AccountUsage')) { return $urls[$locale]['account_usage']; } elseif ($categoryType && str_starts_with($categoryType, 'SiteContents\Search\Info')) { return $urls[$locale]['search_info']; } elseif ($categoryType && str_starts_with($categoryType, 'SiteContents\Manage\Reports')) { return $urls[$locale]['reports']; } elseif ($categoryType && str_starts_with($categoryType, 'SiteContents\Static\Events')) { return $urls[$locale]['events']; } elseif ($categoryType && str_starts_with($categoryType, 'SiteContents\Static\Principles')) { return $urls[$locale]['principles']; } else { // Generic post URL return \LaravelLocalization::getLocalizedURL( $locale, str_replace('{id}', $postId, trans('routes.post.show', [], $locale)) ); } } }