Backend-WIP: Kontakt-Queue, Page-Cache, Validierungs-Feedback, i18n
Gebuendelt, da ueber gemeinsame lang-/Service-Dateien verschraenkt: - Kontakt-Nachrichten: contact_messages-Tabelle + Model/Enums/Factory, Admin-Queue (livewire/admin/contact-messages), ContactController persistiert das Anliegen zusaetzlich zur Brand-Mail - Page-Data-Cache (Services/Web/*PageData) + web-archive-Indizes auf press_releases - Validierungs-Feedback + Company-Portal-Restriction (Feature-Tests) - i18n-Auslagerung lang/de|en (auth, passwords, validation, static) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef5de54300
commit
72e6a3c210
60 changed files with 3963 additions and 327 deletions
30
app/Enums/ContactMessageStatus.php
Normal file
30
app/Enums/ContactMessageStatus.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ContactMessageStatus: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case InProgress = 'in_progress';
|
||||
case Resolved = 'resolved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Open => 'Offen',
|
||||
self::InProgress => 'In Bearbeitung',
|
||||
self::Resolved => 'Erledigt',
|
||||
self::Rejected => 'Abgelehnt',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offene Anliegen, die in der Kontakt-Queue Aufmerksamkeit brauchen
|
||||
* (Nav-Zähler und Default-Filter der Admin-Ansicht).
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this === self::Open || $this === self::InProgress;
|
||||
}
|
||||
}
|
||||
24
app/Enums/ContactMessageTopic.php
Normal file
24
app/Enums/ContactMessageTopic.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ContactMessageTopic: string
|
||||
{
|
||||
case Contact = 'contact';
|
||||
case BugReport = 'bug_report';
|
||||
case PressReleaseReport = 'press_release_report';
|
||||
case Feedback = 'feedback';
|
||||
|
||||
/**
|
||||
* Interne (Admin-)Bezeichnung.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Contact => 'Kontaktaufnahme',
|
||||
self::BugReport => 'Fehlermeldung',
|
||||
self::PressReleaseReport => 'PM-Meldung',
|
||||
self::Feedback => 'Feedback',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
|||
use App\Http\Resources\PressReleaseResource;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
|
|
@ -151,6 +152,10 @@ class PressReleaseController extends Controller
|
|||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
} catch (AiDeclarationRequiredException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
} catch (BlacklistViolationException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
|
|
|
|||
|
|
@ -2,19 +2,24 @@
|
|||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ContactRequestSubmitted;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Öffentliches Kontaktformular der Web-Domains. Versendet die Anfrage per
|
||||
* Mail an die Brand-Kontaktadresse (config domains.*.brand.contact_email,
|
||||
* Fallback: Absenderadresse der Anwendung). Kein Persistieren.
|
||||
* Öffentliches Kontaktformular der Web-Domains (Hub-Modal + schwebendes
|
||||
* Kontakt-Widget). Persistiert das Anliegen in der Admin-Queue „Kontakt"
|
||||
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
|
||||
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
|
||||
* Absenderadresse der Anwendung).
|
||||
*/
|
||||
class ContactController extends Controller
|
||||
{
|
||||
|
|
@ -30,16 +35,42 @@ class ContactController extends Controller
|
|||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
'topic' => ['nullable', Rule::enum(ContactMessageTopic::class)],
|
||||
'page_url' => ['nullable', 'string', 'max:500'],
|
||||
], [
|
||||
'name.required' => __('web.launcher.validation_name'),
|
||||
'name.max' => __('web.launcher.validation_name'),
|
||||
'email.required' => __('web.launcher.validation_email'),
|
||||
'email.email' => __('web.launcher.validation_email'),
|
||||
'email.max' => __('web.launcher.validation_email'),
|
||||
'message.required' => __('web.launcher.validation_message'),
|
||||
'message.min' => __('web.launcher.validation_message_min'),
|
||||
'message.max' => __('web.launcher.validation_message_max'),
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||
|
||||
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
|
||||
|
||||
ContactMessage::create([
|
||||
'portal' => $this->portal($request),
|
||||
'topic' => $topic,
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'message' => $validated['message'],
|
||||
'page_url' => $validated['page_url'] ?? null,
|
||||
'requester_ip' => $request->ip(),
|
||||
'user_id' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
Mail::to($this->recipient())->send(new ContactRequestSubmitted(
|
||||
senderName: $validated['name'],
|
||||
senderEmail: $validated['email'],
|
||||
messageText: $validated['message'],
|
||||
host: $request->getHost(),
|
||||
topicLabel: $topic->label(),
|
||||
pageUrl: $validated['page_url'] ?? null,
|
||||
));
|
||||
|
||||
return $this->done();
|
||||
|
|
@ -47,7 +78,20 @@ class ContactController extends Controller
|
|||
|
||||
private function done(): RedirectResponse
|
||||
{
|
||||
return back()->with('contact-status', __('Vielen Dank für Ihre Nachricht. Wir melden uns schnellstmöglich bei Ihnen zurück.'));
|
||||
return back()->with('contact-status', __('web.launcher.success'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal-Kennung für die Admin-Queue, aus dem Host abgeleitet:
|
||||
* Hub (pressekonto) vs. Spoke-Portale.
|
||||
*/
|
||||
private function portal(Request $request): string
|
||||
{
|
||||
if (str_contains($request->getHost(), 'pressekonto')) {
|
||||
return 'pressekonto';
|
||||
}
|
||||
|
||||
return WebPortalContext::portalFromHost()->value;
|
||||
}
|
||||
|
||||
private function recipient(): string
|
||||
|
|
@ -72,7 +116,7 @@ class ContactController extends Controller
|
|||
$seconds = RateLimiter::availableIn($this->throttleKey($request));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'message' => __('web.launcher.throttle', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class ReportFormController extends Controller
|
|||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||
// bewusst VOR der Validierung (sonst verrät die Regel die Falle).
|
||||
if (filled($request->input('website')) || filled($request->input('homepage'))) {
|
||||
return back()->with('report-status', __('Vielen Dank. Ihre Meldung ist eingegangen.'));
|
||||
return back()->with('report-status', __('static.report_form.flash_honeypot'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
|
@ -53,7 +53,7 @@ class ReportFormController extends Controller
|
|||
// bestem Wissen und Gewissen abgegeben wird (nur Weg 1).
|
||||
'truth_affirmed' => $way === 'rechtswidrig' ? ['accepted'] : ['nullable'],
|
||||
], [
|
||||
'truth_affirmed.accepted' => __('Bitte bestätigen Sie, dass Sie die Meldung nach bestem Wissen und Gewissen abgeben.'),
|
||||
'truth_affirmed.accepted' => __('static.report_form.validation_truth'),
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
|
|
@ -82,7 +82,7 @@ class ReportFormController extends Controller
|
|||
Mail::to($legalRequest->requester_email)->send(new LegalRequestReceived($legalRequest));
|
||||
Mail::to($this->operatorRecipient($request))->send(new LegalRequestSubmitted($legalRequest));
|
||||
|
||||
return back()->with('report-status', __('Vielen Dank. Ihre Meldung ist unter der Vorgangsnummer :reference eingegangen — Sie erhalten eine Eingangsbestätigung per E-Mail.', [
|
||||
return back()->with('report-status', __('static.report_form.flash_received', [
|
||||
'reference' => $legalRequest->reference(),
|
||||
]));
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ class ReportFormController extends Controller
|
|||
$seconds = RateLimiter::availableIn($this->throttleKey($request));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'message' => __('static.report_form.throttle', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ class TerminationController extends Controller
|
|||
'terminate_mode' => ['required', 'in:asap,date'],
|
||||
'terminate_at' => ['required_if:terminate_mode,date', 'nullable', 'date'],
|
||||
], [
|
||||
'reason.required_if' => __('Bitte geben Sie den Grund der außerordentlichen Kündigung an.'),
|
||||
'terminate_at.required_if' => __('Bitte geben Sie das gewünschte Beendigungsdatum an.'),
|
||||
'reason.required_if' => __('static.cancellation.validation_reason'),
|
||||
'terminate_at.required_if' => __('static.cancellation.validation_date'),
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
|
|
@ -79,7 +79,7 @@ class TerminationController extends Controller
|
|||
|
||||
private function done(): RedirectResponse
|
||||
{
|
||||
return back()->with('termination-status', __('Ihre Kündigung ist bei uns eingegangen. Sie erhalten umgehend eine automatische Eingangsbestätigung per E-Mail mit Datum und Uhrzeit des Zugangs.'));
|
||||
return back()->with('termination-status', __('static.cancellation.flash_received'));
|
||||
}
|
||||
|
||||
private function operatorRecipient(): string
|
||||
|
|
@ -102,7 +102,7 @@ class TerminationController extends Controller
|
|||
$seconds = RateLimiter::availableIn($this->throttleKey($request));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'email' => __('static.cancellation.throttle', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
])->errorBag('termination');
|
||||
|
|
|
|||
|
|
@ -124,22 +124,32 @@ trait InteractsWithPressReleaseScheduling
|
|||
}
|
||||
|
||||
/**
|
||||
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung. Die einzelnen
|
||||
* Feld-Errors werden weiterhin direkt am Input angezeigt.
|
||||
* Toast mit Sammelhinweis nach fehlgeschlagener Validierung. Nennt die
|
||||
* betroffenen Felder beim Namen (aus validation.attributes), damit der
|
||||
* User nicht raten muss, wo es hakt. Die einzelnen Feld-Errors werden
|
||||
* weiterhin direkt am Input angezeigt, zusätzlich rendert
|
||||
* [[x-portal.pr-error-summary]] eine persistente Übersicht im Formular.
|
||||
*/
|
||||
protected function notifyValidationError(?ValidationException $exception = null): void
|
||||
{
|
||||
$count = $exception
|
||||
? array_sum(array_map('count', $exception->errors()))
|
||||
: count($this->getErrorBag()->all());
|
||||
$fieldKeys = $exception
|
||||
? array_keys($exception->errors())
|
||||
: $this->getErrorBag()->keys();
|
||||
|
||||
$fieldLabels = collect($fieldKeys)
|
||||
->map(fn (string $key): string => trans()->has('validation.attributes.'.$key)
|
||||
? __('validation.attributes.'.$key)
|
||||
: $key)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Bitte Eingaben prüfen'),
|
||||
text: $count > 1
|
||||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
||||
text: $fieldLabels->isNotEmpty()
|
||||
? __('Bitte prüfe: :fields', ['fields' => $fieldLabels->join(', ')])
|
||||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
||||
variant: 'danger',
|
||||
duration: 6000,
|
||||
duration: 8000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,20 @@ class ContactRequestSubmitted extends Mailable
|
|||
public readonly string $senderEmail,
|
||||
public readonly string $messageText,
|
||||
public readonly string $host,
|
||||
public readonly ?string $topicLabel = null,
|
||||
public readonly ?string $pageUrl = null,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = 'Neue Kontaktanfrage über '.$this->host;
|
||||
|
||||
if ($this->topicLabel !== null && $this->topicLabel !== 'Kontaktaufnahme') {
|
||||
$subject = 'Neue '.$this->topicLabel.' über '.$this->host;
|
||||
}
|
||||
|
||||
return new Envelope(
|
||||
subject: 'Neue Kontaktanfrage über '.$this->host,
|
||||
subject: $subject,
|
||||
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
79
app/Models/ContactMessage.php
Normal file
79
app/Models/ContactMessage.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use Database\Factories\ContactMessageFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Kontakt-Anliegen aus dem schwebenden Kontakt-Widget (Kontaktaufnahme,
|
||||
* Fehlermeldung, PM-Meldung, Feedback). Bewusst OHNE PortalScope: die
|
||||
* Admin-Queue „Kontakt" sieht alle Portale; das Portal wird zur Anzeige/
|
||||
* Filterung als eigene Spalte gehalten.
|
||||
*/
|
||||
class ContactMessage extends Model
|
||||
{
|
||||
/** @use HasFactory<ContactMessageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'portal',
|
||||
'topic',
|
||||
'status',
|
||||
'name',
|
||||
'email',
|
||||
'message',
|
||||
'page_url',
|
||||
'payload',
|
||||
'requester_ip',
|
||||
'user_id',
|
||||
'resolved_by_user_id',
|
||||
'admin_note',
|
||||
'resolved_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'topic' => ContactMessageTopic::class,
|
||||
'status' => ContactMessageStatus::class,
|
||||
'payload' => 'array',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}").
|
||||
*/
|
||||
public function reference(): string
|
||||
{
|
||||
return 'KT-'.$this->id;
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function resolver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'resolved_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<ContactMessage> $query
|
||||
* @return Builder<ContactMessage>
|
||||
*/
|
||||
public function scopeOpen(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
ContactMessageStatus::Open->value,
|
||||
ContactMessageStatus::InProgress->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -136,6 +136,18 @@ class PressRelease extends Model
|
|||
return $this->embargo_at;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL der explizit gewählten Platzhalter-Grafik für bildlose PMs.
|
||||
* Null bei Altbeständen ohne gesetzte Variante — dort rendert das
|
||||
* Web-Frontend weiterhin seinen generischen Fallback.
|
||||
*/
|
||||
public function chosenPlaceholderUrl(): ?string
|
||||
{
|
||||
return $this->placeholder_variant
|
||||
? asset($this->placeholder_variant->path())
|
||||
: null;
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
|
|
|||
|
|
@ -3,15 +3,23 @@
|
|||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3
|
||||
* („PM XY wurde am … auf … veröffentlicht" inkl. URL, Datum, Vorschau).
|
||||
* On-demand aus vorhandenen PM-Daten — keine KI, kein externer Renderer
|
||||
* (gleicher schlanker PDF-Ansatz wie der Legacy-Rechnungs-Renderer).
|
||||
* Erzeugt den Veröffentlichungsnachweis-PDF laut Decision-Update §2.3.
|
||||
*
|
||||
* Der Nachweis dokumentiert die Veröffentlichung vollständig: Bestätigungstext,
|
||||
* alle Veröffentlichungsdaten (Portal, Datum, öffentliche URLs, Kategorie …),
|
||||
* die Pressemitteilung im kompletten Wortlaut, Boilerplate, Pressekontakte und
|
||||
* Herausgeber-Angaben — mehrseitig mit Seitennummerierung und Fußzeile, im
|
||||
* Pressekonto-Branding (Navy/Bronze). On-demand aus vorhandenen PM-Daten —
|
||||
* keine KI, kein externer Renderer (gleicher schlanker PDF-Ansatz wie der
|
||||
* Legacy-Rechnungs-Renderer).
|
||||
*/
|
||||
class ProofPdfRenderer
|
||||
{
|
||||
|
|
@ -19,9 +27,40 @@ class ProofPdfRenderer
|
|||
|
||||
private const PAGE_HEIGHT = 842;
|
||||
|
||||
private const LEFT = 70;
|
||||
private const LEFT = 60;
|
||||
|
||||
private const RIGHT = 525;
|
||||
private const RIGHT = 535;
|
||||
|
||||
private const CONTENT_TOP = 796;
|
||||
|
||||
private const BOTTOM = 92;
|
||||
|
||||
/** Pressekonto-Hub-Navy (#1A2540) — Markenfarbe für Headings/Flächen. */
|
||||
private const NAVY = [0.102, 0.145, 0.251];
|
||||
|
||||
/** Pressekonto-Bronze (#B07A3A) — Akzentfarbe für Linien/Details. */
|
||||
private const BRONZE = [0.690, 0.478, 0.227];
|
||||
|
||||
private const INK = [0.129, 0.145, 0.180];
|
||||
|
||||
private const SLATE = [0.302, 0.333, 0.384];
|
||||
|
||||
private const GRAY = [0.478, 0.502, 0.549];
|
||||
|
||||
private const HEADER_MUTED = [0.663, 0.702, 0.800];
|
||||
|
||||
private const BOX_BG = [0.958, 0.962, 0.973];
|
||||
|
||||
private const RULE = [0.855, 0.867, 0.898];
|
||||
|
||||
private const WHITE = [1.0, 1.0, 1.0];
|
||||
|
||||
/** @var list<string> abgeschlossene Seiten-Content-Streams */
|
||||
private array $pages = [];
|
||||
|
||||
private string $stream = '';
|
||||
|
||||
private float $y = self::CONTENT_TOP;
|
||||
|
||||
public function downloadResponse(PressRelease $pressRelease): Response
|
||||
{
|
||||
|
|
@ -46,83 +85,658 @@ class ProofPdfRenderer
|
|||
|
||||
public function render(PressRelease $pressRelease): string
|
||||
{
|
||||
$content = '';
|
||||
$portalLabel = $pressRelease->portal instanceof Portal
|
||||
? $pressRelease->portal->label()
|
||||
: (string) $pressRelease->portal;
|
||||
$publishedAt = $pressRelease->published_at;
|
||||
$dateLabel = $publishedAt?->format('d.m.Y') ?? 'n/a';
|
||||
$timeLabel = $publishedAt?->format('H:i') ?? null;
|
||||
$this->pages = [];
|
||||
$this->stream = '';
|
||||
$this->y = self::CONTENT_TOP;
|
||||
|
||||
$content .= $this->text(self::LEFT, 790, 'Veröffentlichungsnachweis', 22, 'F2');
|
||||
$content .= $this->line(self::LEFT, 778, self::RIGHT, 778, 0.7);
|
||||
$content .= $this->text(self::LEFT, 760, $portalLabel, 11, 'F2');
|
||||
$company = $pressRelease->company;
|
||||
|
||||
$y = 712.0;
|
||||
$headline = $timeLabel
|
||||
? "Diese Pressemitteilung wurde am {$dateLabel} um {$timeLabel} Uhr auf {$portalLabel} veröffentlicht."
|
||||
: "Diese Pressemitteilung wurde am {$dateLabel} auf {$portalLabel} veröffentlicht.";
|
||||
$y = $this->wrappedText($content, self::LEFT, $y, $headline, 11, self::RIGHT - self::LEFT, 16);
|
||||
/** @var Collection<int, Contact> $contacts */
|
||||
$contacts = $pressRelease->contacts()
|
||||
->withoutGlobalScopes()
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
$y -= 24;
|
||||
$content .= $this->text(self::LEFT, $y, 'Titel', 9, 'F2');
|
||||
$y = $this->wrappedText($content, self::LEFT, $y - 15, (string) $pressRelease->title, 13, self::RIGHT - self::LEFT, 17, 'F2');
|
||||
$this->renderDocumentHead($pressRelease);
|
||||
$this->renderConfirmation($pressRelease);
|
||||
$this->renderPublicationDetails($pressRelease, $company);
|
||||
$this->renderFullContent($pressRelease);
|
||||
$this->renderBoilerplate($pressRelease, $company);
|
||||
$this->renderPressContacts($contacts);
|
||||
$this->renderPublisher($company);
|
||||
$this->renderClosingNote();
|
||||
|
||||
$y -= 18;
|
||||
foreach ($this->metaRows($pressRelease, $portalLabel, $dateLabel) as [$label, $value]) {
|
||||
$content .= $this->text(self::LEFT, $y, $label, 9, 'F2');
|
||||
$this->wrappedText($content, self::LEFT + 110, $y, $value, 9, self::RIGHT - self::LEFT - 110, 12);
|
||||
$y -= 20;
|
||||
}
|
||||
$this->pages[] = $this->stream;
|
||||
|
||||
$y -= 10;
|
||||
$content .= $this->text(self::LEFT, $y, 'Vorschau', 9, 'F2');
|
||||
$this->wrappedText($content, self::LEFT, $y - 15, $this->previewText($pressRelease), 9, self::RIGHT - self::LEFT, 12);
|
||||
|
||||
$content .= $this->line(self::LEFT, 70, self::RIGHT, 70, 0.5);
|
||||
$content .= $this->text(self::LEFT, 56, 'Erstellt über pressekonto – maschinell erzeugter Nachweis.', 8);
|
||||
|
||||
return $this->buildPdf($content);
|
||||
return $this->buildPdf($this->withPageChrome($pressRelease));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{0: string, 1: string}>
|
||||
*/
|
||||
private function metaRows(PressRelease $pressRelease, string $portalLabel, string $dateLabel): array
|
||||
// ------------------------------------------------------------ Abschnitte
|
||||
|
||||
private function renderDocumentHead(PressRelease $pressRelease): void
|
||||
{
|
||||
$bandBottom = 744.0;
|
||||
|
||||
$this->rect(0, $bandBottom, self::PAGE_WIDTH, self::PAGE_HEIGHT - $bandBottom, self::NAVY);
|
||||
$this->rect(0, $bandBottom - 4, self::PAGE_WIDTH, 4, self::BRONZE);
|
||||
|
||||
$this->text(self::LEFT, 810, 'pressekonto', 10.5, 'F2', self::HEADER_MUTED);
|
||||
$this->text(self::LEFT, 778, 'Veröffentlichungsnachweis', 21, 'F2', self::WHITE);
|
||||
$this->text(self::LEFT, 759, $this->portalLabel($pressRelease), 10.5, 'F1', self::HEADER_MUTED);
|
||||
|
||||
$this->y = 704;
|
||||
}
|
||||
|
||||
private function renderConfirmation(PressRelease $pressRelease): void
|
||||
{
|
||||
$publishedAt = $pressRelease->published_at;
|
||||
$dateLabel = $publishedAt?->format('d.m.Y') ?? 'n/a';
|
||||
$timeLabel = $publishedAt?->format('H:i');
|
||||
|
||||
$statement = sprintf(
|
||||
'Hiermit wird bestätigt, dass die nachfolgend dokumentierte Pressemitteilung am %s%s auf %s veröffentlicht wurde.',
|
||||
$dateLabel,
|
||||
$timeLabel ? " um {$timeLabel} Uhr" : '',
|
||||
$this->portalLabel($pressRelease),
|
||||
);
|
||||
|
||||
$padding = 16.0;
|
||||
$textWidth = self::RIGHT - self::LEFT - 2 * $padding;
|
||||
$statementLines = $this->wrap($statement, $textWidth, 10.5);
|
||||
$noteLines = $pressRelease->depublished_at
|
||||
? $this->wrap(
|
||||
'Hinweis: Die Veröffentlichung wurde am '.$pressRelease->depublished_at->format('d.m.Y').' beendet (offline genommen).',
|
||||
$textWidth,
|
||||
9,
|
||||
)
|
||||
: [];
|
||||
|
||||
$height = 2 * $padding + count($statementLines) * 15.5
|
||||
+ ($noteLines !== [] ? 8 + count($noteLines) * 12.5 : 0);
|
||||
|
||||
$this->ensureSpace($height);
|
||||
$top = $this->y;
|
||||
|
||||
$this->rect(self::LEFT, $top - $height, self::RIGHT - self::LEFT, $height, self::BOX_BG);
|
||||
$this->rect(self::LEFT, $top - $height, 3, $height, self::BRONZE);
|
||||
|
||||
$baseline = $top - $padding - 10;
|
||||
|
||||
foreach ($statementLines as $line) {
|
||||
$this->text(self::LEFT + $padding, $baseline, $line, 10.5, 'F1', self::INK);
|
||||
$baseline -= 15.5;
|
||||
}
|
||||
|
||||
if ($noteLines !== []) {
|
||||
$baseline -= 4;
|
||||
|
||||
foreach ($noteLines as $line) {
|
||||
$this->text(self::LEFT + $padding, $baseline, $line, 9, 'F1', self::GRAY);
|
||||
$baseline -= 12.5;
|
||||
}
|
||||
}
|
||||
|
||||
$this->y = $top - $height;
|
||||
}
|
||||
|
||||
private function renderPublicationDetails(PressRelease $pressRelease, ?Company $company): void
|
||||
{
|
||||
$this->sectionHeading('Angaben zur Veröffentlichung');
|
||||
|
||||
$publishedAt = $pressRelease->published_at;
|
||||
$publishedLabel = $publishedAt
|
||||
? $publishedAt->format('d.m.Y').', '.$publishedAt->format('H:i').' Uhr'
|
||||
: 'n/a';
|
||||
|
||||
// Bei Portal::Both dokumentiert der Nachweis beide öffentlichen URLs.
|
||||
$links = $pressRelease->publicPortalLinks();
|
||||
$urlRows = array_map(static fn (array $link): array => [
|
||||
count($links) > 1 ? 'URL '.$link['label'] : 'URL',
|
||||
count($links) > 1 ? 'URL '.$link['label'] : 'Öffentliche URL',
|
||||
$link['url'],
|
||||
], $links);
|
||||
|
||||
return array_values(array_filter([
|
||||
['Portal', $portalLabel],
|
||||
['Veröffentlicht', $dateLabel],
|
||||
$rows = array_values(array_filter([
|
||||
['Pressemitteilung', 'PM #'.$pressRelease->id],
|
||||
['Titel', (string) $pressRelease->title],
|
||||
$company?->name ? ['Firma', (string) $company->name] : null,
|
||||
['Portal', $this->portalLabel($pressRelease)],
|
||||
['Veröffentlicht am', $publishedLabel],
|
||||
$pressRelease->depublished_at ? ['Offline seit', $pressRelease->depublished_at->format('d.m.Y')] : null,
|
||||
...$urlRows,
|
||||
$pressRelease->category?->name ? ['Kategorie', (string) $pressRelease->category->name] : null,
|
||||
$this->categoryName($pressRelease) ? ['Kategorie', (string) $this->categoryName($pressRelease)] : null,
|
||||
$this->languageLabel($pressRelease->language) ? ['Sprache', (string) $this->languageLabel($pressRelease->language)] : null,
|
||||
['Nachweis erstellt am', now()->format('d.m.Y').', '.now()->format('H:i').' Uhr'],
|
||||
]));
|
||||
|
||||
$lastIndex = count($rows) - 1;
|
||||
|
||||
foreach ($rows as $index => [$label, $value]) {
|
||||
$this->metaRow($label, $value, $index < $lastIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private function previewText(PressRelease $pressRelease): string
|
||||
private function renderFullContent(PressRelease $pressRelease): void
|
||||
{
|
||||
$plain = trim(html_entity_decode(strip_tags((string) $pressRelease->text)));
|
||||
$this->sectionHeading('Pressemitteilung im vollständigen Wortlaut');
|
||||
|
||||
return Str::limit($plain, 600);
|
||||
$this->writeParagraph((string) $pressRelease->title, 14.5, 19, 'F2', self::NAVY);
|
||||
|
||||
if (filled($pressRelease->subtitle)) {
|
||||
$this->y -= 2;
|
||||
$this->writeParagraph((string) $pressRelease->subtitle, 11, 15, 'F3', self::GRAY);
|
||||
}
|
||||
|
||||
$this->y -= 8;
|
||||
|
||||
foreach ($this->htmlToParagraphs($pressRelease->text) as $paragraph) {
|
||||
if ($paragraph['heading']) {
|
||||
$this->ensureSpace(48);
|
||||
$this->y -= 4;
|
||||
$this->writeParagraph($paragraph['text'], 11, 15, 'F2', self::NAVY);
|
||||
$this->y -= 3;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->writeParagraph($paragraph['text'], 10, 14.5);
|
||||
$this->y -= 6;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPdf(string $content): string
|
||||
private function renderBoilerplate(PressRelease $pressRelease, ?Company $company): void
|
||||
{
|
||||
$boilerplate = trim((string) ($pressRelease->boilerplate_override ?: $company?->boilerplate));
|
||||
|
||||
if ($boilerplate === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sectionHeading($company?->name ? 'Über '.$company->name : 'Über das Unternehmen');
|
||||
|
||||
foreach ($this->htmlToParagraphs($boilerplate) as $paragraph) {
|
||||
$this->writeParagraph($paragraph['text'], 9.5, 13.5, 'F1', self::SLATE);
|
||||
$this->y -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Contact> $contacts
|
||||
*/
|
||||
private function renderPressContacts(Collection $contacts): void
|
||||
{
|
||||
if ($contacts->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sectionHeading($contacts->count() > 1 ? 'Pressekontakte' : 'Pressekontakt');
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$details = array_values(array_filter([
|
||||
filled($contact->phone) ? 'Telefon: '.$contact->phone : null,
|
||||
filled($contact->fax) ? 'Fax: '.$contact->fax : null,
|
||||
filled($contact->email) ? 'E-Mail: '.$contact->email : null,
|
||||
]));
|
||||
|
||||
$padding = 12.0;
|
||||
$height = $padding + 9
|
||||
+ (filled($contact->responsibility) ? 13.5 : 0)
|
||||
+ count($details) * 13
|
||||
+ 4 + $padding;
|
||||
|
||||
$this->ensureSpace($height + 10);
|
||||
$top = $this->y;
|
||||
|
||||
$this->rect(self::LEFT, $top - $height, self::RIGHT - self::LEFT, $height, self::BOX_BG);
|
||||
$this->rect(self::LEFT, $top - $height, 3, $height, self::NAVY);
|
||||
|
||||
$baseline = $top - $padding - 8;
|
||||
$this->text(self::LEFT + $padding + 4, $baseline, $contact->displayName(), 10.5, 'F2', self::NAVY);
|
||||
|
||||
if (filled($contact->responsibility)) {
|
||||
$baseline -= 13.5;
|
||||
$this->text(self::LEFT + $padding + 4, $baseline, (string) $contact->responsibility, 9, 'F3', self::GRAY);
|
||||
}
|
||||
|
||||
foreach ($details as $detail) {
|
||||
$baseline -= 13;
|
||||
$this->text(self::LEFT + $padding + 4, $baseline, $detail, 9.5, 'F1', self::INK);
|
||||
}
|
||||
|
||||
$this->y = $top - $height - 10;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderPublisher(?Company $company): void
|
||||
{
|
||||
if (! $company) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sectionHeading('Herausgeber');
|
||||
|
||||
$this->ensureSpace(40);
|
||||
$this->text(self::LEFT, $this->y, (string) $company->name, 10.5, 'F2', self::NAVY);
|
||||
$this->y -= 15;
|
||||
|
||||
if (filled($company->address)) {
|
||||
$this->writeParagraph((string) $company->address, 9.5, 13);
|
||||
}
|
||||
|
||||
if ($countryName = $this->countryName($company->country_code)) {
|
||||
$this->ensureSpace(13);
|
||||
$this->text(self::LEFT, $this->y, $countryName, 9.5);
|
||||
$this->y -= 13;
|
||||
}
|
||||
|
||||
$details = array_filter([
|
||||
filled($company->phone) ? 'Telefon: '.$company->phone : null,
|
||||
filled($company->fax) ? 'Fax: '.$company->fax : null,
|
||||
filled($company->email) ? 'E-Mail: '.$company->email : null,
|
||||
filled($company->website) ? 'Web: '.$company->website : null,
|
||||
]);
|
||||
|
||||
foreach ($details as $detail) {
|
||||
$this->ensureSpace(13);
|
||||
$this->text(self::LEFT, $this->y, $detail, 9.5);
|
||||
$this->y -= 13;
|
||||
}
|
||||
}
|
||||
|
||||
private function renderClosingNote(): void
|
||||
{
|
||||
$this->y -= 18;
|
||||
$this->ensureSpace(40);
|
||||
$this->line(self::LEFT, $this->y + 6, self::RIGHT, $this->y + 6, 0.5, self::RULE);
|
||||
$this->y -= 6;
|
||||
$this->writeParagraph(
|
||||
'Dieser Veröffentlichungsnachweis wurde maschinell über pressekonto erstellt und ist ohne Unterschrift gültig. '
|
||||
.'Die inhaltliche Verantwortung für die Pressemitteilung liegt beim Herausgeber.',
|
||||
8.5,
|
||||
12,
|
||||
'F1',
|
||||
self::GRAY,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fußzeile (Trennlinie, Dokument-Kennung, Seite X von Y) auf jeder Seite
|
||||
* sowie ein schmales Fortsetzungs-Band ab Seite 2 — erst nach dem Rendern
|
||||
* möglich, weil die Gesamtseitenzahl vorher nicht feststeht.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function withPageChrome(PressRelease $pressRelease): array
|
||||
{
|
||||
$total = count($this->pages);
|
||||
$generated = now()->format('d.m.Y, H:i');
|
||||
|
||||
return array_map(function (string $stream, int $index) use ($total, $pressRelease, $generated): string {
|
||||
$pageNumber = $index + 1;
|
||||
|
||||
$chrome = $this->lineOp(self::LEFT, 66, self::RIGHT, 66, 0.6, self::RULE);
|
||||
$chrome .= $this->textOp(
|
||||
self::LEFT,
|
||||
53,
|
||||
"Veröffentlichungsnachweis PM #{$pressRelease->id} · erstellt am {$generated} Uhr über pressekonto",
|
||||
8,
|
||||
'F1',
|
||||
self::GRAY,
|
||||
);
|
||||
|
||||
$pageLabel = "Seite {$pageNumber} von {$total}";
|
||||
$chrome .= $this->textOp(self::RIGHT - $this->estimateWidth($pageLabel, 8), 53, $pageLabel, 8, 'F1', self::GRAY);
|
||||
|
||||
if ($pageNumber > 1) {
|
||||
$chrome .= $this->rectOp(0, self::PAGE_HEIGHT - 6, self::PAGE_WIDTH, 6, self::NAVY);
|
||||
$chrome .= $this->textOp(
|
||||
self::LEFT,
|
||||
818,
|
||||
"Veröffentlichungsnachweis PM #{$pressRelease->id} – Fortsetzung",
|
||||
8,
|
||||
'F1',
|
||||
self::GRAY,
|
||||
);
|
||||
}
|
||||
|
||||
return $stream.$chrome;
|
||||
}, $this->pages, array_keys($this->pages));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Datenaufbereitung
|
||||
|
||||
private function portalLabel(PressRelease $pressRelease): string
|
||||
{
|
||||
if (! $pressRelease->portal instanceof Portal) {
|
||||
return (string) $pressRelease->portal;
|
||||
}
|
||||
|
||||
return $pressRelease->portal === Portal::Both
|
||||
? 'Presseecho und Businessportal24'
|
||||
: $pressRelease->portal->label();
|
||||
}
|
||||
|
||||
private function categoryName(PressRelease $pressRelease): ?string
|
||||
{
|
||||
if (! $pressRelease->category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$locale = in_array($pressRelease->language, ['de', 'en'], true) ? $pressRelease->language : 'de';
|
||||
|
||||
return $pressRelease->category->translations()->where('locale', $locale)->value('name')
|
||||
?? $pressRelease->category->translations()->value('name');
|
||||
}
|
||||
|
||||
private function languageLabel(?string $language): ?string
|
||||
{
|
||||
return match ($language) {
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'Englisch',
|
||||
null, '' => null,
|
||||
default => strtoupper($language),
|
||||
};
|
||||
}
|
||||
|
||||
private function countryName(?string $countryCode): ?string
|
||||
{
|
||||
return match (strtoupper((string) $countryCode)) {
|
||||
'DE' => 'Deutschland',
|
||||
'AT' => 'Österreich',
|
||||
'CH' => 'Schweiz',
|
||||
'' => null,
|
||||
default => strtoupper((string) $countryCode),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den (sanitisierten) HTML-Inhalt der PM in Plaintext-Absätze:
|
||||
* Block-Elemente werden zu Absatztrennern, <br> zu Zeilenumbrüchen,
|
||||
* Listenpunkte erhalten ein Aufzählungszeichen. Zwischenüberschriften
|
||||
* (h1–h6) werden markiert, damit sie im PDF hervorgehoben gesetzt werden
|
||||
* können. Legacy-Plaintext läuft unverändert durch die Absatz-Trennung
|
||||
* (Doppelumbruch).
|
||||
*
|
||||
* @return list<array{heading: bool, text: string}>
|
||||
*/
|
||||
private function htmlToParagraphs(?string $html): array
|
||||
{
|
||||
$headingMarker = "\x01";
|
||||
|
||||
$text = (string) $html;
|
||||
$text = (string) preg_replace('/<br\s*\/?>/i', "\n", $text);
|
||||
$text = (string) preg_replace('/<li\b[^>]*>/i', '• ', $text);
|
||||
$text = (string) preg_replace('/<h[1-6]\b[^>]*>/i', "\n\n".$headingMarker, $text);
|
||||
$text = (string) preg_replace('/<\/(p|h[1-6]|li|ul|ol|blockquote|div)>/i', "\n\n", $text);
|
||||
$text = strip_tags($text);
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = (string) preg_replace('/[ \t]+/u', ' ', $text);
|
||||
|
||||
$paragraphs = [];
|
||||
|
||||
foreach (preg_split('/\n{2,}/u', $text) ?: [] as $paragraph) {
|
||||
$paragraph = trim((string) preg_replace('/\s*\n\s*/u', "\n", $paragraph));
|
||||
|
||||
if ($paragraph === '' || $paragraph === $headingMarker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paragraphs[] = [
|
||||
'heading' => str_starts_with($paragraph, $headingMarker),
|
||||
'text' => ltrim($paragraph, $headingMarker),
|
||||
];
|
||||
}
|
||||
|
||||
return $paragraphs;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Layout-Primitive
|
||||
|
||||
private function sectionHeading(string $title): void
|
||||
{
|
||||
$this->ensureSpace(74);
|
||||
$this->y -= 22;
|
||||
$this->text(self::LEFT, $this->y, mb_strtoupper($title), 9, 'F2', self::NAVY);
|
||||
$this->y -= 9;
|
||||
$this->rect(self::LEFT, $this->y, 26, 2, self::BRONZE);
|
||||
$this->line(self::LEFT + 34, $this->y + 1, self::RIGHT, $this->y + 1, 0.5, self::RULE);
|
||||
$this->y -= 18;
|
||||
}
|
||||
|
||||
private function metaRow(string $label, string $value, bool $withSeparator): void
|
||||
{
|
||||
$valueLines = $this->wrap($value, self::RIGHT - self::LEFT - 140, 9.5);
|
||||
$this->ensureSpace(max(1, count($valueLines)) * 13 + 9);
|
||||
|
||||
$this->text(self::LEFT, $this->y, $label, 9, 'F1', self::GRAY);
|
||||
|
||||
if ($valueLines === []) {
|
||||
$this->y -= 13;
|
||||
}
|
||||
|
||||
foreach ($valueLines as $line) {
|
||||
if ($line !== '') {
|
||||
$this->text(self::LEFT + 140, $this->y, $line, 9.5, 'F1', self::INK);
|
||||
}
|
||||
|
||||
$this->y -= 13;
|
||||
}
|
||||
|
||||
$this->y -= 6;
|
||||
|
||||
if ($withSeparator) {
|
||||
$this->line(self::LEFT, $this->y + 10, self::RIGHT, $this->y + 10, 0.4, self::RULE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function writeParagraph(string $text, float $size, float $lineHeight, string $font = 'F1', array $color = self::INK): void
|
||||
{
|
||||
foreach ($this->wrap($text, self::RIGHT - self::LEFT, $size) as $line) {
|
||||
$this->ensureSpace($lineHeight);
|
||||
|
||||
if ($line !== '') {
|
||||
$this->text(self::LEFT, $this->y, $line, $size, $font, $color);
|
||||
}
|
||||
|
||||
$this->y -= $lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureSpace(float $needed): void
|
||||
{
|
||||
if ($this->y - $needed < self::BOTTOM) {
|
||||
$this->newPage();
|
||||
}
|
||||
}
|
||||
|
||||
private function newPage(): void
|
||||
{
|
||||
$this->pages[] = $this->stream;
|
||||
$this->stream = '';
|
||||
$this->y = self::CONTENT_TOP;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function text(float $x, float $y, string $text, float $size = 9, string $font = 'F1', array $color = self::INK): void
|
||||
{
|
||||
$this->stream .= $this->textOp($x, $y, $text, $size, $font, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function rect(float $x, float $y, float $width, float $height, array $color): void
|
||||
{
|
||||
$this->stream .= $this->rectOp($x, $y, $width, $height, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function line(float $x1, float $y1, float $x2, float $y2, float $width, array $color): void
|
||||
{
|
||||
$this->stream .= $this->lineOp($x1, $y1, $x2, $y2, $width, $color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function textOp(float $x, float $y, string $text, float $size, string $font = 'F1', array $color = self::INK): string
|
||||
{
|
||||
return sprintf(
|
||||
"BT /%s %.2F Tf %.3F %.3F %.3F rg %.2F %.2F Td (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$color[0],
|
||||
$color[1],
|
||||
$color[2],
|
||||
$x,
|
||||
$y,
|
||||
$this->escapePdfText($text),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function rectOp(float $x, float $y, float $width, float $height, array $color): string
|
||||
{
|
||||
return sprintf(
|
||||
"%.3F %.3F %.3F rg %.2F %.2F %.2F %.2F re f\n",
|
||||
$color[0],
|
||||
$color[1],
|
||||
$color[2],
|
||||
$x,
|
||||
$y,
|
||||
$width,
|
||||
$height,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: float, 1: float, 2: float} $color
|
||||
*/
|
||||
private function lineOp(float $x1, float $y1, float $x2, float $y2, float $width, array $color): string
|
||||
{
|
||||
return sprintf(
|
||||
"%.3F %.3F %.3F RG %.2F w %.2F %.2F m %.2F %.2F l S\n",
|
||||
$color[0],
|
||||
$color[1],
|
||||
$color[2],
|
||||
$width,
|
||||
$x1,
|
||||
$y1,
|
||||
$x2,
|
||||
$y2,
|
||||
);
|
||||
}
|
||||
|
||||
private function estimateWidth(string $text, float $size): float
|
||||
{
|
||||
return Str::length($text) * $size * 0.5;
|
||||
}
|
||||
|
||||
private function escapePdfText(string $text): string
|
||||
{
|
||||
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
|
||||
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function wrap(string $text, float $width, float $size): array
|
||||
{
|
||||
$lines = [];
|
||||
$maxCharacters = max(8, (int) floor($width / ($size * 0.5)));
|
||||
|
||||
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $paragraph) {
|
||||
if (trim($paragraph) === '') {
|
||||
$lines[] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$words = [];
|
||||
foreach (preg_split('/\s+/u', trim($paragraph)) ?: [] as $word) {
|
||||
// Überlange Wörter (v. a. URLs) hart umbrechen statt überlaufen.
|
||||
if (Str::length($word) > $maxCharacters) {
|
||||
array_push($words, ...mb_str_split($word, $maxCharacters));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$words[] = $word;
|
||||
}
|
||||
|
||||
$line = '';
|
||||
foreach ($words as $word) {
|
||||
$candidate = $line === '' ? $word : $line.' '.$word;
|
||||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $candidate;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ PDF-Aufbau
|
||||
|
||||
/**
|
||||
* @param list<string> $pages
|
||||
*/
|
||||
private function buildPdf(array $pages): string
|
||||
{
|
||||
$fonts = [
|
||||
'F1' => 'Helvetica',
|
||||
'F2' => 'Helvetica-Bold',
|
||||
'F3' => 'Helvetica-Oblique',
|
||||
];
|
||||
|
||||
$objects = [
|
||||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
|
||||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n",
|
||||
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
"6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n",
|
||||
];
|
||||
|
||||
$firstPageObjectNumber = 3 + count($fonts);
|
||||
$kids = [];
|
||||
foreach (array_keys($pages) as $index) {
|
||||
$kids[] = ($firstPageObjectNumber + $index * 2).' 0 R';
|
||||
}
|
||||
|
||||
$objects[] = "2 0 obj\n<< /Type /Pages /Kids [".implode(' ', $kids).'] /Count '.count($pages)." >>\nendobj\n";
|
||||
|
||||
$fontRefs = [];
|
||||
$number = 3;
|
||||
foreach ($fonts as $alias => $baseFont) {
|
||||
$objects[] = "{$number} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /{$baseFont} /Encoding /WinAnsiEncoding >>\nendobj\n";
|
||||
$fontRefs[] = "/{$alias} {$number} 0 R";
|
||||
$number++;
|
||||
}
|
||||
|
||||
foreach ($pages as $content) {
|
||||
$contentNumber = $number + 1;
|
||||
$objects[] = "{$number} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT.'] /Resources << /Font << '.implode(' ', $fontRefs)." >> >> /Contents {$contentNumber} 0 R >>\nendobj\n";
|
||||
$objects[] = "{$contentNumber} 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n";
|
||||
$number += 2;
|
||||
}
|
||||
|
||||
$pdf = "%PDF-1.4\n";
|
||||
$offsets = [0];
|
||||
|
||||
|
|
@ -144,85 +758,4 @@ class ProofPdfRenderer
|
|||
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
private function escapePdfText(string $text): string
|
||||
{
|
||||
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
|
||||
|
||||
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
|
||||
}
|
||||
|
||||
private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1'): string
|
||||
{
|
||||
return sprintf(
|
||||
"BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n",
|
||||
$font,
|
||||
$size,
|
||||
$x,
|
||||
$y,
|
||||
$this->escapePdfText($text),
|
||||
);
|
||||
}
|
||||
|
||||
private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string
|
||||
{
|
||||
return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2);
|
||||
}
|
||||
|
||||
private function wrappedText(
|
||||
string &$content,
|
||||
float $x,
|
||||
float $y,
|
||||
string $text,
|
||||
int $size,
|
||||
float $width,
|
||||
float $lineHeight = 12,
|
||||
string $font = 'F1',
|
||||
): float {
|
||||
foreach ($this->wrap($text, $width, $size) as $line) {
|
||||
if ($line !== '') {
|
||||
$content .= $this->text($x, $y, $line, $size, $font);
|
||||
}
|
||||
|
||||
$y -= $lineHeight;
|
||||
}
|
||||
|
||||
return $y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function wrap(string $text, float $width, int $size): array
|
||||
{
|
||||
$lines = [];
|
||||
$maxCharacters = max(8, (int) floor($width / ($size * 0.5)));
|
||||
|
||||
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $paragraph) {
|
||||
if (trim($paragraph) === '') {
|
||||
$lines[] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = '';
|
||||
foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) {
|
||||
$candidate = trim($line.' '.$word);
|
||||
if ($line !== '' && Str::length($candidate) > $maxCharacters) {
|
||||
$lines[] = $line;
|
||||
$line = $word;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = $candidate;
|
||||
}
|
||||
|
||||
if ($line !== '') {
|
||||
$lines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,39 @@ use App\Scopes\PortalScope;
|
|||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Datenaufbereitung der Kategorie-/Branchenseite je Ausgabe: Lead-/Top-/Feed-
|
||||
* Meldungen, Kennzahlen, aktivste Newsrooms sowie Unter- und verwandte Rubriken.
|
||||
*
|
||||
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
|
||||
* ~30 Queries über das gesamte Archiv und die Daten ändern sich höchstens
|
||||
* minütlich. Ein 404 (unbekannte Rubrik) wird nicht gecacht, da abort()
|
||||
* vor dem Cache-Write wirft.
|
||||
*/
|
||||
class CategoryPageData
|
||||
{
|
||||
private const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(WebPortalContext $context, string $slug): array
|
||||
{
|
||||
$cacheKey = sprintf('web.category.%s.%s.%s', $context->portal->value, $context->language, $slug);
|
||||
|
||||
return Cache::remember(
|
||||
$cacheKey,
|
||||
self::CACHE_TTL_SECONDS,
|
||||
fn (): array => $this->buildFresh($context, $slug)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildFresh(WebPortalContext $context, string $slug): array
|
||||
{
|
||||
$portalValues = $context->portalValues;
|
||||
$language = $context->language;
|
||||
|
|
@ -63,6 +85,10 @@ class CategoryPageData
|
|||
$previousSince = $referenceNow->copy()->subDays(14);
|
||||
$monthSince = $referenceNow->copy()->subDays(30);
|
||||
|
||||
// Bereichsvergleich statt whereDate(): DATE(published_at) würde die
|
||||
// Index-Nutzung auf published_at verhindern (non-sargable).
|
||||
$todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()];
|
||||
|
||||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
|
|
@ -71,12 +97,16 @@ class CategoryPageData
|
|||
|
||||
$leadRelease = $base()->with($with)->orderByDesc('published_at')->first();
|
||||
|
||||
// Ohne mediumText-Spalte `text`: Top-/Feed-Listen rendern nur
|
||||
// Titel/Meta, der Volltext wäre unnötiger I/O.
|
||||
$listColumns = ['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score'];
|
||||
|
||||
$topReleases = $base()
|
||||
->with($with)
|
||||
->when($leadRelease, fn (Builder $query) => $query->whereKeyNot($leadRelease->getKey()))
|
||||
->orderByDesc('published_at')
|
||||
->limit(2)
|
||||
->get();
|
||||
->get($listColumns);
|
||||
|
||||
$excludeIds = collect([$leadRelease?->getKey()])->merge($topReleases->modelKeys())->filter()->all();
|
||||
|
||||
|
|
@ -85,7 +115,7 @@ class CategoryPageData
|
|||
->when($excludeIds !== [], fn (Builder $query) => $query->whereNotIn('id', $excludeIds))
|
||||
->orderByDesc('published_at')
|
||||
->limit(12)
|
||||
->get();
|
||||
->get($listColumns);
|
||||
|
||||
$mostReadReleases = $base()
|
||||
->orderByDesc('hits')
|
||||
|
|
@ -94,7 +124,7 @@ class CategoryPageData
|
|||
->get(['id', 'slug', 'title', 'hits', 'portal', 'language']);
|
||||
|
||||
$stats = [
|
||||
'today' => $base()->whereDate('published_at', $referenceNow->toDateString())->count(),
|
||||
'today' => $base()->whereBetween('published_at', $todayRange)->count(),
|
||||
'week' => $base()->where('published_at', '>=', $recentSince)->count(),
|
||||
'previousWeek' => $base()->where('published_at', '>=', $previousSince)->where('published_at', '<', $recentSince)->count(),
|
||||
'month' => $base()->where('published_at', '>=', $monthSince)->count(),
|
||||
|
|
@ -124,9 +154,9 @@ class CategoryPageData
|
|||
$newsroomFilter($query);
|
||||
$query->where('published_at', '>=', $recentSince);
|
||||
},
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($newsroomFilter, $referenceNow): void {
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($newsroomFilter, $todayRange): void {
|
||||
$newsroomFilter($query);
|
||||
$query->whereDate('published_at', $referenceNow->toDateString());
|
||||
$query->whereBetween('published_at', $todayRange);
|
||||
},
|
||||
])
|
||||
->orderByDesc('recent_releases_count')
|
||||
|
|
@ -145,26 +175,37 @@ class CategoryPageData
|
|||
->forLanguage($language)
|
||||
->published();
|
||||
|
||||
// Alle Zählwerte der Unterkategorien in einer Query (statt 3 Counts
|
||||
// pro Kind): bedingte Aggregate, gruppiert nach category_id.
|
||||
$childCounts = $childCategories->isEmpty()
|
||||
? collect()
|
||||
: PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->tap($publishedCount)
|
||||
->whereIn('category_id', $childCategories->modelKeys())
|
||||
->groupBy('category_id')
|
||||
->selectRaw('category_id, count(*) as total_count')
|
||||
->selectRaw('sum(case when published_at >= ? then 1 else 0 end) as recent_count', [$recentSince])
|
||||
->selectRaw('sum(case when published_at >= ? and published_at < ? then 1 else 0 end) as previous_count', [$previousSince, $recentSince])
|
||||
->get()
|
||||
->keyBy('category_id');
|
||||
|
||||
$subCategories = $childCategories
|
||||
->map(function (Category $child) use ($recentSince, $previousSince, $publishedCount): ?array {
|
||||
->map(function (Category $child) use ($childCounts): ?array {
|
||||
$translation = $child->translations->first();
|
||||
|
||||
if (! $translation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = PressRelease::query()->withoutGlobalScope(PortalScope::class)
|
||||
->where('category_id', $child->id)->tap($publishedCount)->count();
|
||||
$recent = PressRelease::query()->withoutGlobalScope(PortalScope::class)
|
||||
->where('category_id', $child->id)->tap($publishedCount)->where('published_at', '>=', $recentSince)->count();
|
||||
$previous = PressRelease::query()->withoutGlobalScope(PortalScope::class)
|
||||
->where('category_id', $child->id)->tap($publishedCount)
|
||||
->where('published_at', '>=', $previousSince)->where('published_at', '<', $recentSince)->count();
|
||||
$counts = $childCounts->get($child->getKey());
|
||||
$recent = (int) ($counts->recent_count ?? 0);
|
||||
$previous = (int) ($counts->previous_count ?? 0);
|
||||
|
||||
return [
|
||||
'name' => $translation->name,
|
||||
'href' => $translation->slug ? route('kategorie', ['slug' => $translation->slug]) : route('web.home'),
|
||||
'total' => $total,
|
||||
'total' => (int) ($counts->total_count ?? 0),
|
||||
'recent' => $recent,
|
||||
'delta' => $recent - $previous,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,18 +9,39 @@ use App\Models\PressRelease;
|
|||
use App\Support\EditorialClock;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Datenaufbereitung der öffentlichen Startseite einer Ausgabe: Lead-/Seiten-/
|
||||
* meistgelesene Meldungen, aktive Newsrooms, Branchen-Index, „Heute im Fokus"
|
||||
* (Spotlight) und Kennzahlen. Der Ad-Hoc-Ticker kommt zentral aus TickerData.
|
||||
*
|
||||
* Das Ergebnis wird kurz gecacht (analog TickerData): Der Aufbau umfasst
|
||||
* ~25 Queries über das gesamte Archiv und die Daten ändern sich höchstens
|
||||
* minütlich — neue Meldungen erscheinen spätestens nach Ablauf der TTL.
|
||||
*/
|
||||
class HomePageData
|
||||
{
|
||||
private const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(Portal $portal, string $language = 'de'): array
|
||||
{
|
||||
$cacheKey = sprintf('web.home.%s.%s', $portal->value, $language);
|
||||
|
||||
return Cache::remember(
|
||||
$cacheKey,
|
||||
self::CACHE_TTL_SECONDS,
|
||||
fn (): array => $this->buildFresh($portal, $language)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildFresh(Portal $portal, string $language): array
|
||||
{
|
||||
$portalValues = [$portal->value, Portal::Both->value];
|
||||
|
||||
|
|
@ -29,6 +50,10 @@ class HomePageData
|
|||
$recentSince = $referenceNow->copy()->subDays(7);
|
||||
$previousSince = $referenceNow->copy()->subDays(14);
|
||||
|
||||
// Bereichsvergleich statt whereDate(): DATE(published_at) würde die
|
||||
// Index-Nutzung auf published_at verhindern (non-sargable).
|
||||
$todayRange = [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()];
|
||||
|
||||
$publishedQuery = static fn (): Builder => PressRelease::query()
|
||||
->forPortalValues($portalValues)
|
||||
->forLanguage($language)
|
||||
|
|
@ -52,12 +77,14 @@ class HomePageData
|
|||
->orderByDesc('published_at')
|
||||
->first();
|
||||
|
||||
// Ohne mediumText-Spalte `text`: die Seitenleisten-Teaser rendern nur
|
||||
// Titel/Meta, der Volltext wäre unnötiger I/O.
|
||||
$sideReleases = $publishedQuery()
|
||||
->with($with)
|
||||
->when($leadRelease, fn (Builder $query) => $query->where('id', '!=', $leadRelease->id))
|
||||
->orderByDesc('published_at')
|
||||
->limit(4)
|
||||
->get();
|
||||
->get(['id', 'portal', 'language', 'slug', 'title', 'category_id', 'company_id', 'published_at', 'content_score']);
|
||||
|
||||
$mostReadReleases = $publishedQuery()
|
||||
->orderByDesc('hits')
|
||||
|
|
@ -83,12 +110,12 @@ class HomePageData
|
|||
->published()
|
||||
->where('published_at', '>=', $recentSince);
|
||||
},
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $referenceNow, $language): void {
|
||||
'pressReleases as today_releases_count' => function (Builder $query) use ($portalValues, $todayRange, $language): void {
|
||||
$query
|
||||
->forPortalValues($portalValues)
|
||||
->forLanguage($language)
|
||||
->published()
|
||||
->whereDate('published_at', $referenceNow->toDateString());
|
||||
->whereBetween('published_at', $todayRange);
|
||||
},
|
||||
])
|
||||
->orderByDesc('today_releases_count')
|
||||
|
|
@ -224,7 +251,7 @@ class HomePageData
|
|||
'spotlight' => $spotlight,
|
||||
'homeStats' => [
|
||||
'publishedCount' => $publishedQuery()->count(),
|
||||
'publishedToday' => $publishedQuery()->whereDate('published_at', $referenceNow->toDateString())->count(),
|
||||
'publishedToday' => $publishedQuery()->whereBetween('published_at', $todayRange)->count(),
|
||||
'archiveSince' => $oldestPublishedAt ? (int) Carbon::parse($oldestPublishedAt)->format('Y') : null,
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ class NewsroomDetailPageData
|
|||
'newsroomStats' => [
|
||||
'total' => $base()->count(),
|
||||
'recent' => $base()->where('published_at', '>=', $recentSince)->count(),
|
||||
'today' => $base()->whereDate('published_at', $referenceNow->toDateString())->count(),
|
||||
'today' => $base()->whereBetween('published_at', [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()])->count(),
|
||||
'totalHits' => (int) $base()->sum('hits'),
|
||||
'sinceYear' => $oldest ? (int) Carbon::parse($oldest)->format('Y') : null,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class TickerData
|
|||
private const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* @return list<array{time: string, text: string}>
|
||||
* @return list<array{time: string, text: string, href: ?string}>
|
||||
*/
|
||||
public function items(Portal $portal, string $language): array
|
||||
{
|
||||
|
|
@ -31,10 +31,15 @@ class TickerData
|
|||
->visibleInArchive()
|
||||
->orderByDesc('published_at')
|
||||
->limit(8)
|
||||
->get(['id', 'title', 'published_at'])
|
||||
->get(['id', 'title', 'slug', 'published_at'])
|
||||
->map(static fn (PressRelease $release): array => [
|
||||
'time' => $release->published_at?->format('H:i') ?? '',
|
||||
'text' => Str::limit((string) $release->title, 70),
|
||||
// Relativ, damit der gecachte Eintrag host-unabhängig bleibt
|
||||
// (der Header rendert auf allen Hosts des Portals identisch).
|
||||
'href' => $release->slug
|
||||
? route('release.detail', ['slug' => $release->slug, 'edition' => $language], absolute: false)
|
||||
: null,
|
||||
])
|
||||
->all();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class VeroeffentlichenPageData
|
|||
->visibleInArchive();
|
||||
|
||||
$archiveTotal = $publishedBase()->count();
|
||||
$publishedToday = $publishedBase()->whereDate('published_at', $referenceNow->toDateString())->count();
|
||||
$publishedToday = $publishedBase()->whereBetween('published_at', [$referenceNow->copy()->startOfDay(), $referenceNow->copy()->endOfDay()])->count();
|
||||
$oldest = $publishedBase()->min('published_at');
|
||||
$oldestYear = $oldest ? (int) Carbon::parse($oldest)->format('Y') : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,12 @@ class CategoryNavigation
|
|||
/**
|
||||
* Top-Level-Rubriken der aktiven Ausgabe als Navigationsitems.
|
||||
*
|
||||
* Sortiert nach Anzahl veröffentlichter Pressemitteilungen; `null` als
|
||||
* Limit liefert alle Rubriken (z. B. für das „Mehr“-Dropdown im Header).
|
||||
*
|
||||
* @return Collection<int, array{label:string, slug:string, href:string}>
|
||||
*/
|
||||
public static function items(int $limit = 9): Collection
|
||||
public static function items(?int $limit = 9): Collection
|
||||
{
|
||||
$portal = Portal::tryFrom((string) config('app.theme')) ?? Portal::Businessportal24;
|
||||
$language = app()->getLocale();
|
||||
|
|
@ -46,7 +49,7 @@ class CategoryNavigation
|
|||
->whereNull('parent_id')
|
||||
->orderByDesc('published_count')
|
||||
->orderBy('id')
|
||||
->limit($limit)
|
||||
->when($limit !== null, fn (Builder $query) => $query->limit($limit))
|
||||
->get()
|
||||
->map(function (Category $category): ?array {
|
||||
$translation = $category->translations->first();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue