Files
timebank-cc-public/app/Http/Livewire/Posts/Manage.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

1685 lines
61 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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))
);
}
}
}