Files
timebank-cc-public/app/Http/Livewire/Posts/Manage.php
T

1998 lines
73 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\Poll;
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\Gate;
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 bool $showBulkPrintModal = false;
public int $bulkPrintPerPage = 1;
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 array $principlesMissingLocales = [];
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', '');
}
// Message from the poll manager after editing a poll opened from this table
if ($notification = session('posts.manage.notification')) {
$this->notification()->send($notification);
}
}
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.'));
}
}
},
],
];
}
/**
* Compute which supported locales are missing translations for the current post.
* An empty array means all translations exist and the start-date may be set.
*/
protected function computePrinciplesMissingLocales(): void
{
if (!$this->isPrinciplesPost) {
$this->principlesMissingLocales = [];
return;
}
$supported = array_keys(config('laravellocalization.supportedLocales', []));
if (!$this->postId) {
// New post: only the locale being created is considered "present"
$existing = $this->locale ? [$this->locale] : [];
} else {
// Existing post: fetch saved translations from DB
$existing = Post::find($this->postId)
?->translations()
->pluck('locale')
->toArray() ?? [];
// When adding a new translation (not yet saved), count the current locale
// as present — once saved it will exist, so publication becomes possible now
if ($this->createTranslation && $this->locale && !in_array($this->locale, $existing)) {
$existing[] = $this->locale;
}
}
$this->principlesMissingLocales = array_values(array_diff($supported, $existing));
}
/**
* 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();
$this->computePrinciplesMissingLocales();
}
/**
* 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();
$this->computePrinciplesMissingLocales();
}
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();
$postId = PostTranslation::find($translationId)?->post_id;
if ($postId && $this->pollContentLocked($postId)) {
return;
}
if ($postId && Post::find($postId)?->is_locked) {
$this->notification()->error(
__('Locked'),
__('This post is locked and cannot be edited. Create a new post to update the principles.')
);
return;
}
// 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->computePrinciplesMissingLocales();
$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();
$this->authorizePermission(is_null($this->postId) ? 'create posts' : 'update posts');
// The post of an opened poll is read-only (edited, if at all, on the Polls page)
if (!is_null($this->postId) && $this->pollContentLocked($this->postId)) {
return;
}
// 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();
$this->computePrinciplesMissingLocales();
// Principles posts must not have an expiry date
if ($this->isPrinciplesPost) {
$this->till = null;
}
// Principles posts may not have a start date until all translations exist
if ($this->isPrinciplesPost && !empty($this->principlesMissingLocales)) {
$this->from = null;
}
// 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 ?: null,
'till' => $this->till ?: null,
]);
$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 ?: 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);
}
}
$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 ?: null,
'till' => $this->till ?: null,
]);
$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 ?: null,
'till' => $this->meetingTill ?: null
];
$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();
}
}
// A post in a poll category gets a draft poll, configured on the Polls page
if (isset($post) && $post instanceof Post) {
$this->ensureDraftPoll($post);
}
// If a Principles post was saved with a from-date, sync it to all translations,
// lock the post, and stop older Principles posts
if ($this->isPrinciplesPost && $this->postId && $this->from) {
$this->syncPrinciplesFromDate($this->postId, $this->from);
$this->lockAndActivatePrinciplesPost($this->postId);
}
$this->close();
}
/**
* Whether the post belongs to a poll whose content is read-only (see Poll::isContentLocked);
* shows why when it is.
*/
private function pollContentLocked(int $postId): bool
{
$poll = Poll::where('post_id', $postId)->first();
if (!$poll || !$poll->isContentLocked()) {
return false;
}
$this->notification()->error(
__('Read-only'),
__('This post belongs to a poll that is no longer a draft, so its content cannot be changed. Settings of the poll can be changed on the Polls page.')
);
return true;
}
/**
* Polls are managed on the Polls page (security audit R1): the Posts page may not delete,
* or start or stop the publication of, the post of a poll that has been opened or has
* ballots. A draft's post may be deleted only with 'delete polls' as well.
*/
private function pollPostBlocked(int $postId, bool $deleting = false): bool
{
$poll = Poll::where('post_id', $postId)->first();
if (!$poll) {
return false;
}
if ($poll->status !== Poll::STATUS_DRAFT || $poll->ledger()->exists()) {
return true;
}
return $deleting && !Gate::allows('delete polls');
}
private function ensureDraftPoll(Post $post): void
{
if (!Category::where('id', $post->category_id)->where('type', Poll::class)->exists()) {
return;
}
Poll::firstOrCreate(['post_id' => $post->id], [
'pollable_id' => session('activeProfileId'),
'pollable_type' => session('activeProfileType'),
'status' => Poll::STATUS_DRAFT,
'anonymous' => true, // new polls start anonymous
]);
}
protected function syncPrinciplesFromDate(int $postId, string $from): void
{
Post::find($postId)?->translations()->update(['from' => $from, 'till' => null]);
}
protected function lockAndActivatePrinciplesPost(int $postId): void
{
$post = Post::find($postId);
if (!$post) {
return;
}
$post->is_locked = true;
$post->save();
// Stop all other Principles posts
Post::whereHas('category', fn($q) => $q->where('type', 'SiteContents\Static\Principles'))
->where('id', '!=', $postId)
->each(function ($oldPost) {
$oldPost->translations()->update(['till' => now()]);
$oldPost->unsearchable();
});
}
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 confirmBulkPrint(): void
{
$this->showBulkPrintModal = true;
}
public function executeBulkPrint(): void
{
$ids = implode(',', array_map('intval', $this->bulkSelected));
$url = route('post.pdf-bulk', [
'ids' => $ids,
'per_page' => $this->bulkPrintPerPage,
]);
$this->showBulkPrintModal = false;
$this->dispatch('open-bulk-pdf', url: $url);
}
public function deleteSelected()
{
// CRITICAL: Authorize admin access for deleting posts
$this->authorizeAdminAccess();
$this->authorizePermission('delete posts');
// Get the selected translations, excluding any that belong to a locked post
$allSelected = PostTranslation::whereIn('id', $this->bulkSelected)->get();
$lockedCount = 0;
$pollCount = 0;
$selectedTranslations = $allSelected->filter(function ($translation) use (&$lockedCount, &$pollCount) {
$post = Post::find($translation->post_id);
if ($post?->is_locked) {
$lockedCount++;
return false;
}
if ($this->pollPostBlocked($translation->post_id, deleting: true)) {
$pollCount++;
return false;
}
return true;
});
if ($pollCount > 0) {
$this->notification()->warning(
__('Polls skipped'),
__(':count translation(s) belong to polls and were not deleted. Polls are managed on the Polls page.', ['count' => $pollCount])
);
}
if ($lockedCount > 0) {
$this->notification()->warning(
__('Locked posts skipped'),
__(':count translation(s) belonging to locked Principles posts were not deleted.', ['count' => $lockedCount])
);
}
if ($selectedTranslations->isEmpty()) {
$this->resetBulkSelection();
return;
}
// 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
$allowedIds = $selectedTranslations->pluck('id')->toArray();
PostTranslation::whereIn('id', $allowedIds)->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();
$this->authorizePermission('update posts');
// 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)
{
$translation = PostTranslation::find($translationId);
if ($translation && Post::find($translation->post_id)?->is_locked) {
$this->notification()->error(
__('Locked'),
__('Publication of a locked Principles post cannot be stopped.')
);
return;
}
$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();
$this->authorizePermission('unpublish posts');
$translation = PostTranslation::find($translationId);
if ($translation) {
$post = Post::find($translation->post_id);
if ($post?->is_locked) {
$this->notification()->error(
__('Locked'),
__('Publication of a locked Principles post cannot be stopped.')
);
$this->modalStopPublication = false;
return;
}
if ($this->pollPostBlocked($translation->post_id)) {
$this->notification()->error(__('Poll'), __('The publication of a poll is managed on the Polls page.'));
$this->modalStopPublication = false;
return;
}
}
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();
$this->authorizePermission('publish posts');
$translation = PostTranslation::find($translationId);
if ($translation && $this->pollPostBlocked($translation->post_id)) {
$this->notification()->error(__('Poll'), __('The publication of a poll is managed on the Polls page.'));
$this->modalStartPublication = false;
return;
}
if ($translation) {
$post = Post::with(['translations', 'category'])->find($translation->post_id);
$isPrinciples = $post?->category?->type === 'SiteContents\Static\Principles';
if ($isPrinciples) {
// Warn about missing translations before publishing
$supportedLocales = array_keys(config('laravellocalization.supportedLocales', []));
$existingLocales = $post->translations->pluck('locale')->toArray();
$missingLocales = array_diff($supportedLocales, $existingLocales);
if (!empty($missingLocales)) {
$this->notification()->warning(
__('Missing translations'),
__('Publishing without translations for: ') . implode(', ', array_values($missingLocales))
);
}
// Auto-publish all translations of this principles post
$post->translations()->update(['from' => now(), 'till' => null]);
// Lock the post so it cannot be edited again
$post->is_locked = true;
$post->save();
// Stop publication of all other Principles posts
Post::with('translations')
->whereHas('category', fn($q) => $q->where('type', 'SiteContents\Static\Principles'))
->where('id', '!=', $post->id)
->each(function ($oldPost) {
$oldPost->translations()->update(['till' => now()]);
$oldPost->unsearchable();
});
} else {
// Normal post: publish only this translation
$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) {
$freshPost->unsearchable();
$freshPost->searchable();
}
$this->resetForm();
}
$this->modalStartPublication = false;
}
public function openStartPublicationModal($translationId)
{
$translation = PostTranslation::find($translationId);
if ($translation) {
$post = Post::with('translations')->find($translation->post_id);
if ($post?->category?->type === 'SiteContents\Static\Principles') {
$supported = array_keys(config('laravellocalization.supportedLocales', []));
$existing = $post->translations->pluck('locale')->toArray();
$missing = array_values(array_diff($supported, $existing));
if (!empty($missing)) {
$this->notification()->error(
__('Missing translations'),
__('Cannot publish: missing translations for') . ' ' . implode(', ', $missing)
);
return;
}
}
}
$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()
{
$locale = app()->getLocale();
$fallback = config('app.fallback_locale');
return Category::with(['translations' => fn($q) => $q->whereIn('locale', [$locale, $fallback])])
->where('type', '!=', 'App\Models\Tag')
->get()
->map(function ($category) use ($locale, $fallback) {
$translation = $category->translations->firstWhere('locale', $locale)
?? $category->translations->firstWhere('locale', $fallback)
?? $category->translations->first();
$name = $translation ? $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',
'poll:id,post_id,status',
'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);
// For Principles posts: compute which locales are missing per post so the
// table can disable the start-publication button when translations are incomplete.
$supportedLocales = array_keys(config('laravellocalization.supportedLocales', []));
$principlesMissingLocalesByPost = [];
foreach ($posts as $post) {
if ($post->category?->type === 'SiteContents\Static\Principles') {
$existing = $post->translations->pluck('locale')->toArray();
$missing = array_values(array_diff($supportedLocales, $existing));
$principlesMissingLocalesByPost[$post->id] = $missing;
}
}
return view('livewire.posts.manage', [
'posts' => $posts,
'principlesMissingLocalesByPost' => $principlesMissingLocalesByPost,
]);
}
/**
* 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))
);
}
}
}