1685 lines
61 KiB
PHP
1685 lines
61 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Livewire\Posts;
|
||
|
||
use App\Models\Category;
|
||
use App\Models\Locations\Location;
|
||
use App\Models\Meeting;
|
||
use App\Models\Post;
|
||
use App\Models\PostTranslation;
|
||
use App\Models\TransactionType;
|
||
use App\Rules\MaxLengthWithoutHtml;
|
||
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
|
||
use App\Traits\FormHelpersTrait;
|
||
use Cviebrock\EloquentSluggable\Services\SlugService;
|
||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||
use Illuminate\Support\Facades\App;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Validation\Rule;
|
||
use Livewire\Component;
|
||
use Livewire\WithFileUploads;
|
||
use Livewire\WithPagination;
|
||
use WireUi\Traits\WireUiActions;
|
||
|
||
class Manage extends Component
|
||
{
|
||
use WithPagination;
|
||
use WithFileUploads;
|
||
use WireUiActions;
|
||
use HandlesAuthorization;
|
||
use FormHelpersTrait;
|
||
use RequiresAdminAuthorization;
|
||
|
||
public $search;
|
||
public $postTypeFilter = '';
|
||
public $categoryFilter = '';
|
||
public $languageFilter = '';
|
||
public $publicationStatusFilter = '';
|
||
public bool $showModal = false;
|
||
public $createTranslation;
|
||
public $postId;
|
||
public $bulkSelected = [];
|
||
public bool $bulkDisabled = true;
|
||
public bool $selectAll = false;
|
||
public $categoryId;
|
||
public $post = ['title' => '', '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))
|
||
);
|
||
}
|
||
}
|
||
}
|