Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Language;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTranslation;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
|
||||
public function showById($id)
|
||||
{
|
||||
$post =
|
||||
Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) {
|
||||
$query->with('translations');
|
||||
},
|
||||
'translations' => function ($query) {
|
||||
$query
|
||||
->where('locale', App::getLocale());
|
||||
// ->whereDate('from', '<=', now()) //TODO: Exclude date queries ONLY for post Admins!
|
||||
// ->where( function($query) {
|
||||
// $query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
// })
|
||||
},
|
||||
])
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
// Poll posts have their own page
|
||||
if (\App\Models\Poll::where('post_id', $post->id)->exists()) {
|
||||
$slug = $post->translations->first()?->slug ?? PostTranslation::where('post_id', $post->id)->value('slug');
|
||||
if ($slug) {
|
||||
return redirect()->route('poll.show', ['slug' => $slug]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($post->media) {
|
||||
$media = Post::find($id)->getFirstMedia('posts');
|
||||
}
|
||||
|
||||
if ($post->translations->count() >= 1) {
|
||||
|
||||
if ($post->category->translations) {
|
||||
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
|
||||
}
|
||||
|
||||
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at), config('app.timezone'))->isoFormat('LL');
|
||||
|
||||
} else {
|
||||
// No translation in current locale - check for fallback translations
|
||||
return $this->handleNoTranslation($post, $id);
|
||||
}
|
||||
|
||||
// Use guest layout for non-authenticated users, app layout for authenticated users
|
||||
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
|
||||
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle case when no translation exists for current locale.
|
||||
* Check for available translations in other locales and offer fallback.
|
||||
*/
|
||||
private function handleNoTranslation($post, $postId)
|
||||
{
|
||||
// Get all available translations for this post (active ones)
|
||||
$availableTranslation = PostTranslation::where('post_id', $postId)
|
||||
->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
})
|
||||
->first();
|
||||
|
||||
$viewData = [
|
||||
'fallbackLocale' => null,
|
||||
'fallbackLanguageName' => null,
|
||||
'fallbackUrl' => null,
|
||||
'post' => $post,
|
||||
];
|
||||
|
||||
if ($availableTranslation) {
|
||||
// Found an available translation in another locale
|
||||
$fallbackLocale = $availableTranslation->locale;
|
||||
$fallbackLanguageName = Language::where('lang_code', $fallbackLocale)->first()->name ?? $fallbackLocale;
|
||||
|
||||
// Generate URL to the same post in the fallback locale
|
||||
$fallbackUrl = \LaravelLocalization::getLocalizedURL($fallbackLocale, route('post.show', ['id' => $postId]));
|
||||
|
||||
$viewData['fallbackLocale'] = $fallbackLocale;
|
||||
$viewData['fallbackLanguageName'] = __($fallbackLanguageName);
|
||||
$viewData['fallbackUrl'] = $fallbackUrl;
|
||||
}
|
||||
|
||||
// Use guest layout for non-authenticated users, app layout for authenticated users
|
||||
$viewName = Auth::check() ? 'posts.no_translation' : 'posts.no_translation-guest';
|
||||
return view($viewName, $viewData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show view by .../posts/{slug}.
|
||||
* Post will be shown in user's App:locale() language if available, even is the slug is in another language.
|
||||
*
|
||||
* @param mixed $slug
|
||||
* @return void
|
||||
*/
|
||||
public function showBySlug($slug)
|
||||
{
|
||||
$postTranslation = PostTranslation::where('slug', $slug)->get()->first();
|
||||
if (!$postTranslation) {
|
||||
return view('posts.not_found');
|
||||
}
|
||||
$id = $postTranslation->post_id;
|
||||
$locale = $postTranslation->locale;
|
||||
|
||||
// Poll posts have their own page
|
||||
if (\App\Models\Poll::where('post_id', $id)->exists()) {
|
||||
return redirect()->route('poll.show', ['slug' => $slug]);
|
||||
}
|
||||
|
||||
$post = Post::with([
|
||||
'postable' => function ($query) {
|
||||
$query->select(['id', 'name']);
|
||||
},
|
||||
'category' => function ($query) {
|
||||
$query->with('translations');
|
||||
},
|
||||
'meeting',
|
||||
'translations' => function ($query) {
|
||||
//TODO!: Currently only user 1 (Super-admin) can view unpublished posts, change to permission/role based!
|
||||
$query->where('locale', App::getLocale());
|
||||
// Only show published posts for guests and non-admin users
|
||||
if (!Auth::guard('web')->check() || Auth::guard('web')->user()->id != 1) {
|
||||
$query->whereDate('from', '<=', now())
|
||||
->where(function ($query) {
|
||||
$query->whereDate('till', '>', now())->orWhereNull('till');
|
||||
});
|
||||
}
|
||||
}
|
||||
])
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
|
||||
if ($post->media) {
|
||||
$media = Post::find($id)->getFirstMedia('posts');
|
||||
}
|
||||
|
||||
|
||||
if ($post->translations->count() >= 1) {
|
||||
if ($post->category->translations) {
|
||||
$category = $post->category->translations->where('locale', App::getLocale())->first()->name;
|
||||
}
|
||||
|
||||
$update = Carbon::createFromTimeStamp(strtotime($post->translations->first()->updated_at), config('app.timezone'))->isoFormat('LL');
|
||||
|
||||
} else {
|
||||
// No translation in current locale - check for fallback translations
|
||||
return $this->handleNoTranslation($post, $id);
|
||||
}
|
||||
|
||||
// Use guest layout for non-authenticated users, app layout for authenticated users
|
||||
$viewName = Auth::check() ? 'posts.show' : 'posts.show-guest';
|
||||
return ($post != null ? view($viewName, compact(['post','media','category','update'])) : abort(403));
|
||||
|
||||
}
|
||||
|
||||
|
||||
public function manage()
|
||||
{
|
||||
if (getActiveProfileType() !== 'Admin') {
|
||||
abort(403, __('Admin profile required'));
|
||||
}
|
||||
|
||||
return view('posts.manage');
|
||||
}
|
||||
|
||||
|
||||
public function postPdf($id)
|
||||
{
|
||||
$data = $this->buildPostPdfData($id);
|
||||
$pdf = Pdf::loadView('posts.pdf', $data)
|
||||
->setPaper('A4', 'portrait')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
]);
|
||||
return $pdf->download('post-' . $data['post']->id . '.pdf');
|
||||
}
|
||||
|
||||
public function postPdfBulk(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$ids = array_filter(array_map('intval', explode(',', $request->input('ids', ''))));
|
||||
$perPage = in_array((int) $request->input('per_page', 1), [1, 2, 4])
|
||||
? (int) $request->input('per_page', 1)
|
||||
: 1;
|
||||
if (empty($ids)) abort(400);
|
||||
|
||||
$activeProfileType = session('activeProfileType');
|
||||
$activeProfileId = session('activeProfileId');
|
||||
$isAdmin = $activeProfileType === \App\Models\Admin::class
|
||||
|| ($activeProfileType === \App\Models\Bank::class
|
||||
&& \App\Models\Bank::find($activeProfileId)?->level === 0);
|
||||
|
||||
if (!$isAdmin) abort(403);
|
||||
|
||||
$posts = [];
|
||||
foreach ($ids as $translationId) {
|
||||
$translation = PostTranslation::withTrashed()->find($translationId);
|
||||
if (!$translation) continue;
|
||||
|
||||
$locale = $translation->locale ?? App::getLocale();
|
||||
$posts[] = $this->buildPostPdfDataForLocale($translation->post_id, $locale);
|
||||
}
|
||||
|
||||
if (empty($posts)) abort(403);
|
||||
|
||||
$view = 'posts.pdf-bulk';
|
||||
$orientation = $perPage === 2 ? 'landscape' : 'portrait';
|
||||
|
||||
$pdf = Pdf::loadView($view, compact('posts', 'perPage'))
|
||||
->setPaper('A4', $orientation)
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => false,
|
||||
'defaultFont' => 'DejaVu Sans',
|
||||
'dpi' => 150,
|
||||
'chroot' => public_path(),
|
||||
'margin_top' => 0,
|
||||
'margin_right' => 0,
|
||||
'margin_bottom' => 0,
|
||||
'margin_left' => 0,
|
||||
]);
|
||||
|
||||
return $pdf->download('posts-bulk-' . now()->format('Ymd-His') . '.pdf');
|
||||
}
|
||||
|
||||
private function buildPostPdfDataForLocale(int $postId, string $locale): array
|
||||
{
|
||||
$previous = App::getLocale();
|
||||
App::setLocale($locale);
|
||||
try {
|
||||
return $this->buildPostPdfData($postId);
|
||||
} finally {
|
||||
App::setLocale($previous);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPostPdfData($id): array
|
||||
{
|
||||
$locale = App::getLocale();
|
||||
|
||||
$post = Post::with([
|
||||
'author',
|
||||
'category.translations',
|
||||
'translations' => function ($query) use ($locale) {
|
||||
$query->where('locale', $locale);
|
||||
},
|
||||
'meeting.meetingable',
|
||||
'meeting.location.city.translations',
|
||||
])->findOrFail($id);
|
||||
|
||||
// Polls are for logged-in profiles only
|
||||
abort_if($post->isPoll() && !Auth::guard('web')->check(), 404);
|
||||
|
||||
$translation = $post->translations->first();
|
||||
|
||||
$categoryName = $post->category?->translations->firstWhere('locale', $locale)?->name
|
||||
?? $post->category?->translations->first()?->name;
|
||||
|
||||
$categoryType = $post->category?->type ?? '';
|
||||
$isEvent = $categoryType === 'App\Models\Meeting';
|
||||
$isArticle = $categoryType === 'App\Models\Article';
|
||||
$isNews = $categoryType === 'App\Models\News';
|
||||
$isImagePost = str_starts_with($categoryType, 'App\Models\ImagePost');
|
||||
|
||||
$update = $translation
|
||||
? Carbon::parse($translation->updated_at)->isoFormat('LL')
|
||||
: null;
|
||||
|
||||
// Hero image as base64 for DomPDF (GD for webp, direct read for others)
|
||||
$heroBase64 = null;
|
||||
$heroMedia = $post->getFirstMedia('posts');
|
||||
if ($heroMedia) {
|
||||
$heroPath = $heroMedia->getPath();
|
||||
if (file_exists($heroPath)) {
|
||||
$raw = @file_get_contents($heroPath);
|
||||
if ($raw) {
|
||||
$src = @imagecreatefromstring($raw);
|
||||
if ($src) {
|
||||
// Crop to A4 portrait aspect ratio (cover behaviour)
|
||||
$srcW = imagesx($src);
|
||||
$srcH = imagesy($src);
|
||||
$dstW = 1240; // 210mm @ 150dpi
|
||||
$dstH = 1754; // 297mm @ 150dpi
|
||||
$scale = max($dstW / $srcW, $dstH / $srcH);
|
||||
$scaledW = (int) round($srcW * $scale);
|
||||
$scaledH = (int) round($srcH * $scale);
|
||||
$cropX = (int) round(($scaledW - $dstW) / 2);
|
||||
$cropY = (int) round(($scaledH - $dstH) / 2);
|
||||
$dst = imagecreatetruecolor($dstW, $dstH);
|
||||
imagecopyresampled($dst, $src, 0, 0, (int) round($cropX / $scale), (int) round($cropY / $scale), $dstW, $dstH, (int) round($dstW / $scale), (int) round($dstH / $scale));
|
||||
// Boost saturation +50%: blend color image with its grayscale at factor > 1
|
||||
// output = gray + (color - gray) * 1.5 → clamp to 0-255
|
||||
$gray = imagecreatetruecolor($dstW, $dstH);
|
||||
imagecopy($gray, $dst, 0, 0, 0, 0, $dstW, $dstH);
|
||||
imagefilter($gray, IMG_FILTER_GRAYSCALE);
|
||||
for ($px = 0; $px < $dstW; $px++) {
|
||||
for ($py = 0; $py < $dstH; $py++) {
|
||||
$c = imagecolorat($dst, $px, $py);
|
||||
$g = imagecolorat($gray, $px, $py);
|
||||
$gr = ($g >> 16) & 0xFF;
|
||||
$nr = max(0, min(255, (int) round($gr + ((($c >> 16) & 0xFF) - $gr) * 1.75)));
|
||||
$ng = max(0, min(255, (int) round($gr + ((($c >> 8) & 0xFF) - $gr) * 1.75)));
|
||||
$nb = max(0, min(255, (int) round($gr + (($c & 0xFF) - $gr) * 1.75)));
|
||||
imagesetpixel($dst, $px, $py, ($nr << 16) | ($ng << 8) | $nb);
|
||||
}
|
||||
}
|
||||
imagedestroy($gray);
|
||||
ob_start();
|
||||
imagejpeg($dst, null, 85);
|
||||
$heroBase64 = 'data:image/jpeg;base64,' . base64_encode(ob_get_clean());
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Author avatar as base64 (SVG skipped — GD cannot render SVG)
|
||||
$avatarBase64 = null;
|
||||
$author = $post->author;
|
||||
if ($author && $author->profile_photo_path) {
|
||||
$avatarPath = storage_path('app/public/' . $author->profile_photo_path);
|
||||
$ext = strtolower(pathinfo($avatarPath, PATHINFO_EXTENSION));
|
||||
if (file_exists($avatarPath) && $ext !== 'svg') {
|
||||
$mime = mime_content_type($avatarPath) ?: 'image/jpeg';
|
||||
$avatarBase64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($avatarPath));
|
||||
}
|
||||
}
|
||||
|
||||
$brandColor = theme_color('brand') ?: '#000000';
|
||||
|
||||
// Platform logo
|
||||
$logoBase64 = null;
|
||||
$logoPath = theme_email_logo_file();
|
||||
if (file_exists($logoPath)) {
|
||||
$logoBase64 = 'data:image/png;base64,' . base64_encode(file_get_contents($logoPath));
|
||||
}
|
||||
|
||||
// Hero logo: invert RGB to white while preserving alpha transparency
|
||||
$logoWhiteBase64 = null;
|
||||
if ($logoPath && file_exists($logoPath)) {
|
||||
$src = @imagecreatefrompng($logoPath);
|
||||
if ($src) {
|
||||
$w = imagesx($src);
|
||||
$h = imagesy($src);
|
||||
$dst = imagecreatetruecolor($w, $h);
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
// Fill with transparent background
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefill($dst, 0, 0, $transparent);
|
||||
// Copy pixel by pixel, inverting RGB but keeping alpha
|
||||
for ($x = 0; $x < $w; $x++) {
|
||||
for ($y = 0; $y < $h; $y++) {
|
||||
$rgba = imagecolorat($src, $x, $y);
|
||||
$alpha = ($rgba >> 24) & 0x7F;
|
||||
$r = 255 - (($rgba >> 16) & 0xFF);
|
||||
$g = 255 - (($rgba >> 8) & 0xFF);
|
||||
$b = 255 - ($rgba & 0xFF);
|
||||
imagesetpixel($dst, $x, $y, imagecolorallocatealpha($dst, $r, $g, $b, $alpha));
|
||||
}
|
||||
}
|
||||
ob_start();
|
||||
imagepng($dst);
|
||||
$logoWhiteBase64 = 'data:image/png;base64,' . base64_encode(ob_get_clean());
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
}
|
||||
}
|
||||
|
||||
// QR code as inline SVG (no ImageMagick needed)
|
||||
$postUrl = route('post.show', ['id' => $post->id]);
|
||||
$qrSvg = \SimpleSoftwareIO\QrCode\Facades\QrCode::format('svg')
|
||||
->size(300)->margin(2)->errorCorrection('M')->color(0, 0, 0)->generate($postUrl);
|
||||
$qrBase64 = 'data:image/svg+xml;base64,' . base64_encode($qrSvg);
|
||||
|
||||
// Sanitize and truncate content for PDF, preserving HTML formatting.
|
||||
// Truncation is based on plain-text length; the HTML is kept intact up to that point.
|
||||
$rawContent = $translation?->content ?? '';
|
||||
$plainContent = strip_tags($rawContent);
|
||||
$contentMax = 800;
|
||||
$isTruncated = mb_strlen($plainContent) > $contentMax;
|
||||
$pdfContent = \App\Helpers\StringHelper::sanitizeHtml($rawContent);
|
||||
|
||||
// Pass the full excerpt — CSS overflow:hidden on .hero-excerpt clips it within the hero.
|
||||
$rawExcerpt = $translation?->excerpt ?? '';
|
||||
$pdfExcerpt = $rawExcerpt;
|
||||
|
||||
$meetingCityName = null;
|
||||
if ($isEvent && $post->meeting?->location?->city) {
|
||||
$city = $post->meeting->location->city;
|
||||
$meetingCityName = $city->translations->firstWhere('locale', $locale)?->name
|
||||
?? $city->translations->first()?->name;
|
||||
}
|
||||
|
||||
return compact(
|
||||
'post', 'translation', 'categoryName', 'update',
|
||||
'categoryType', 'isEvent', 'isArticle', 'isNews', 'isImagePost',
|
||||
'heroBase64', 'avatarBase64', 'logoBase64', 'logoWhiteBase64', 'qrBase64',
|
||||
'pdfContent', 'isTruncated', 'pdfExcerpt', 'author', 'brandColor',
|
||||
'meetingCityName'
|
||||
);
|
||||
}
|
||||
|
||||
public function notFound()
|
||||
{
|
||||
return view('posts.not_found');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user