Files
timebank-cc-public/app/Http/Controllers/PostController.php
T

432 lines
17 KiB
PHP

<?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');
}
}