User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline
Phase 8 (Rest) + Umbauten vom 10./11.06.: - Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker, PressReleaseCoverImage-Resolver - Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen, Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise) - Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt), geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE) - Quota-Stub (users.press_release_quota) + monatlicher Reset-Command - Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout) KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans): - API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route durch denselben Funnel (Blacklist, Quota, Status-Log) - Klassifikation Rot/Gelb/Gruen asynchron (Queue classification, OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log - Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen -> Auto-Publish; Scheduler publiziert nur gruene faellige PMs - Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl. Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung - Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override Suite: 442 passed, 4 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
|
|
@ -11,11 +12,13 @@ use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Veröffentlicht Pressemitteilungen mit Status `review` und einem
|
* Veröffentlicht Pressemitteilungen mit Status `review`, der KI-Klassifikation
|
||||||
* `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
|
* `green` und einem `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
|
||||||
*
|
*
|
||||||
* Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent:
|
* Läuft regelmäßig per Scheduler (siehe routes/console.php). Idempotent:
|
||||||
* berührt nur PRs in Review-Status — bereits publishte werden ignoriert.
|
* berührt nur grüne PRs in Review-Status — bereits publishte werden ignoriert.
|
||||||
|
* Gelb eingestufte PMs bleiben bewusst in der manuellen Admin-Queue, auch wenn
|
||||||
|
* ihr Termin fällig ist.
|
||||||
*
|
*
|
||||||
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
|
* Blacklist-Treffer landen wie beim manuellen Publish im Reject-Status mit
|
||||||
* Mail-Benachrichtigung des Autors.
|
* Mail-Benachrichtigung des Autors.
|
||||||
|
|
@ -43,6 +46,7 @@ class PublishScheduledPressReleases extends Command
|
||||||
|
|
||||||
$candidates = PressRelease::withoutGlobalScopes()
|
$candidates = PressRelease::withoutGlobalScopes()
|
||||||
->where('status', PressReleaseStatus::Review->value)
|
->where('status', PressReleaseStatus::Review->value)
|
||||||
|
->where('classification', PressReleaseClassification::Green->value)
|
||||||
->whereNotNull('scheduled_at')
|
->whereNotNull('scheduled_at')
|
||||||
->where('scheduled_at', '<=', $now)
|
->where('scheduled_at', '<=', $now)
|
||||||
->orderBy('scheduled_at')
|
->orderBy('scheduled_at')
|
||||||
|
|
|
||||||
36
app/Console/Commands/ResetMonthlyPressReleaseQuota.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den monatlichen PM-Kontingent-Verbrauch aller User zurück.
|
||||||
|
*
|
||||||
|
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Läuft per Scheduler
|
||||||
|
* am Monatsanfang (siehe routes/console.php).
|
||||||
|
*/
|
||||||
|
class ResetMonthlyPressReleaseQuota extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'press-releases:reset-monthly-quota';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Setzt den monatlichen PM-Kontingent-Verbrauch (press_release_quota_used_this_month) aller User auf 0 zurück.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$affected = User::query()
|
||||||
|
->where('press_release_quota_used_this_month', '>', 0)
|
||||||
|
->update(['press_release_quota_used_this_month' => 0]);
|
||||||
|
|
||||||
|
$this->info(sprintf('Kontingent-Verbrauch für %d User zurückgesetzt.', $affected));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Console/Commands/RunClassificationQueue.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbeitet die KI-Klassifikations-Queue zum Testen einmalig ab – ohne
|
||||||
|
* dauerhaft laufenden Queue-Worker.
|
||||||
|
*
|
||||||
|
* Standardmäßig werden alle anstehenden Jobs verarbeitet und der Worker
|
||||||
|
* beendet sich danach (--stop-when-empty). Mit --once nur ein einzelner Job.
|
||||||
|
*/
|
||||||
|
class RunClassificationQueue extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'classification:work {--once : Nur einen einzelnen Job verarbeiten}';
|
||||||
|
|
||||||
|
protected $description = 'Verarbeitet die KI-Klassifikations-Queue einmalig (ohne Dauer-Worker).';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$options = [
|
||||||
|
'--queue' => 'classification',
|
||||||
|
'--tries' => 3,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->option('once')) {
|
||||||
|
$options['--once'] = true;
|
||||||
|
} else {
|
||||||
|
$options['--stop-when-empty'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->call('queue:work', $options);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Enums/ImageLicenseType.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lizenz-/Herkunftstyp eines Pressemitteilungs-Bildes.
|
||||||
|
*
|
||||||
|
* Dient der rechtssicheren Erfassung von Bildrechten beim Upload.
|
||||||
|
*/
|
||||||
|
enum ImageLicenseType: string
|
||||||
|
{
|
||||||
|
case Own = 'own';
|
||||||
|
case CreativeCommons = 'cc';
|
||||||
|
case Commercial = 'commercial';
|
||||||
|
case Consent = 'consent';
|
||||||
|
case PressPr = 'press_pr';
|
||||||
|
case PublicDomain = 'public_domain';
|
||||||
|
case Other = 'other';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesbares Label für die UI.
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Own => 'Eigene Aufnahme',
|
||||||
|
self::Consent => 'Vom Urheber / Fotografen freigegeben',
|
||||||
|
self::Commercial => 'Agentur-/Stockbild-Lizenz',
|
||||||
|
self::CreativeCommons => 'Creative-Commons-Lizenz',
|
||||||
|
self::PressPr => 'Presse-/PR-Bild mit Nutzungsfreigabe',
|
||||||
|
self::PublicDomain => 'Gemeinfrei / Public Domain / CC0',
|
||||||
|
self::Other => 'Sonstige Lizenz / Sondervereinbarung',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob für diesen Typ eine Lizenz-URL Pflicht ist.
|
||||||
|
*/
|
||||||
|
public function requiresLicenseUrl(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::CreativeCommons, self::Commercial, self::PressPr], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob zusaetzliche Lizenzdetails verpflichtend sind.
|
||||||
|
*/
|
||||||
|
public function requiresLicenseDetail(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::CreativeCommons, self::Other], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionsliste für Selects: value => label.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach (self::cases() as $case) {
|
||||||
|
$options[$case->value] = $case->label();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Enums/PressReleaseClassification.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klassifikations-Score („Red Flag") laut Konzept §15.1.
|
||||||
|
*
|
||||||
|
* Entscheidet, ob eine Pressemitteilung überhaupt veröffentlicht wird:
|
||||||
|
* - Green → unauffällig, Veröffentlichungspfad
|
||||||
|
* - Yellow → grenzwertig, manuelle Review-Queue
|
||||||
|
* - Red → unzulässig, zurück an den Autor
|
||||||
|
*/
|
||||||
|
enum PressReleaseClassification: string
|
||||||
|
{
|
||||||
|
case Green = 'green';
|
||||||
|
case Yellow = 'yellow';
|
||||||
|
case Red = 'red';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Green => 'Grün',
|
||||||
|
self::Yellow => 'Gelb',
|
||||||
|
self::Red => 'Rot',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Enums/PressReleaseContentTier.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-Score-Stufe (Außenkommunikation, Konzept Update 2).
|
||||||
|
*
|
||||||
|
* Der numerische Score (0–100) bleibt plattform-intern; nach außen wird er auf
|
||||||
|
* drei Stufen gemappt. Schwellen sind über config/scoring.php kalibrierbar.
|
||||||
|
*/
|
||||||
|
enum PressReleaseContentTier: string
|
||||||
|
{
|
||||||
|
case Standard = 'standard';
|
||||||
|
case Geprueft = 'gepruft';
|
||||||
|
case Hochwertig = 'hochwertig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet die Stufe aus dem numerischen Score ab (kalibrierbar über
|
||||||
|
* config('scoring.content_score.tiers')).
|
||||||
|
*/
|
||||||
|
public static function fromScore(int $score): self
|
||||||
|
{
|
||||||
|
$tiers = config('scoring.content_score.tiers', []);
|
||||||
|
$hochwertig = (int) ($tiers['hochwertig'] ?? 80);
|
||||||
|
$gepruft = (int) ($tiers['gepruft'] ?? 60);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$score >= $hochwertig => self::Hochwertig,
|
||||||
|
$score >= $gepruft => self::Geprueft,
|
||||||
|
default => self::Standard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Standard => 'Standard',
|
||||||
|
self::Geprueft => 'Geprüft',
|
||||||
|
self::Hochwertig => 'Hochwertig',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob die Stufe öffentlich als Vertrauensindikator gezeigt wird. Standard
|
||||||
|
* wird laut Update 2 bewusst nicht beworben (kein Badge/Label).
|
||||||
|
*/
|
||||||
|
public function isPubliclyBadged(): bool
|
||||||
|
{
|
||||||
|
return $this !== self::Standard;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Enums/PressReleasePlaceholder.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiederverwendbares Set farbiger SVG-Titelbild-Platzhalter für
|
||||||
|
* Pressemitteilungen ohne eigenes Bild.
|
||||||
|
*
|
||||||
|
* Die Werte entsprechen den Dateinamen in
|
||||||
|
* `public/images/press-release-placeholders/<value>.svg`.
|
||||||
|
*/
|
||||||
|
enum PressReleasePlaceholder: string
|
||||||
|
{
|
||||||
|
case GridBlue = '01-grid-blue';
|
||||||
|
case GridGreen = '02-grid-green';
|
||||||
|
case GridAmber = '03-grid-amber';
|
||||||
|
case LinesBlue = '04-lines-blue';
|
||||||
|
case LinesGreen = '05-lines-green';
|
||||||
|
case LinesAmber = '06-lines-amber';
|
||||||
|
case DotsBlue = '07-dots-blue';
|
||||||
|
case DotsGreen = '08-dots-green';
|
||||||
|
case DotsAmber = '09-dots-amber';
|
||||||
|
case WavesBlue = '10-waves-blue';
|
||||||
|
case WavesGreen = '11-waves-green';
|
||||||
|
case WavesAmber = '12-waves-amber';
|
||||||
|
case EditorialBlue = '13-editorial-blue';
|
||||||
|
case EditorialGreen = '14-editorial-green';
|
||||||
|
case EditorialAmber = '15-editorial-amber';
|
||||||
|
case SignalBlue = '16-signal-blue';
|
||||||
|
case SignalGreen = '17-signal-green';
|
||||||
|
case SignalAmber = '18-signal-amber';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default-Variante, wenn nichts gesetzt ist.
|
||||||
|
*/
|
||||||
|
public static function default(): self
|
||||||
|
{
|
||||||
|
return self::GridBlue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Variante zu einem Roh-Wert oder den Default-Fallback.
|
||||||
|
*/
|
||||||
|
public static function fromValueOrDefault(?string $value): self
|
||||||
|
{
|
||||||
|
return self::tryFrom((string) $value) ?? self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministische Variante aus einem Seed (z. B. PM-ID/Titel), damit
|
||||||
|
* dieselbe PM immer denselben Platzhalter bekommt.
|
||||||
|
*/
|
||||||
|
public static function fromSeed(int|string $seed): self
|
||||||
|
{
|
||||||
|
$cases = self::cases();
|
||||||
|
|
||||||
|
return $cases[abs(crc32((string) $seed)) % count($cases)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlicher Asset-Pfad relativ zu `public/`.
|
||||||
|
*/
|
||||||
|
public function path(): string
|
||||||
|
{
|
||||||
|
return 'images/press-release-placeholders/'.$this->value.'.svg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesbares Label für die UI (Picker-Tooltips etc.).
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
$pattern = match (true) {
|
||||||
|
str_contains($this->value, 'grid') => 'Raster',
|
||||||
|
str_contains($this->value, 'lines') => 'Linien',
|
||||||
|
str_contains($this->value, 'dots') => 'Punkte',
|
||||||
|
str_contains($this->value, 'waves') => 'Wellen',
|
||||||
|
str_contains($this->value, 'editorial') => 'Editorial',
|
||||||
|
default => 'Signal',
|
||||||
|
};
|
||||||
|
|
||||||
|
$color = match (true) {
|
||||||
|
str_contains($this->value, 'blue') => 'Blau',
|
||||||
|
str_contains($this->value, 'green') => 'Grün',
|
||||||
|
default => 'Bernstein',
|
||||||
|
};
|
||||||
|
|
||||||
|
return $pattern.' · '.$color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
|
||||||
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
|
||||||
use App\Http\Resources\PressReleaseResource;
|
use App\Http\Resources\PressReleaseResource;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
@ -46,7 +49,10 @@ class PressReleaseController extends Controller
|
||||||
$company->portal->value,
|
$company->portal->value,
|
||||||
$validated['language'],
|
$validated['language'],
|
||||||
),
|
),
|
||||||
'status' => $validated['status'] ?? 'draft',
|
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
|
||||||
|
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
||||||
|
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
||||||
|
'status' => PressReleaseStatus::Draft->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return PressReleaseResource::make(
|
return PressReleaseResource::make(
|
||||||
|
|
@ -101,11 +107,51 @@ class PressReleaseController extends Controller
|
||||||
|
|
||||||
$pressRelease->save();
|
$pressRelease->save();
|
||||||
|
|
||||||
|
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
|
||||||
|
if ($pressRelease->wasChanged(['title', 'text'])) {
|
||||||
|
$service = app(PressReleaseService::class);
|
||||||
|
$service->reclassifyIfClassified($pressRelease);
|
||||||
|
$service->rescoreIfScored($pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
return PressReleaseResource::make(
|
return PressReleaseResource::make(
|
||||||
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
$pressRelease->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reicht eine PM zur Prüfung ein – der einzige API-Weg nach `review`.
|
||||||
|
*
|
||||||
|
* Kapselt denselben Funnel wie das Web-Formular: Blacklist-Hard-Filter,
|
||||||
|
* Statuswechsel, Status-Log und Quota werden über den
|
||||||
|
* `PressReleaseService` garantiert durchlaufen. `published` bleibt über die
|
||||||
|
* API unerreichbar.
|
||||||
|
*/
|
||||||
|
public function submit(Request $request, int $pressRelease, PressReleaseService $service): PressReleaseResource|JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||||||
|
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);
|
||||||
|
abort_unless($pressRelease !== null, 403);
|
||||||
|
|
||||||
|
if (! in_array($pressRelease->status->value, ['draft', 'rejected'], true)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Only draft or rejected press releases may be submitted for review.',
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->submitForReview($pressRelease);
|
||||||
|
} catch (BlacklistViolationException $exception) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PressReleaseResource::make(
|
||||||
|
$pressRelease->fresh()->load(['company:id,name,portal,legacy_portal,legacy_id', 'category.translations', 'images'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
public function destroy(Request $request, int $pressRelease): JsonResponse|Response
|
||||||
{
|
{
|
||||||
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\V1;
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
use App\Enums\PressReleaseStatus;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StorePressReleaseRequest extends FormRequest
|
class StorePressReleaseRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
|
@ -32,10 +30,6 @@ class StorePressReleaseRequest extends FormRequest
|
||||||
'text' => ['required', 'string'],
|
'text' => ['required', 'string'],
|
||||||
'backlink_url' => ['nullable', 'url', 'max:255'],
|
'backlink_url' => ['nullable', 'url', 'max:255'],
|
||||||
'keywords' => ['nullable', 'string', 'max:255'],
|
'keywords' => ['nullable', 'string', 'max:255'],
|
||||||
'status' => ['nullable', Rule::in([
|
|
||||||
PressReleaseStatus::Draft->value,
|
|
||||||
PressReleaseStatus::Review->value,
|
|
||||||
])],
|
|
||||||
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
||||||
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
||||||
'no_export' => ['nullable', 'boolean'],
|
'no_export' => ['nullable', 'boolean'],
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\V1;
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
use App\Enums\PressReleaseStatus;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdatePressReleaseRequest extends FormRequest
|
class UpdatePressReleaseRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
|
@ -32,10 +30,6 @@ class UpdatePressReleaseRequest extends FormRequest
|
||||||
'text' => ['sometimes', 'required', 'string'],
|
'text' => ['sometimes', 'required', 'string'],
|
||||||
'backlink_url' => ['nullable', 'url', 'max:255'],
|
'backlink_url' => ['nullable', 'url', 'max:255'],
|
||||||
'keywords' => ['nullable', 'string', 'max:255'],
|
'keywords' => ['nullable', 'string', 'max:255'],
|
||||||
'status' => ['sometimes', Rule::in([
|
|
||||||
PressReleaseStatus::Draft->value,
|
|
||||||
PressReleaseStatus::Review->value,
|
|
||||||
])],
|
|
||||||
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
'teaser_begin' => ['nullable', 'integer', 'min:0'],
|
||||||
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
'teaser_end' => ['nullable', 'integer', 'min:0'],
|
||||||
'no_export' => ['nullable', 'boolean'],
|
'no_export' => ['nullable', 'boolean'],
|
||||||
|
|
|
||||||
107
app/Jobs/ClassifyPressRelease.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\KiAudit;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
|
use App\Services\PressRelease\Classification\ClassificationManager;
|
||||||
|
use App\Services\PressRelease\Classification\ClassificationResult;
|
||||||
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klassifiziert eine Pressemitteilung asynchron (Red Flag, Konzept §15.1).
|
||||||
|
*
|
||||||
|
* Läuft auf der dedizierten Queue „classification". Nutzt den konfigurierten
|
||||||
|
* KI-Treiber; bei Ausfall wird auf den deterministischen Treiber
|
||||||
|
* zurückgefallen, damit eine Einreichung nie an einer KI-Störung hängen
|
||||||
|
* bleibt. Ergebnis landet in `press_releases.classification`/`classified_at`
|
||||||
|
* und als unveränderlicher Eintrag in `ki_audits`.
|
||||||
|
*
|
||||||
|
* Das Ergebnis wird gespeichert/auditiert und anschließend über
|
||||||
|
* PressReleaseService::routeByClassification() in den Status überführt
|
||||||
|
* (Rot→rejected, Gelb→review, Grün→publish).
|
||||||
|
*/
|
||||||
|
class ClassifyPressRelease implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $pressReleaseId Zu prüfende PM
|
||||||
|
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
|
||||||
|
* Grün→publish). Beim regulären Einreichen `true`;
|
||||||
|
* beim manuellen Admin-Re-Check `false` (nur
|
||||||
|
* klassifizieren/auditieren, Status unverändert).
|
||||||
|
* @param string|null $providerOverride Optionaler abweichender Treiber
|
||||||
|
* (z. B. 'openai'|'deterministic') für diesen Lauf.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $pressReleaseId,
|
||||||
|
public readonly bool $route = true,
|
||||||
|
public readonly ?string $providerOverride = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(ClassificationManager $manager, PressReleaseService $service): void
|
||||||
|
{
|
||||||
|
$pressRelease = PressRelease::withoutGlobalScopes()->find($this->pressReleaseId);
|
||||||
|
|
||||||
|
if ($pressRelease === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->classify($manager, $pressRelease);
|
||||||
|
|
||||||
|
$pressRelease->forceFill([
|
||||||
|
'classification' => $result->classification->value,
|
||||||
|
'classified_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
KiAudit::query()->create([
|
||||||
|
'press_release_id' => $pressRelease->id,
|
||||||
|
'type' => KiAudit::TYPE_CLASSIFICATION,
|
||||||
|
'provider' => $result->provider,
|
||||||
|
'model' => $result->model,
|
||||||
|
'result' => $result->classification->value,
|
||||||
|
'reason' => $result->reasonText(),
|
||||||
|
'raw_response' => $result->rawResponse,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $this->route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText());
|
||||||
|
} catch (BlacklistViolationException) {
|
||||||
|
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klassifiziert über den aktiven (oder explizit gewählten) Treiber; bei
|
||||||
|
* Fehler greift der deterministische Fallback, damit das Ergebnis
|
||||||
|
* nachvollziehbar bleibt.
|
||||||
|
*/
|
||||||
|
private function classify(ClassificationManager $manager, PressRelease $pressRelease): ClassificationResult
|
||||||
|
{
|
||||||
|
$provider = $this->providerOverride ?: $manager->getDefaultDriver();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $manager->driver($provider)->classify($pressRelease);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [
|
||||||
|
'press_release_id' => $pressRelease->id,
|
||||||
|
'provider' => $provider,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $manager->driver('deterministic')->classify($pressRelease);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/Jobs/ScorePressRelease.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseContentTier;
|
||||||
|
use App\Models\KiAudit;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\ContentScore\ContentScoreManager;
|
||||||
|
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet den Content-Score einer Pressemitteilung asynchron (Konzept §15.2).
|
||||||
|
*
|
||||||
|
* Läuft auf der Queue „classification" (gemeinsam mit der Klassifikation, ein
|
||||||
|
* Drain-Befehl). Nutzt den konfigurierten Treiber; bei Ausfall greift der
|
||||||
|
* deterministische Fallback. Ergebnis: `content_score` + abgeleitete
|
||||||
|
* `content_tier` + `scored_at`, plus `ki_audits`-Eintrag (type=content_score).
|
||||||
|
*
|
||||||
|
* Reine Qualitätsbewertung – ändert nie den Status (das macht die
|
||||||
|
* Klassifikation).
|
||||||
|
*/
|
||||||
|
class ScorePressRelease implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $pressReleaseId,
|
||||||
|
public readonly ?string $providerOverride = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(ContentScoreManager $manager): void
|
||||||
|
{
|
||||||
|
$pressRelease = PressRelease::withoutGlobalScopes()->find($this->pressReleaseId);
|
||||||
|
|
||||||
|
if ($pressRelease === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->score($manager, $pressRelease);
|
||||||
|
$tier = PressReleaseContentTier::fromScore($result->score);
|
||||||
|
|
||||||
|
$pressRelease->forceFill([
|
||||||
|
'content_score' => $result->score,
|
||||||
|
'content_tier' => $tier->value,
|
||||||
|
'scored_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
KiAudit::query()->create([
|
||||||
|
'press_release_id' => $pressRelease->id,
|
||||||
|
'type' => KiAudit::TYPE_CONTENT_SCORE,
|
||||||
|
'provider' => $result->provider,
|
||||||
|
'model' => $result->model,
|
||||||
|
'result' => (string) $result->score,
|
||||||
|
'reason' => $tier->label(),
|
||||||
|
'raw_response' => $result->rawResponse,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewertet über den aktiven (oder explizit gewählten) Treiber; bei Fehler
|
||||||
|
* greift der deterministische Fallback.
|
||||||
|
*/
|
||||||
|
private function score(ContentScoreManager $manager, PressRelease $pressRelease): ContentScoreResult
|
||||||
|
{
|
||||||
|
$provider = $this->providerOverride ?: $manager->getDefaultDriver();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $manager->driver($provider)->score($pressRelease);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('Content-Score fiel auf den deterministischen Treiber zurück.', [
|
||||||
|
'press_release_id' => $pressRelease->id,
|
||||||
|
'provider' => $provider,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $manager->driver('deterministic')->score($pressRelease);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Models/KiAudit.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\KiAuditFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log jeder KI-Entscheidung (Klassifikation / Content-Score).
|
||||||
|
*
|
||||||
|
* Schreibt für jede Bewertung einen unveränderlichen Eintrag inkl.
|
||||||
|
* Anbieter, Modell, Ergebnis, Begründung und Roh-Antwort – für
|
||||||
|
* Nachvollziehbarkeit und Nachweispflicht (DSGVO).
|
||||||
|
*/
|
||||||
|
class KiAudit extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<KiAuditFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public const TYPE_CLASSIFICATION = 'classification';
|
||||||
|
|
||||||
|
public const TYPE_CONTENT_SCORE = 'content_score';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'press_release_id',
|
||||||
|
'type',
|
||||||
|
'provider',
|
||||||
|
'model',
|
||||||
|
'result',
|
||||||
|
'reason',
|
||||||
|
'raw_response',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'raw_response' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pressRelease(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PressRelease::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\Portal;
|
use App\Enums\Portal;
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
|
use App\Enums\PressReleaseContentTier;
|
||||||
|
use App\Enums\PressReleasePlaceholder;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Models\Concerns\HasUniqueSlug;
|
use App\Models\Concerns\HasUniqueSlug;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
|
@ -14,6 +17,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\HtmlString;
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class PressRelease extends Model
|
class PressRelease extends Model
|
||||||
|
|
@ -21,6 +25,13 @@ class PressRelease extends Model
|
||||||
/** @use HasFactory<PressReleaseFactory> */
|
/** @use HasFactory<PressReleaseFactory> */
|
||||||
use HasFactory, HasUniqueSlug, SoftDeletes;
|
use HasFactory, HasUniqueSlug, SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzeige-Zeitzone für vom Nutzer erfasste Termine (scheduled_at,
|
||||||
|
* embargo_at). In der Datenbank wird weiterhin UTC gespeichert
|
||||||
|
* (config app.timezone).
|
||||||
|
*/
|
||||||
|
public const DISPLAY_TIMEZONE = 'Europe/Berlin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
|
|
@ -37,6 +48,13 @@ class PressRelease extends Model
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::addGlobalScope(new PortalScope);
|
static::addGlobalScope(new PortalScope);
|
||||||
|
|
||||||
|
static::creating(function (self $pressRelease): void {
|
||||||
|
if (blank($pressRelease->placeholder_variant)) {
|
||||||
|
$seed = $pressRelease->uuid ?? $pressRelease->title ?? (string) now()->timestamp;
|
||||||
|
$pressRelease->placeholder_variant = PressReleasePlaceholder::fromSeed($seed)->value;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -51,9 +69,15 @@ class PressRelease extends Model
|
||||||
'slug',
|
'slug',
|
||||||
'text',
|
'text',
|
||||||
'boilerplate_override',
|
'boilerplate_override',
|
||||||
|
'placeholder_variant',
|
||||||
'backlink_url',
|
'backlink_url',
|
||||||
'keywords',
|
'keywords',
|
||||||
'status',
|
'status',
|
||||||
|
'classification',
|
||||||
|
'classified_at',
|
||||||
|
'content_score',
|
||||||
|
'content_tier',
|
||||||
|
'scored_at',
|
||||||
'hits',
|
'hits',
|
||||||
'teaser_begin',
|
'teaser_begin',
|
||||||
'teaser_end',
|
'teaser_end',
|
||||||
|
|
@ -69,7 +93,13 @@ class PressRelease extends Model
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'portal' => Portal::class,
|
'portal' => Portal::class,
|
||||||
|
'placeholder_variant' => PressReleasePlaceholder::class,
|
||||||
'status' => PressReleaseStatus::class,
|
'status' => PressReleaseStatus::class,
|
||||||
|
'classification' => PressReleaseClassification::class,
|
||||||
|
'classified_at' => 'datetime',
|
||||||
|
'content_score' => 'integer',
|
||||||
|
'content_tier' => PressReleaseContentTier::class,
|
||||||
|
'scored_at' => 'datetime',
|
||||||
'hits' => 'integer',
|
'hits' => 'integer',
|
||||||
'teaser_begin' => 'integer',
|
'teaser_begin' => 'integer',
|
||||||
'teaser_end' => 'integer',
|
'teaser_end' => 'integer',
|
||||||
|
|
@ -81,6 +111,22 @@ class PressRelease extends Model
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geplanter Veröffentlichungstermin in der Anzeige-Zeitzone (Europe/Berlin).
|
||||||
|
*/
|
||||||
|
public function scheduledAtLocal(): ?Carbon
|
||||||
|
{
|
||||||
|
return $this->scheduled_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sperrfrist (Embargo) in der Anzeige-Zeitzone (Europe/Berlin).
|
||||||
|
*/
|
||||||
|
public function embargoAtLocal(): ?Carbon
|
||||||
|
{
|
||||||
|
return $this->embargo_at?->copy()->setTimezone(self::DISPLAY_TIMEZONE);
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|
@ -116,6 +162,11 @@ class PressRelease extends Model
|
||||||
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
|
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function kiAudits(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(KiAudit::class)->orderByDesc('created_at');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display-ready text. Returns sanitized HTML for Phase-7+ PMs and
|
* Display-ready text. Returns sanitized HTML for Phase-7+ PMs and
|
||||||
* <p>/<br>-wrapped legacy plain text for older imports.
|
* <p>/<br>-wrapped legacy plain text for older imports.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ImageLicenseType;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
@ -19,6 +20,16 @@ class PressReleaseImage extends Model
|
||||||
'title',
|
'title',
|
||||||
'description',
|
'description',
|
||||||
'copyright',
|
'copyright',
|
||||||
|
'author',
|
||||||
|
'license_type',
|
||||||
|
'license_detail',
|
||||||
|
'license_url',
|
||||||
|
'source_url',
|
||||||
|
'persons_consent',
|
||||||
|
'people_rights_status',
|
||||||
|
'property_rights_status',
|
||||||
|
'rights_notes',
|
||||||
|
'rights_confirmed_at',
|
||||||
'is_preview',
|
'is_preview',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'width',
|
'width',
|
||||||
|
|
@ -32,6 +43,9 @@ class PressReleaseImage extends Model
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'variants' => 'array',
|
'variants' => 'array',
|
||||||
|
'license_type' => ImageLicenseType::class,
|
||||||
|
'persons_consent' => 'boolean',
|
||||||
|
'rights_confirmed_at' => 'datetime',
|
||||||
'is_preview' => 'boolean',
|
'is_preview' => 'boolean',
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
'width' => 'integer',
|
'width' => 'integer',
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ class User extends Authenticatable
|
||||||
'legacy_portal',
|
'legacy_portal',
|
||||||
'legacy_id',
|
'legacy_id',
|
||||||
'password',
|
'password',
|
||||||
|
'press_release_quota',
|
||||||
|
'press_release_quota_used_this_month',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -73,9 +75,23 @@ class User extends Authenticatable
|
||||||
'last_seen_at' => 'datetime',
|
'last_seen_at' => 'datetime',
|
||||||
'deleted_at' => 'datetime',
|
'deleted_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'press_release_quota' => 'integer',
|
||||||
|
'press_release_quota_used_this_month' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbleibendes PM-Kontingent in diesem Monat.
|
||||||
|
*
|
||||||
|
* Temporärer Stub bis zum echten Tarif-/Credit-Modul. Die Schnittstelle
|
||||||
|
* (`pressReleaseQuotaRemaining()`) bleibt stabil, damit das
|
||||||
|
* Veröffentlichungs-Modal nicht neu gebaut werden muss.
|
||||||
|
*/
|
||||||
|
public function pressReleaseQuotaRemaining(): int
|
||||||
|
{
|
||||||
|
return max(0, (int) $this->press_release_quota - (int) $this->press_release_quota_used_this_month);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user's initials
|
* Get the user's initials
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services\Customer;
|
namespace App\Services\Customer;
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
@ -78,6 +79,30 @@ class CustomerCompanyContext
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Company>
|
||||||
|
*/
|
||||||
|
public function searchCompaniesFor(User $user, string $term = '', ?int $selectedCompanyId = null, int $limit = 10): Collection
|
||||||
|
{
|
||||||
|
$term = Portal::stripTrailingAbbreviation($term);
|
||||||
|
$limit = max(1, $limit);
|
||||||
|
|
||||||
|
if ($term === '') {
|
||||||
|
return $this->companyOptionsWithSelected($user, $selectedCompanyId, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->companyOptionQuery($user)
|
||||||
|
->where(function (Builder $query) use ($term): void {
|
||||||
|
$query->where('companies.name', 'like', '%'.$term.'%')
|
||||||
|
->orWhere('companies.slug', 'like', '%'.$term.'%')
|
||||||
|
->orWhere('companies.email', 'like', '%'.$term.'%');
|
||||||
|
})
|
||||||
|
->orderBy('companies.name')
|
||||||
|
->orderBy('companies.id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
public function companyCountFor(User $user): int
|
public function companyCountFor(User $user): int
|
||||||
{
|
{
|
||||||
return $this->accessibleCompanyQuery($user)->count();
|
return $this->accessibleCompanyQuery($user)->count();
|
||||||
|
|
@ -181,6 +206,41 @@ class CustomerCompanyContext
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Builder<Company>
|
||||||
|
*/
|
||||||
|
private function companyOptionQuery(User $user): Builder
|
||||||
|
{
|
||||||
|
return $this->accessibleCompanyQuery($user)
|
||||||
|
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Company>
|
||||||
|
*/
|
||||||
|
private function companyOptionsWithSelected(User $user, ?int $selectedCompanyId, int $limit): Collection
|
||||||
|
{
|
||||||
|
$selectedCompany = $selectedCompanyId
|
||||||
|
? $this->companyOptionQuery($user)->whereKey($selectedCompanyId)->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$companies = $this->companyOptionQuery($user)
|
||||||
|
->when($selectedCompany, fn (Builder $query): Builder => $query->whereKeyNot($selectedCompany->id))
|
||||||
|
->latest('companies.created_at')
|
||||||
|
->latest('companies.id')
|
||||||
|
->limit($selectedCompany ? max(0, $limit - 1) : $limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if (! $selectedCompany) {
|
||||||
|
return $companies;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $companies
|
||||||
|
->prepend($selectedCompany)
|
||||||
|
->unique('id')
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
private function userCanAccessCompany(User $user, int $companyId): bool
|
private function userCanAccessCompany(User $user, int $companyId): bool
|
||||||
{
|
{
|
||||||
return $this->accessibleCompanyQuery($user)
|
return $this->accessibleCompanyQuery($user)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ class ImageService
|
||||||
'thumb' => ['width' => 320, 'height' => 240],
|
'thumb' => ['width' => 320, 'height' => 240],
|
||||||
'medium' => ['width' => 800, 'height' => 600],
|
'medium' => ['width' => 800, 'height' => 600],
|
||||||
'large' => ['width' => 1600, 'height' => 1200],
|
'large' => ['width' => 1600, 'height' => 1200],
|
||||||
|
// Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px.
|
||||||
|
'cover' => ['width' => 1280, 'height' => 580],
|
||||||
];
|
];
|
||||||
|
|
||||||
public const ALLOWED_LOGO_MIME_TYPES = [
|
public const ALLOWED_LOGO_MIME_TYPES = [
|
||||||
|
|
@ -60,7 +62,7 @@ class ImageService
|
||||||
|
|
||||||
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
|
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||||
|
|
||||||
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB
|
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB
|
||||||
|
|
||||||
public function __construct(private readonly string $disk = 'public') {}
|
public function __construct(private readonly string $disk = 'public') {}
|
||||||
|
|
||||||
|
|
@ -99,8 +101,9 @@ class ImageService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists a freshly uploaded press release image and generates all
|
* Persists a freshly uploaded press release image, generates all variants
|
||||||
* variants. Original is stored under `press-releases/{id}/images`.
|
* and discards the original upload. The canonical stored path points to
|
||||||
|
* the cover variant to keep storage usage predictable.
|
||||||
*
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* path: string,
|
* path: string,
|
||||||
|
|
@ -122,9 +125,6 @@ class ImageService
|
||||||
$disk = $this->disk();
|
$disk = $this->disk();
|
||||||
$disk->put($relativePath, $upload->get(), 'public');
|
$disk->put($relativePath, $upload->get(), 'public');
|
||||||
|
|
||||||
$absolute = $disk->path($relativePath);
|
|
||||||
$size = @getimagesize($absolute) ?: [null, null];
|
|
||||||
|
|
||||||
$variants = $this->generateVariants(
|
$variants = $this->generateVariants(
|
||||||
$disk,
|
$disk,
|
||||||
$relativePath,
|
$relativePath,
|
||||||
|
|
@ -133,11 +133,19 @@ class ImageService
|
||||||
cover: true,
|
cover: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$coverPath = $variants['cover'] ?? $relativePath;
|
||||||
|
$coverAbsolute = $disk->path($coverPath);
|
||||||
|
$coverSize = @getimagesize($coverAbsolute) ?: [null, null];
|
||||||
|
|
||||||
|
if ($coverPath !== $relativePath && $disk->exists($relativePath)) {
|
||||||
|
$disk->delete($relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'path' => $relativePath,
|
'path' => $coverPath,
|
||||||
'variants' => $variants,
|
'variants' => $variants,
|
||||||
'width' => is_int($size[0] ?? null) ? $size[0] : null,
|
'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null,
|
||||||
'height' => is_int($size[1] ?? null) ? $size[1] : null,
|
'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null,
|
||||||
'mime' => $upload->getMimeType(),
|
'mime' => $upload->getMimeType(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\Classification;
|
||||||
|
|
||||||
|
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
|
||||||
|
use App\Services\PressRelease\Classification\Drivers\DeterministicClassificationDriver;
|
||||||
|
use App\Services\PressRelease\Classification\Drivers\OpenAiClassificationDriver;
|
||||||
|
use Illuminate\Support\Manager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst den aktiven Klassifikations-Treiber anhand von
|
||||||
|
* config('scoring.classification.provider') auf (Laravel-Manager-Pattern).
|
||||||
|
*
|
||||||
|
* Weitere Anbieter (Anthropic, Gemini) werden später als zusätzliche
|
||||||
|
* create*Driver()-Methoden ergänzt.
|
||||||
|
*/
|
||||||
|
class ClassificationManager extends Manager
|
||||||
|
{
|
||||||
|
public function getDefaultDriver(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->config->get('scoring.classification.provider') ?: 'deterministic');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDeterministicDriver(): ClassificationDriver
|
||||||
|
{
|
||||||
|
return $this->container->make(DeterministicClassificationDriver::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createOpenaiDriver(): ClassificationDriver
|
||||||
|
{
|
||||||
|
return $this->container->make(OpenAiClassificationDriver::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\Classification;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer KI-Klassifikation (Red Flag, Konzept §15.1).
|
||||||
|
*
|
||||||
|
* Provider-neutral: jeder Treiber liefert dasselbe Schema zurück, damit der
|
||||||
|
* Job das Ergebnis einheitlich persistieren und auditieren kann.
|
||||||
|
*/
|
||||||
|
final class ClassificationResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $reasons Begründungen der KI (kann leer sein)
|
||||||
|
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly PressReleaseClassification $classification,
|
||||||
|
public readonly array $reasons,
|
||||||
|
public readonly string $provider,
|
||||||
|
public readonly ?string $model,
|
||||||
|
public readonly array $rawResponse,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function reasonText(): ?string
|
||||||
|
{
|
||||||
|
return $this->reasons === [] ? null : implode('; ', $this->reasons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\Classification\Contracts;
|
||||||
|
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\Classification\ClassificationResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertrag für austauschbare Klassifikations-Treiber (OpenAI, Anthropic,
|
||||||
|
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
|
||||||
|
*/
|
||||||
|
interface ClassificationDriver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Klassifiziert eine Pressemitteilung (green/yellow/red).
|
||||||
|
*
|
||||||
|
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job
|
||||||
|
* weicht dann auf den deterministischen Fallback aus.
|
||||||
|
*/
|
||||||
|
public function classify(PressRelease $pressRelease): ClassificationResult;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\Classification\Drivers;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\BlacklistService;
|
||||||
|
use App\Services\PressRelease\Classification\ClassificationResult;
|
||||||
|
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regelbasierter Fallback ohne externe API.
|
||||||
|
*
|
||||||
|
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt
|
||||||
|
* (Timeout, Rate-Limit, Fehler). Bewertet ausschließlich über die
|
||||||
|
* Wort-Blacklist: Treffer → Rot, sonst Grün. Liefert nie Gelb.
|
||||||
|
*/
|
||||||
|
class DeterministicClassificationDriver implements ClassificationDriver
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BlacklistService $blacklist) {}
|
||||||
|
|
||||||
|
public function classify(PressRelease $pressRelease): ClassificationResult
|
||||||
|
{
|
||||||
|
$word = $this->blacklist->findInPressRelease($pressRelease);
|
||||||
|
|
||||||
|
if ($word !== null) {
|
||||||
|
return new ClassificationResult(
|
||||||
|
classification: PressReleaseClassification::Red,
|
||||||
|
reasons: [sprintf('Unzulässiges Wort gefunden: "%s".', $word)],
|
||||||
|
provider: 'deterministic',
|
||||||
|
model: null,
|
||||||
|
rawResponse: ['matched_word' => $word],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ClassificationResult(
|
||||||
|
classification: PressReleaseClassification::Green,
|
||||||
|
reasons: [],
|
||||||
|
provider: 'deterministic',
|
||||||
|
model: null,
|
||||||
|
rawResponse: ['matched_word' => null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\Classification\Drivers;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\Classification\ClassificationResult;
|
||||||
|
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klassifikations-Treiber auf Basis der OpenAI Chat-Completions-API.
|
||||||
|
*
|
||||||
|
* Liest Zugangsdaten/Modell aus config/services.openai. Verlangt eine
|
||||||
|
* strukturierte JSON-Antwort `{classification, reasons[]}`. Bei fehlendem
|
||||||
|
* Key, Transport-/HTTP-Fehlern oder ungültiger Antwort wird eine Exception
|
||||||
|
* geworfen, sodass der Job auf den deterministischen Treiber ausweicht.
|
||||||
|
*/
|
||||||
|
class OpenAiClassificationDriver implements ClassificationDriver
|
||||||
|
{
|
||||||
|
public function classify(PressRelease $pressRelease): ClassificationResult
|
||||||
|
{
|
||||||
|
$config = config('services.openai');
|
||||||
|
$apiKey = $config['api_key'] ?? null;
|
||||||
|
|
||||||
|
if (blank($apiKey)) {
|
||||||
|
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = config('scoring.classification.model') ?: ($config['model'] ?? 'gpt-5.4-mini');
|
||||||
|
$timeout = (int) (config('scoring.classification.timeout') ?: ($config['timeout'] ?? 30));
|
||||||
|
|
||||||
|
$response = Http::withToken($apiKey)
|
||||||
|
->timeout($timeout)
|
||||||
|
->acceptJson()
|
||||||
|
->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [
|
||||||
|
'model' => $model,
|
||||||
|
'response_format' => ['type' => 'json_object'],
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'system', 'content' => $this->systemPrompt()],
|
||||||
|
['role' => 'user', 'content' => $this->userPrompt($pressRelease)],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new RuntimeException("OpenAI-Klassifikation fehlgeschlagen (HTTP {$response->status()}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response->json();
|
||||||
|
$content = data_get($payload, 'choices.0.message.content');
|
||||||
|
|
||||||
|
if (! is_string($content) || trim($content) === '') {
|
||||||
|
throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = json_decode($content, true);
|
||||||
|
|
||||||
|
if (! is_array($parsed) || ! isset($parsed['classification'])) {
|
||||||
|
throw new RuntimeException('OpenAI-Antwort war kein gültiges Klassifikations-JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$classification = PressReleaseClassification::tryFrom((string) $parsed['classification']);
|
||||||
|
|
||||||
|
if ($classification === null) {
|
||||||
|
throw new RuntimeException('OpenAI lieferte einen unbekannten Klassifikationswert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasons = array_values(array_filter(array_map(
|
||||||
|
static fn ($reason): string => (string) $reason,
|
||||||
|
is_array($parsed['reasons'] ?? null) ? $parsed['reasons'] : [],
|
||||||
|
)));
|
||||||
|
|
||||||
|
return new ClassificationResult(
|
||||||
|
classification: $classification,
|
||||||
|
reasons: $reasons,
|
||||||
|
provider: 'openai',
|
||||||
|
model: $model,
|
||||||
|
rawResponse: is_array($payload) ? $payload : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function systemPrompt(): string
|
||||||
|
{
|
||||||
|
return <<<'PROMPT'
|
||||||
|
Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal.
|
||||||
|
Bewerte, ob eine eingereichte Pressemitteilung veröffentlicht werden darf.
|
||||||
|
|
||||||
|
Klassifiziere genau eine der drei Stufen:
|
||||||
|
- "green": unauffällig, kann veröffentlicht werden.
|
||||||
|
- "yellow": grenzwertig/unklar, sollte manuell geprüft werden.
|
||||||
|
- "red": unzulässig, darf nicht veröffentlicht werden.
|
||||||
|
|
||||||
|
Prüfe insbesondere diese Faktoren (Red Flags):
|
||||||
|
- reine Werbung statt journalistischer Pressemitteilung
|
||||||
|
- beleidigende, diskriminierende oder hetzerische Inhalte
|
||||||
|
- rechtlich heikle Aussagen (z. B. unbelegte Heil-/Gewinnversprechen,
|
||||||
|
Verleumdung, Aufruf zu Straftaten)
|
||||||
|
- Spam-Muster (sinnlose Keyword-Wiederholung, irreführende Links)
|
||||||
|
- unseriöse oder offensichtlich falsche Versprechen
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
|
||||||
|
{"classification": "green|yellow|red", "reasons": ["kurze Begründung", ...]}
|
||||||
|
Bei "green" darf "reasons" leer sein. Schreibe Begründungen auf Deutsch.
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userPrompt(PressRelease $pressRelease): string
|
||||||
|
{
|
||||||
|
$title = (string) $pressRelease->title;
|
||||||
|
$text = trim(strip_tags((string) $pressRelease->text));
|
||||||
|
|
||||||
|
return "Titel:\n{$title}\n\nText:\n{$text}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\ContentScore;
|
||||||
|
|
||||||
|
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
|
||||||
|
use App\Services\PressRelease\ContentScore\Drivers\DeterministicContentScoreDriver;
|
||||||
|
use App\Services\PressRelease\ContentScore\Drivers\OpenAiContentScoreDriver;
|
||||||
|
use Illuminate\Support\Manager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst den aktiven Content-Score-Treiber anhand von
|
||||||
|
* config('scoring.content_score.provider') auf (Laravel-Manager-Pattern).
|
||||||
|
*/
|
||||||
|
class ContentScoreManager extends Manager
|
||||||
|
{
|
||||||
|
public function getDefaultDriver(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->config->get('scoring.content_score.provider') ?: 'deterministic');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDeterministicDriver(): ContentScoreDriver
|
||||||
|
{
|
||||||
|
return $this->container->make(DeterministicContentScoreDriver::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createOpenaiDriver(): ContentScoreDriver
|
||||||
|
{
|
||||||
|
return $this->container->make(OpenAiContentScoreDriver::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\ContentScore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer Content-Score-Berechnung (Qualität, Konzept §15.2).
|
||||||
|
*
|
||||||
|
* Provider-neutral: jeder Treiber liefert einen 0–100-Score plus optionalen
|
||||||
|
* Faktor-Breakdown, damit der Job einheitlich persistieren/auditieren kann.
|
||||||
|
*/
|
||||||
|
final class ContentScoreResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $score 0–100
|
||||||
|
* @param array<string, mixed> $breakdown Faktor-Aufschlüsselung (optional)
|
||||||
|
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $score,
|
||||||
|
public readonly array $breakdown,
|
||||||
|
public readonly string $provider,
|
||||||
|
public readonly ?string $model,
|
||||||
|
public readonly array $rawResponse,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\ContentScore\Contracts;
|
||||||
|
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertrag für austauschbare Content-Score-Treiber (OpenAI, später Anthropic/
|
||||||
|
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
|
||||||
|
*/
|
||||||
|
interface ContentScoreDriver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Berechnet den Content-Score (0–100) einer Pressemitteilung.
|
||||||
|
*
|
||||||
|
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job weicht
|
||||||
|
* dann auf den deterministischen Fallback aus.
|
||||||
|
*/
|
||||||
|
public function score(PressRelease $pressRelease): ContentScoreResult;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\ContentScore\Drivers;
|
||||||
|
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||||
|
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regelbasierter Fallback ohne externe API.
|
||||||
|
*
|
||||||
|
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt. Bewertet
|
||||||
|
* messbare, formale Faktoren (Textlänge, Bild, Quelle, Headline, Vollständigkeit)
|
||||||
|
* zu einem groben 0–100-Score. Ersetzt keine inhaltliche KI-Bewertung, hält den
|
||||||
|
* Funnel aber lauffähig.
|
||||||
|
*/
|
||||||
|
class DeterministicContentScoreDriver implements ContentScoreDriver
|
||||||
|
{
|
||||||
|
public function score(PressRelease $pressRelease): ContentScoreResult
|
||||||
|
{
|
||||||
|
$breakdown = [];
|
||||||
|
$score = 40; // Basiswert für eine formal vorhandene PM
|
||||||
|
$breakdown['base'] = 40;
|
||||||
|
|
||||||
|
$textLength = $pressRelease->plainTextLength();
|
||||||
|
$lengthPoints = match (true) {
|
||||||
|
$textLength >= 1500 => 20,
|
||||||
|
$textLength >= 800 => 12,
|
||||||
|
$textLength >= 300 => 6,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
$score += $lengthPoints;
|
||||||
|
$breakdown['length'] = $lengthPoints;
|
||||||
|
|
||||||
|
$subtitlePoints = filled($pressRelease->subtitle) ? 6 : 0;
|
||||||
|
$score += $subtitlePoints;
|
||||||
|
$breakdown['subtitle'] = $subtitlePoints;
|
||||||
|
|
||||||
|
$imagePoints = $pressRelease->images()->count() > 0 ? 10 : 0;
|
||||||
|
$score += $imagePoints;
|
||||||
|
$breakdown['image'] = $imagePoints;
|
||||||
|
|
||||||
|
$sourcePoints = filled($pressRelease->backlink_url) ? 8 : 0;
|
||||||
|
$score += $sourcePoints;
|
||||||
|
$breakdown['source'] = $sourcePoints;
|
||||||
|
|
||||||
|
$titleLength = mb_strlen((string) $pressRelease->title);
|
||||||
|
$headlinePoints = ($titleLength >= 30 && $titleLength <= 90) ? 8 : 0;
|
||||||
|
$score += $headlinePoints;
|
||||||
|
$breakdown['headline'] = $headlinePoints;
|
||||||
|
|
||||||
|
$keywordPoints = filled($pressRelease->keywords) ? 4 : 0;
|
||||||
|
$score += $keywordPoints;
|
||||||
|
$breakdown['keywords'] = $keywordPoints;
|
||||||
|
|
||||||
|
$contactPoints = $pressRelease->contacts()->count() > 0 ? 4 : 0;
|
||||||
|
$score += $contactPoints;
|
||||||
|
$breakdown['contact'] = $contactPoints;
|
||||||
|
|
||||||
|
$score = max(0, min(100, $score));
|
||||||
|
|
||||||
|
return new ContentScoreResult(
|
||||||
|
score: $score,
|
||||||
|
breakdown: $breakdown,
|
||||||
|
provider: 'deterministic',
|
||||||
|
model: null,
|
||||||
|
rawResponse: ['breakdown' => $breakdown, 'text_length' => $textLength],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\ContentScore\Drivers;
|
||||||
|
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||||
|
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-Score-Treiber auf Basis der OpenAI Chat-Completions-API.
|
||||||
|
*
|
||||||
|
* Liest Zugangsdaten aus config/services.openai, Modell/Timeout aus
|
||||||
|
* config/scoring.content_score. Verlangt strukturiertes JSON
|
||||||
|
* `{score, breakdown}`. Bei fehlendem Key, HTTP-/Transportfehlern oder
|
||||||
|
* ungültiger Antwort wird eine Exception geworfen (Job → Fallback).
|
||||||
|
*/
|
||||||
|
class OpenAiContentScoreDriver implements ContentScoreDriver
|
||||||
|
{
|
||||||
|
public function score(PressRelease $pressRelease): ContentScoreResult
|
||||||
|
{
|
||||||
|
$openai = config('services.openai');
|
||||||
|
$apiKey = $openai['api_key'] ?? null;
|
||||||
|
|
||||||
|
if (blank($apiKey)) {
|
||||||
|
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = config('scoring.content_score.model') ?: ($openai['model'] ?? 'gpt-5.4-mini');
|
||||||
|
$timeout = (int) (config('scoring.content_score.timeout') ?: 30);
|
||||||
|
|
||||||
|
$response = Http::withToken($apiKey)
|
||||||
|
->timeout($timeout)
|
||||||
|
->acceptJson()
|
||||||
|
->post($openai['url'] ?? 'https://api.openai.com/v1/chat/completions', [
|
||||||
|
'model' => $model,
|
||||||
|
'response_format' => ['type' => 'json_object'],
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'system', 'content' => $this->systemPrompt()],
|
||||||
|
['role' => 'user', 'content' => $this->userPrompt($pressRelease)],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new RuntimeException("OpenAI-Content-Score fehlgeschlagen (HTTP {$response->status()}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response->json();
|
||||||
|
$content = data_get($payload, 'choices.0.message.content');
|
||||||
|
|
||||||
|
if (! is_string($content) || trim($content) === '') {
|
||||||
|
throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = json_decode($content, true);
|
||||||
|
|
||||||
|
if (! is_array($parsed) || ! isset($parsed['score']) || ! is_numeric($parsed['score'])) {
|
||||||
|
throw new RuntimeException('OpenAI-Antwort war kein gültiges Score-JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$score = (int) max(0, min(100, (int) round((float) $parsed['score'])));
|
||||||
|
$breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : [];
|
||||||
|
|
||||||
|
return new ContentScoreResult(
|
||||||
|
score: $score,
|
||||||
|
breakdown: $breakdown,
|
||||||
|
provider: 'openai',
|
||||||
|
model: $model,
|
||||||
|
rawResponse: is_array($payload) ? $payload : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function systemPrompt(): string
|
||||||
|
{
|
||||||
|
return <<<'PROMPT'
|
||||||
|
Du bewertest die handwerkliche Qualität einer deutschen Pressemitteilung
|
||||||
|
auf einer Skala von 0 bis 100 (Content-Score). Bewerte ausschließlich die
|
||||||
|
Qualität, nicht die Zulässigkeit.
|
||||||
|
|
||||||
|
Berücksichtige diese gewichteten Kategorien:
|
||||||
|
- Pressestil (20%): informativ statt werblich, aktive Sprache, Zitate
|
||||||
|
- Struktur (15%): Lead-Absatz, sinnvolle Absätze, pyramidaler Aufbau
|
||||||
|
- Lesbarkeit (10%): Satzlängen, angemessene Fachsprache
|
||||||
|
- Vollständigkeit (15%): Pressekontakt, Unternehmensinfo, Datum, Region
|
||||||
|
- Bildmaterial (10%): Bild vorhanden/erwähnt
|
||||||
|
- Quellen/Belege (10%): Verlinkungen, Studien, Datenquellen
|
||||||
|
- Headline-Stärke (10%): Länge, Klarheit, Keyword-Relevanz
|
||||||
|
- Originalität (10%): kein Boilerplate, individueller Ton
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
|
||||||
|
{"score": 0-100, "breakdown": {"pressestil": 0-20, "struktur": 0-15,
|
||||||
|
"lesbarkeit": 0-10, "vollstaendigkeit": 0-15, "bild": 0-10,
|
||||||
|
"quellen": 0-10, "headline": 0-10, "originalitaet": 0-10}}
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userPrompt(PressRelease $pressRelease): string
|
||||||
|
{
|
||||||
|
$title = (string) $pressRelease->title;
|
||||||
|
$text = trim(strip_tags((string) $pressRelease->text));
|
||||||
|
$hasImage = $pressRelease->images()->count() > 0 ? 'ja' : 'nein';
|
||||||
|
$source = filled($pressRelease->backlink_url) ? $pressRelease->backlink_url : '—';
|
||||||
|
|
||||||
|
return "Titel:\n{$title}\n\nBild vorhanden: {$hasImage}\nQuelle/Link: {$source}\n\nText:\n{$text}";
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Services/PressRelease/PressReleaseCoverImage.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease;
|
||||||
|
|
||||||
|
use App\Enums\PressReleasePlaceholder;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auflösung des Titelbildes (Hero/Thumb) einer Pressemitteilung.
|
||||||
|
*
|
||||||
|
* Liefert immer ein darstellbares Bild: entweder das echte Vorschaubild
|
||||||
|
* (bevorzugt das als `is_preview` markierte, sonst das erste) oder den
|
||||||
|
* deterministischen SVG-Platzhalter aus dem Set.
|
||||||
|
*/
|
||||||
|
class PressReleaseCoverImage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Liefert die URL zum Titelbild. Fällt auf den SVG-Platzhalter zurück.
|
||||||
|
*
|
||||||
|
* Die Variante wird über eine Fallback-Kette aufgelöst, damit auch
|
||||||
|
* Altbestände ohne `cover`-Variante (1280x580) sauber rendern.
|
||||||
|
*
|
||||||
|
* @param string $variant Bevorzugte Bild-Variante (cover|large|medium|thumb).
|
||||||
|
*/
|
||||||
|
public function coverUrl(PressRelease $pressRelease, string $variant = 'cover'): string
|
||||||
|
{
|
||||||
|
$image = $this->coverImage($pressRelease);
|
||||||
|
|
||||||
|
if (! $image) {
|
||||||
|
return $this->placeholderUrl($pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->fallbackChain($variant) as $key) {
|
||||||
|
$url = $image->variantUrl($key);
|
||||||
|
|
||||||
|
if ($url !== null) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $image->url() ?? $this->placeholderUrl($pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback-Reihenfolge der Bild-Varianten, beginnend bei der gewünschten.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function fallbackChain(string $preferred): array
|
||||||
|
{
|
||||||
|
$chain = [$preferred, 'cover', 'large', 'medium', 'thumb'];
|
||||||
|
|
||||||
|
return array_values(array_unique($chain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob für diese PM nur ein Platzhalter (kein echtes Bild) vorliegt.
|
||||||
|
*/
|
||||||
|
public function coverIsPlaceholder(PressRelease $pressRelease): bool
|
||||||
|
{
|
||||||
|
return $this->coverImage($pressRelease) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Platzhalter-Variante dieser PM (mit Default-Fallback).
|
||||||
|
*/
|
||||||
|
public function placeholder(PressRelease $pressRelease): PressReleasePlaceholder
|
||||||
|
{
|
||||||
|
$variant = $pressRelease->placeholder_variant;
|
||||||
|
|
||||||
|
if ($variant instanceof PressReleasePlaceholder) {
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PressReleasePlaceholder::fromValueOrDefault($variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function placeholderUrl(PressRelease $pressRelease): string
|
||||||
|
{
|
||||||
|
return asset($this->placeholder($pressRelease)->path());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function coverImage(PressRelease $pressRelease): ?PressReleaseImage
|
||||||
|
{
|
||||||
|
$images = $pressRelease->relationLoaded('images')
|
||||||
|
? $pressRelease->images
|
||||||
|
: $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->get();
|
||||||
|
|
||||||
|
return $images->firstWhere('is_preview', true) ?? $images->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,10 @@
|
||||||
|
|
||||||
namespace App\Services\PressRelease;
|
namespace App\Services\PressRelease;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Jobs\ClassifyPressRelease;
|
||||||
|
use App\Jobs\ScorePressRelease;
|
||||||
use App\Mail\PressReleasePublished;
|
use App\Mail\PressReleasePublished;
|
||||||
use App\Mail\PressReleaseRejected;
|
use App\Mail\PressReleaseRejected;
|
||||||
use App\Models\AdminPreset;
|
use App\Models\AdminPreset;
|
||||||
|
|
@ -43,9 +46,101 @@ class PressReleaseService
|
||||||
|
|
||||||
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
|
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
|
||||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
|
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
|
||||||
|
|
||||||
|
// Quota-Stub: zählt den Monatsverbrauch des Autors hoch. Wird vom
|
||||||
|
// echten Tarif-Modul später abgelöst (Schnittstelle bleibt stabil).
|
||||||
|
$pressRelease->user?->increment('press_release_quota_used_this_month');
|
||||||
|
|
||||||
|
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
|
||||||
|
// des Ergebnisses übernimmt der Job über routeByClassification().
|
||||||
|
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||||
|
|
||||||
|
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
|
||||||
|
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function publish(PressRelease $pressRelease, string $source = 'admin'): void
|
/**
|
||||||
|
* Stößt eine erneute KI-Klassifikation an, wenn die PM bereits einmal
|
||||||
|
* klassifiziert wurde (Konzept §15.1: „Bei Änderung wird neu klassifiziert").
|
||||||
|
*
|
||||||
|
* Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden
|
||||||
|
* aktualisiert, der Status bleibt unverändert. So führt das bloße
|
||||||
|
* Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung
|
||||||
|
* oder Ablehnung – die Entscheidung bleibt beim regulären Workflow/Admin.
|
||||||
|
*/
|
||||||
|
public function reclassifyIfClassified(PressRelease $pressRelease): void
|
||||||
|
{
|
||||||
|
if ($pressRelease->classification === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassifyPressRelease::dispatch($pressRelease->id, route: false)->onQueue('classification');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stößt eine erneute Content-Score-Berechnung an, wenn die PM bereits
|
||||||
|
* einmal bewertet wurde (Konzept §15.2: „bei jeder Änderung neu berechnet").
|
||||||
|
*/
|
||||||
|
public function rescoreIfScored(PressRelease $pressRelease): void
|
||||||
|
{
|
||||||
|
if ($pressRelease->content_score === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses
|
||||||
|
* (Konzept §15.1). Wird vom ClassifyPressRelease-Job aufgerufen.
|
||||||
|
*
|
||||||
|
* - Rot → Ablehnung mit Begründung an den Autor
|
||||||
|
* - Gelb → bleibt in der manuellen Review-Queue
|
||||||
|
* - Grün → automatische Veröffentlichung (sofort bzw. zum geplanten Termin)
|
||||||
|
*
|
||||||
|
* Greift nur, solange die PM noch im Status `review` steht; manuelle
|
||||||
|
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
|
||||||
|
*/
|
||||||
|
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void
|
||||||
|
{
|
||||||
|
if ($pressRelease->status !== PressReleaseStatus::Review) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($classification === PressReleaseClassification::Red) {
|
||||||
|
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($classification === PressReleaseClassification::Green) {
|
||||||
|
$this->autoPublishGreen($pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gelb: keine Aktion – bleibt zur manuellen Prüfung im Status „review".
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Veröffentlicht eine grün klassifizierte PM automatisch.
|
||||||
|
*
|
||||||
|
* Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
|
||||||
|
* Scheduler die Publikation zum Termin. Andernfalls wird sofort
|
||||||
|
* publiziert – optional mit einem Sicherheitsfenster
|
||||||
|
* (scoring.classification.green_delay_minutes).
|
||||||
|
*/
|
||||||
|
private function autoPublishGreen(PressRelease $pressRelease): void
|
||||||
|
{
|
||||||
|
if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0);
|
||||||
|
$publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null;
|
||||||
|
|
||||||
|
$this->publish($pressRelease, 'ki', $publishedAtOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void
|
||||||
{
|
{
|
||||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||||
|
|
||||||
|
|
@ -63,7 +158,7 @@ class PressReleaseService
|
||||||
|
|
||||||
$pressRelease->update([
|
$pressRelease->update([
|
||||||
'status' => PressReleaseStatus::Published->value,
|
'status' => PressReleaseStatus::Published->value,
|
||||||
'published_at' => $this->resolvePublishedAt($pressRelease),
|
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
|
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
|
||||||
|
|
@ -83,14 +178,18 @@ class PressReleaseService
|
||||||
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
|
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
|
||||||
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
|
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
|
||||||
* öffentlichen Listing.
|
* öffentlichen Listing.
|
||||||
|
*
|
||||||
|
* `$override` setzt einen abweichenden Sofort-Zeitpunkt (z.B. das
|
||||||
|
* Grün-Sicherheitsfenster) und wirkt nur, wenn kein `scheduled_at` gesetzt
|
||||||
|
* ist – ein geplanter Termin hat stets Vorrang.
|
||||||
*/
|
*/
|
||||||
private function resolvePublishedAt(PressRelease $pressRelease): Carbon
|
private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon
|
||||||
{
|
{
|
||||||
if ($pressRelease->published_at) {
|
if ($pressRelease->published_at) {
|
||||||
return $pressRelease->published_at;
|
return $pressRelease->published_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
$base = $pressRelease->scheduled_at ?: now();
|
$base = $pressRelease->scheduled_at ?: ($override ?? now());
|
||||||
|
|
||||||
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
|
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
|
||||||
return $pressRelease->embargo_at;
|
return $pressRelease->embargo_at;
|
||||||
|
|
@ -99,7 +198,7 @@ class PressReleaseService
|
||||||
return $base;
|
return $base;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reject(PressRelease $pressRelease, ?string $reason = null): void
|
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void
|
||||||
{
|
{
|
||||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||||
|
|
||||||
|
|
@ -107,7 +206,7 @@ class PressReleaseService
|
||||||
|
|
||||||
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
|
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
|
||||||
|
|
||||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin');
|
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source);
|
||||||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
71
config/scoring.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| KI-Prüfung & Scoring
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Zentrale, ohne Code-Änderung kalibrierbare Schwellen und Verhaltens-Flags
|
||||||
|
| für die automatisierte Prüfung von Pressemitteilungen (Konzept §15).
|
||||||
|
|
|
||||||
|
| Phase 2 legt die Konfiguration an; die Werte werden ab Phase 3
|
||||||
|
| (Klassifikation) bzw. Phase 4 (Routing) wirksam.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Klassifikation (Red Flag, §15.1)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Aktiver Anbieter und Modell für den Klassifikations-Gate. Die konkreten
|
||||||
|
| Treiber (anthropic|gemini|openai|deterministic) folgen in Phase 3.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'classification' => [
|
||||||
|
// Aktiver Treiber: openai|deterministic (Anthropic/Gemini folgen).
|
||||||
|
// Fällt der Anbieter aus (kein Key, Timeout, Fehler), greift im Job
|
||||||
|
// automatisch der deterministische Treiber.
|
||||||
|
'provider' => env('CLASSIFICATION_PROVIDER', 'openai'),
|
||||||
|
|
||||||
|
// Optional ein abweichendes Modell; leer => config('services.openai.model').
|
||||||
|
'model' => env('CLASSIFICATION_MODEL'),
|
||||||
|
|
||||||
|
// Sekunden, bevor auf den deterministischen Fallback-Treiber
|
||||||
|
// ausgewichen wird (Timeout/Rate-Limit/Ausfall).
|
||||||
|
'timeout' => (int) env('CLASSIFICATION_TIMEOUT', 15),
|
||||||
|
|
||||||
|
// Verzögerung in Minuten für „grün" eingestufte PMs als
|
||||||
|
// Sicherheitsfenster vor der automatischen Veröffentlichung
|
||||||
|
// (Konzept-Option, 0 = sofort).
|
||||||
|
'green_delay_minutes' => (int) env('CLASSIFICATION_GREEN_DELAY', 0),
|
||||||
|
|
||||||
|
// Ob „gelb" eingestufte PMs in die manuelle Admin-Queue gehen.
|
||||||
|
'yellow_to_manual_queue' => (bool) env('CLASSIFICATION_YELLOW_MANUAL', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Content-Score (Qualitätsbewertung, §15.2 / Update 2)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Anbieter/Modell für die Score-Berechnung und Schwellen für die Ableitung
|
||||||
|
| der Stufe aus dem 0–100-Score (Standard < 60 ≤ Geprüft < 80 ≤ Hochwertig).
|
||||||
|
| Schwellen werden laut Konzept nach 100–200 echten PMs kalibriert.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'content_score' => [
|
||||||
|
'provider' => env('CONTENT_SCORE_PROVIDER', 'openai'),
|
||||||
|
'model' => env('CONTENT_SCORE_MODEL'),
|
||||||
|
'timeout' => (int) env('CONTENT_SCORE_TIMEOUT', 30),
|
||||||
|
|
||||||
|
'tiers' => [
|
||||||
|
'hochwertig' => (int) env('CONTENT_SCORE_HOCHWERTIG', 80),
|
||||||
|
'gepruft' => (int) env('CONTENT_SCORE_GEPRUEFT', 60),
|
||||||
|
// alles darunter => 'standard'
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -34,5 +34,11 @@ return [
|
||||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'openai' => [
|
||||||
|
'api_key' => env('OPENAI_API_KEY'),
|
||||||
|
'url' => env('OPENAI_API_URL', 'https://api.openai.com/v1/chat/completions'),
|
||||||
|
'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'),
|
||||||
|
'timeout' => env('OPENAI_TIMEOUT', 60),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
53
database/factories/KiAuditFactory.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseClassification;
|
||||||
|
use App\Models\KiAudit;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<KiAudit>
|
||||||
|
*/
|
||||||
|
class KiAuditFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = KiAudit::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$classification = fake()->randomElement(PressReleaseClassification::cases());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'press_release_id' => PressRelease::factory(),
|
||||||
|
'type' => KiAudit::TYPE_CLASSIFICATION,
|
||||||
|
'provider' => 'anthropic',
|
||||||
|
'model' => 'claude-sonnet-4-6',
|
||||||
|
'result' => $classification->value,
|
||||||
|
'reason' => fake()->optional()->sentence(),
|
||||||
|
'raw_response' => ['classification' => $classification->value, 'reasons' => []],
|
||||||
|
'created_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function classification(PressReleaseClassification $classification): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'type' => KiAudit::TYPE_CLASSIFICATION,
|
||||||
|
'result' => $classification->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contentScore(int $score): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'type' => KiAudit::TYPE_CONTENT_SCORE,
|
||||||
|
'result' => (string) $score,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table) {
|
||||||
|
$table->string('placeholder_variant', 32)->nullable()->after('boilerplate_override');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('placeholder_variant');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_release_images', function (Blueprint $table) {
|
||||||
|
$table->string('author')->nullable()->after('copyright');
|
||||||
|
$table->string('license_type', 32)->nullable()->after('author');
|
||||||
|
$table->string('license_url')->nullable()->after('license_type');
|
||||||
|
$table->boolean('persons_consent')->default(false)->after('license_url');
|
||||||
|
$table->timestamp('rights_confirmed_at')->nullable()->after('persons_consent');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_release_images', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['author', 'license_type', 'license_url', 'persons_consent', 'rights_confirmed_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('press_release_quota')->default(3);
|
||||||
|
$table->unsignedInteger('press_release_quota_used_this_month')->default(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['press_release_quota', 'press_release_quota_used_this_month']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_release_images', function (Blueprint $table) {
|
||||||
|
$table->string('license_detail', 120)->nullable()->after('license_type');
|
||||||
|
$table->string('source_url', 2048)->nullable()->after('license_url');
|
||||||
|
$table->string('people_rights_status', 40)->nullable()->after('persons_consent');
|
||||||
|
$table->string('property_rights_status', 40)->nullable()->after('people_rights_status');
|
||||||
|
$table->text('rights_notes')->nullable()->after('property_rights_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_release_images', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'license_detail',
|
||||||
|
'source_url',
|
||||||
|
'people_rights_status',
|
||||||
|
'property_rights_status',
|
||||||
|
'rights_notes',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table): void {
|
||||||
|
// KI-Klassifikation („Red Flag", Konzept §15.1). Nullable: erst ab
|
||||||
|
// Phase 3 befüllt, ändert in Phase 2 noch kein Verhalten.
|
||||||
|
$table->string('classification', 16)->nullable()->after('status');
|
||||||
|
$table->timestamp('classified_at')->nullable()->after('classification');
|
||||||
|
|
||||||
|
$table->index('classification', 'press_releases_classification_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex('press_releases_classification_idx');
|
||||||
|
$table->dropColumn(['classification', 'classified_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ki_audits', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('press_release_id')
|
||||||
|
->constrained()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
// classification | content_score — welche KI-Bewertung dies protokolliert.
|
||||||
|
$table->string('type', 32);
|
||||||
|
$table->string('provider', 32)->nullable();
|
||||||
|
$table->string('model', 64)->nullable();
|
||||||
|
// Ergebnis als String (z. B. green/yellow/red) oder Score-Wert.
|
||||||
|
$table->string('result', 64)->nullable();
|
||||||
|
$table->text('reason')->nullable();
|
||||||
|
// Vollständige Roh-Antwort der KI für Nachvollziehbarkeit (DSGVO).
|
||||||
|
$table->json('raw_response')->nullable();
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['press_release_id', 'type'], 'ki_audits_pr_type_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ki_audits');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table): void {
|
||||||
|
// Content-Score (0–100, Konzept §15.2) und abgeleitete Stufe
|
||||||
|
// (standard|gepruft|hochwertig, Update 2). Nullable: erst ab Phase 5
|
||||||
|
// befüllt.
|
||||||
|
$table->unsignedTinyInteger('content_score')->nullable()->after('classified_at');
|
||||||
|
$table->string('content_tier', 16)->nullable()->after('content_score');
|
||||||
|
$table->timestamp('scored_at')->nullable()->after('content_tier');
|
||||||
|
|
||||||
|
$table->index('content_tier', 'press_releases_content_tier_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex('press_releases_content_tier_idx');
|
||||||
|
$table->dropColumn(['content_score', 'content_tier', 'scored_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -23,6 +23,9 @@
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
<env name="CLASSIFICATION_PROVIDER" value="deterministic" force="true"/>
|
||||||
|
<env name="CONTENT_SCORE_PROVIDER" value="deterministic" force="true"/>
|
||||||
|
<env name="OPENAI_API_KEY" value="" force="true"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||||
|
|
|
||||||
15
public/images/press-release-placeholders/01-grid-blue.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1a2540"/>
|
||||||
|
<stop offset="1" stop-color="#2e3d66"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M64 0H0V64" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#grid)"/>
|
||||||
|
<circle cx="1240" cy="300" r="240" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="320" cy="700" r="160" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 765 B |
15
public/images/press-release-placeholders/02-grid-green.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1f2e22"/>
|
||||||
|
<stop offset="1" stop-color="#3a5a3a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M64 0H0V64" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#grid)"/>
|
||||||
|
<circle cx="1240" cy="300" r="240" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="320" cy="700" r="160" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 765 B |
15
public/images/press-release-placeholders/03-grid-amber.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2e2117"/>
|
||||||
|
<stop offset="1" stop-color="#8a5e27"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M64 0H0V64" fill="none" stroke="#ffffff" stroke-opacity="0.08" stroke-width="1"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#grid)"/>
|
||||||
|
<circle cx="1240" cy="300" r="240" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="320" cy="700" r="160" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 765 B |
14
public/images/press-release-placeholders/04-lines-blue.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1a2540"/>
|
||||||
|
<stop offset="1" stop-color="#2e3d66"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="lines" width="48" height="48" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="48" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#lines)"/>
|
||||||
|
<circle cx="1300" cy="640" r="220" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 726 B |
14
public/images/press-release-placeholders/05-lines-green.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1f2e22"/>
|
||||||
|
<stop offset="1" stop-color="#3a5a3a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="lines" width="48" height="48" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="48" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#lines)"/>
|
||||||
|
<circle cx="1300" cy="640" r="220" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 726 B |
14
public/images/press-release-placeholders/06-lines-amber.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2e2117"/>
|
||||||
|
<stop offset="1" stop-color="#8a5e27"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="lines" width="48" height="48" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="48" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#lines)"/>
|
||||||
|
<circle cx="1300" cy="640" r="220" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 726 B |
15
public/images/press-release-placeholders/07-dots-blue.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1a2540"/>
|
||||||
|
<stop offset="1" stop-color="#2e3d66"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="dots" width="44" height="44" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="22" cy="22" r="2.4" fill="#ffffff" fill-opacity="0.12"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#dots)"/>
|
||||||
|
<circle cx="380" cy="280" r="200" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="1280" cy="680" r="150" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
15
public/images/press-release-placeholders/08-dots-green.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1f2e22"/>
|
||||||
|
<stop offset="1" stop-color="#3a5a3a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="dots" width="44" height="44" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="22" cy="22" r="2.4" fill="#ffffff" fill-opacity="0.12"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#dots)"/>
|
||||||
|
<circle cx="380" cy="280" r="200" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="1280" cy="680" r="150" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
15
public/images/press-release-placeholders/09-dots-amber.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2e2117"/>
|
||||||
|
<stop offset="1" stop-color="#8a5e27"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="dots" width="44" height="44" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="22" cy="22" r="2.4" fill="#ffffff" fill-opacity="0.12"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#dots)"/>
|
||||||
|
<circle cx="380" cy="280" r="200" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="1280" cy="680" r="150" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
16
public/images/press-release-placeholders/10-waves-blue.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1a2540"/>
|
||||||
|
<stop offset="1" stop-color="#2e3d66"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="waves" width="260" height="120" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M-20 70C30 30 80 30 130 70S230 110 280 70" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="4"/>
|
||||||
|
<path d="M-20 110C30 70 80 70 130 110S230 150 280 110" fill="none" stroke="#ffffff" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#waves)"/>
|
||||||
|
<circle cx="1270" cy="260" r="220" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="360" cy="690" r="180" fill="#ffffff" fill-opacity="0.055"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 932 B |
16
public/images/press-release-placeholders/11-waves-green.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1f2e22"/>
|
||||||
|
<stop offset="1" stop-color="#3a5a3a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="waves" width="260" height="120" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M-20 70C30 30 80 30 130 70S230 110 280 70" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="4"/>
|
||||||
|
<path d="M-20 110C30 70 80 70 130 110S230 150 280 110" fill="none" stroke="#ffffff" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#waves)"/>
|
||||||
|
<circle cx="1270" cy="260" r="220" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="360" cy="690" r="180" fill="#ffffff" fill-opacity="0.055"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 932 B |
16
public/images/press-release-placeholders/12-waves-amber.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2e2117"/>
|
||||||
|
<stop offset="1" stop-color="#8a5e27"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="waves" width="260" height="120" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M-20 70C30 30 80 30 130 70S230 110 280 70" fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="4"/>
|
||||||
|
<path d="M-20 110C30 70 80 70 130 110S230 150 280 110" fill="none" stroke="#ffffff" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#waves)"/>
|
||||||
|
<circle cx="1270" cy="260" r="220" fill="#ffffff" fill-opacity="0.05"/>
|
||||||
|
<circle cx="360" cy="690" r="180" fill="#ffffff" fill-opacity="0.055"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 932 B |
|
|
@ -0,0 +1,17 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1a2540"/>
|
||||||
|
<stop offset="1" stop-color="#2e3d66"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect x="170" y="190" width="520" height="42" rx="10" fill="#ffffff" fill-opacity="0.12"/>
|
||||||
|
<rect x="170" y="282" width="820" height="22" rx="8" fill="#ffffff" fill-opacity="0.08"/>
|
||||||
|
<rect x="170" y="334" width="690" height="22" rx="8" fill="#ffffff" fill-opacity="0.07"/>
|
||||||
|
<rect x="170" y="386" width="760" height="22" rx="8" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
<rect x="1030" y="250" width="330" height="240" rx="20" fill="#ffffff" fill-opacity="0.055"/>
|
||||||
|
<path d="M1100 430L1190 340L1260 400L1305 355L1360 430H1100Z" fill="#ffffff" fill-opacity="0.10"/>
|
||||||
|
<circle cx="1290" cy="315" r="28" fill="#ffffff" fill-opacity="0.11"/>
|
||||||
|
<circle cx="1280" cy="700" r="180" fill="#ffffff" fill-opacity="0.04"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,17 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1f2e22"/>
|
||||||
|
<stop offset="1" stop-color="#3a5a3a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect x="170" y="190" width="520" height="42" rx="10" fill="#ffffff" fill-opacity="0.12"/>
|
||||||
|
<rect x="170" y="282" width="820" height="22" rx="8" fill="#ffffff" fill-opacity="0.08"/>
|
||||||
|
<rect x="170" y="334" width="690" height="22" rx="8" fill="#ffffff" fill-opacity="0.07"/>
|
||||||
|
<rect x="170" y="386" width="760" height="22" rx="8" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
<rect x="1030" y="250" width="330" height="240" rx="20" fill="#ffffff" fill-opacity="0.055"/>
|
||||||
|
<path d="M1100 430L1190 340L1260 400L1305 355L1360 430H1100Z" fill="#ffffff" fill-opacity="0.10"/>
|
||||||
|
<circle cx="1290" cy="315" r="28" fill="#ffffff" fill-opacity="0.11"/>
|
||||||
|
<circle cx="1280" cy="700" r="180" fill="#ffffff" fill-opacity="0.04"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,17 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2e2117"/>
|
||||||
|
<stop offset="1" stop-color="#8a5e27"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect x="170" y="190" width="520" height="42" rx="10" fill="#ffffff" fill-opacity="0.12"/>
|
||||||
|
<rect x="170" y="282" width="820" height="22" rx="8" fill="#ffffff" fill-opacity="0.08"/>
|
||||||
|
<rect x="170" y="334" width="690" height="22" rx="8" fill="#ffffff" fill-opacity="0.07"/>
|
||||||
|
<rect x="170" y="386" width="760" height="22" rx="8" fill="#ffffff" fill-opacity="0.06"/>
|
||||||
|
<rect x="1030" y="250" width="330" height="240" rx="20" fill="#ffffff" fill-opacity="0.055"/>
|
||||||
|
<path d="M1100 430L1190 340L1260 400L1305 355L1360 430H1100Z" fill="#ffffff" fill-opacity="0.10"/>
|
||||||
|
<circle cx="1290" cy="315" r="28" fill="#ffffff" fill-opacity="0.11"/>
|
||||||
|
<circle cx="1280" cy="700" r="180" fill="#ffffff" fill-opacity="0.04"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
16
public/images/press-release-placeholders/16-signal-blue.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1a2540"/>
|
||||||
|
<stop offset="1" stop-color="#2e3d66"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="rings" width="180" height="180" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="90" cy="90" r="38" fill="none" stroke="#ffffff" stroke-opacity="0.09" stroke-width="3"/>
|
||||||
|
<circle cx="90" cy="90" r="4" fill="#ffffff" fill-opacity="0.13"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#rings)"/>
|
||||||
|
<path d="M260 680C510 540 710 560 960 410C1140 302 1260 260 1410 288" fill="none" stroke="#ffffff" stroke-opacity="0.12" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<circle cx="960" cy="410" r="100" fill="#ffffff" fill-opacity="0.045"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 944 B |
16
public/images/press-release-placeholders/17-signal-green.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1f2e22"/>
|
||||||
|
<stop offset="1" stop-color="#3a5a3a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="rings" width="180" height="180" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="90" cy="90" r="38" fill="none" stroke="#ffffff" stroke-opacity="0.09" stroke-width="3"/>
|
||||||
|
<circle cx="90" cy="90" r="4" fill="#ffffff" fill-opacity="0.13"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#rings)"/>
|
||||||
|
<path d="M260 680C510 540 710 560 960 410C1140 302 1260 260 1410 288" fill="none" stroke="#ffffff" stroke-opacity="0.12" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<circle cx="960" cy="410" r="100" fill="#ffffff" fill-opacity="0.045"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 944 B |
16
public/images/press-release-placeholders/18-signal-amber.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900" role="img" aria-label="Platzhalter">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2e2117"/>
|
||||||
|
<stop offset="1" stop-color="#8a5e27"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="rings" width="180" height="180" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="90" cy="90" r="38" fill="none" stroke="#ffffff" stroke-opacity="0.09" stroke-width="3"/>
|
||||||
|
<circle cx="90" cy="90" r="4" fill="#ffffff" fill-opacity="0.13"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<rect width="1600" height="900" fill="url(#rings)"/>
|
||||||
|
<path d="M260 680C510 540 710 560 960 410C1140 302 1260 260 1410 288" fill="none" stroke="#ffffff" stroke-opacity="0.12" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<circle cx="960" cy="410" r="100" fill="#ffffff" fill-opacity="0.045"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 944 B |
|
|
@ -1151,6 +1151,31 @@
|
||||||
* Tag-Chips und Portal-/Veröffentlichungs-Optionen verwendet.
|
* Tag-Chips und Portal-/Veröffentlichungs-Optionen verwendet.
|
||||||
*/
|
*/
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* Container-Query-Kontext: Das zweispaltige Editor-Layout richtet sich
|
||||||
|
nach dem real verfügbaren Inhaltsbereich, nicht nach dem Viewport.
|
||||||
|
So rutschen die rechten Cards automatisch nach unten, sobald die
|
||||||
|
Sidebar offen ist und der Platz knapp wird — unabhängig davon, bei
|
||||||
|
welcher Viewport-Breite die Sidebar gerade ein- oder ausfährt. */
|
||||||
|
.pr-editor-shell {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: pr-editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-editor-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container pr-editor (min-width: 960px) {
|
||||||
|
.pr-editor-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-editor-side {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pr-form-label {
|
.pr-form-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<flux:main>
|
<flux:main>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
||||||
{{ __('Zurück zur Übersicht') }}
|
{{ __('Zurück zur Übersicht') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="min-h-screen bg-bg text-ink antialiased">
|
<body class="min-h-screen bg-bg text-ink antialiased">
|
||||||
<flux:sidebar sticky stashable class="border-e border-bg-rule">
|
<flux:sidebar sticky stashable breakpoint="1280px" class="border-e border-bg-rule">
|
||||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
<flux:sidebar.toggle class="xl:hidden" icon="x-mark" />
|
||||||
|
|
||||||
{{-- Brand-Block: Wortmarke + Hub-Eyebrow --}}
|
{{-- Brand-Block: Wortmarke + Hub-Eyebrow --}}
|
||||||
<a href="{{ config('domains.domain_main_url') }}" class="block px-2 pt-1 pb-3 no-underline">
|
<a href="{{ config('domains.domain_main_url') }}" class="block px-2 pt-1 pb-3 no-underline">
|
||||||
|
|
@ -292,8 +292,8 @@
|
||||||
</flux:sidebar>
|
</flux:sidebar>
|
||||||
|
|
||||||
<!-- Mobile User Menu -->
|
<!-- Mobile User Menu -->
|
||||||
<flux:header class="lg:hidden">
|
<flux:header class="xl:hidden">
|
||||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
<flux:sidebar.toggle class="xl:hidden" icon="bars-2" inset="left" />
|
||||||
|
|
||||||
<a href="{{ config('domains.domain_main_url') }}" class="me-5 ml-2 flex items-baseline no-underline">
|
<a href="{{ config('domains.domain_main_url') }}" class="me-5 ml-2 flex items-baseline no-underline">
|
||||||
<span class="text-[16px] font-bold tracking-[-0.3px] leading-none">
|
<span class="text-[16px] font-bold tracking-[-0.3px] leading-none">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
@props([
|
||||||
|
/**
|
||||||
|
* Variante (Dateiname ohne Endung), z. B. "01-grid-blue".
|
||||||
|
* Ungültige/leere Werte fallen auf den Default zurück.
|
||||||
|
*/
|
||||||
|
'variant' => null,
|
||||||
|
|
||||||
|
/** Optionaler Titel als Overlay-Text auf dem Platzhalter. */
|
||||||
|
'title' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$resolved = \App\Enums\PressReleasePlaceholder::fromValueOrDefault($variant);
|
||||||
|
$src = asset($resolved->path());
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div {{ $attributes->class(['relative overflow-hidden bg-[color:var(--color-hub)]']) }}>
|
||||||
|
<img src="{{ $src }}" alt="{{ $title ? __('Platzhalter für :title', ['title' => $title]) : __('Pressemitteilung Platzhalter') }}"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
|
||||||
|
|
||||||
|
@if ($title)
|
||||||
|
<div class="absolute inset-x-0 bottom-0 px-5 pb-4 pt-12"
|
||||||
|
style="background:linear-gradient(180deg,transparent,rgba(0,0,0,0.55));">
|
||||||
|
<p class="m-0 line-clamp-2 text-[15px] font-semibold leading-[1.25] text-white">
|
||||||
|
{{ $title }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
@props([
|
||||||
|
'name' => 'confirm-submit-review',
|
||||||
|
'action',
|
||||||
|
'confirmLabel' => null,
|
||||||
|
'quotaTotal' => null,
|
||||||
|
'quotaRemaining' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
{{--
|
||||||
|
Wiederverwendbares Einreichungs-Modal für Pressemitteilungen.
|
||||||
|
Wird in Detailansicht, Bearbeiten und Erstellen eingebunden. Der
|
||||||
|
`action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim
|
||||||
|
Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`,
|
||||||
|
`save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben sind.
|
||||||
|
--}}
|
||||||
|
<flux:modal :name="$name" class="w-full max-w-xl">
|
||||||
|
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false }">
|
||||||
|
<div>
|
||||||
|
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
|
||||||
|
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}}
|
||||||
|
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
|
||||||
|
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit dem Einreichen versichern Sie:') }}</p>
|
||||||
|
<ul class="m-0 list-disc space-y-1 ps-5">
|
||||||
|
<li>{{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}</li>
|
||||||
|
<li>{{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}</li>
|
||||||
|
<li>{{ __('Personenbezogene Daten sind nur im zwingend erforderlichen Umfang enthalten.') }}</li>
|
||||||
|
<li>{{ __('Aussagen entsprechen Ihrem Wissensstand und sind sachlich richtig.') }}</li>
|
||||||
|
</ul>
|
||||||
|
<p class="m-0 mt-2 text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||||
|
{{ __('Sie stellen die Plattform von Ansprüchen Dritter frei, die aus einer unberechtigten Nutzung von Inhalten resultieren. Die endgültige Veröffentlichung erfolgt nach redaktioneller Prüfung.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Kontingent (optional) --}}
|
||||||
|
@if (! is_null($quotaRemaining) && ! is_null($quotaTotal))
|
||||||
|
<div class="flex items-center justify-between rounded-[6px] border border-[color:var(--color-bg-rule)] px-4 py-3">
|
||||||
|
<div class="text-[12.5px] text-[color:var(--color-ink-2)]">
|
||||||
|
<div class="font-semibold text-[color:var(--color-ink)]">{{ __('PM-Kontingent diesen Monat') }}</div>
|
||||||
|
<div class="text-[color:var(--color-ink-3)]">{{ __('Verbleibend nach diesem Versand wird angerechnet.') }}</div>
|
||||||
|
</div>
|
||||||
|
<span @class(['badge', $quotaRemaining > 0 ? 'ok' : 'warn'])>
|
||||||
|
{{ $quotaRemaining }} / {{ $quotaTotal }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Bestätigungen --}}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]">
|
||||||
|
<input type="checkbox" x-model="agb" class="mt-0.5" />
|
||||||
|
<span>{{ __('Der Inhalt entspricht den AGB und gesetzlichen Vorgaben.') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]">
|
||||||
|
<input type="checkbox" x-model="images" class="mt-0.5" />
|
||||||
|
<span>{{ __('Alle Bildrechte sind geklärt.') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-2 text-[12.5px] text-[color:var(--color-ink-2)]">
|
||||||
|
<input type="checkbox" x-model="contact" class="mt-0.5" />
|
||||||
|
<span>{{ __('Die Angaben zum Pressekontakt sind korrekt.') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<flux:modal.close>
|
||||||
|
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
|
||||||
|
</flux:modal.close>
|
||||||
|
<flux:button variant="primary" wire:click="{{ $action }}"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
x-bind:disabled="! (agb && images && contact)">
|
||||||
|
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
@ -123,7 +123,7 @@ new #[Layout('components.layouts.app'), Title('Kategorie anlegen')] class extend
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" :href="route('admin.categories.index')" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" :href="route('admin.categories.index')" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" :href="route('admin.categories.index')" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" :href="route('admin.categories.index')" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
|
||||||
@endif
|
@endif
|
||||||
<flux:button
|
<flux:button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="pencil"
|
icon="pencil"
|
||||||
:href="route('admin.categories.edit', $category->id)"
|
:href="route('admin.categories.edit', $category->id)"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -271,7 +271,14 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
|
||||||
<div class="p-5 space-y-4">
|
<div class="p-5 space-y-4">
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Firmenlogo') }}</flux:label>
|
<flux:label>{{ __('Firmenlogo') }}</flux:label>
|
||||||
<flux:input type="file" wire:model="logo" accept="image/*" />
|
<flux:file-upload wire:model="logo" accept="image/*">
|
||||||
|
<flux:file-upload.dropzone
|
||||||
|
:heading="__('Logo hierher ziehen oder klicken')"
|
||||||
|
:text="__('Bilddatei · empfohlen quadratisch, min. 400×400 px')"
|
||||||
|
with-progress
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
</flux:file-upload>
|
||||||
<flux:description>{{ __('Maximal 1 MB. Empfohlen: quadratisch, min. 400x400px') }}</flux:description>
|
<flux:description>{{ __('Maximal 1 MB. Empfohlen: quadratisch, min. 400x400px') }}</flux:description>
|
||||||
<flux:error name="logo" />
|
<flux:error name="logo" />
|
||||||
|
|
||||||
|
|
@ -298,7 +305,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
|
||||||
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
|
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.companies.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.companies.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.show', $companyId) }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.companies.show', $companyId) }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -350,7 +350,14 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
||||||
<div class="p-5 space-y-4">
|
<div class="p-5 space-y-4">
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Firmenlogo') }}</flux:label>
|
<flux:label>{{ __('Firmenlogo') }}</flux:label>
|
||||||
<flux:input type="file" wire:model="logo" accept="image/jpeg,image/png,image/webp,image/gif" />
|
<flux:file-upload wire:model="logo" accept="image/jpeg,image/png,image/webp,image/gif">
|
||||||
|
<flux:file-upload.dropzone
|
||||||
|
:heading="__('Logo hierher ziehen oder klicken')"
|
||||||
|
:text="__('JPG, PNG, WebP oder GIF · max. 4 MB')"
|
||||||
|
with-progress
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
</flux:file-upload>
|
||||||
<flux:description>{{ __('Maximal 4 MB. Varianten (sq/wide) werden automatisch generiert.') }}</flux:description>
|
<flux:description>{{ __('Maximal 4 MB. Varianten (sq/wide) werden automatisch generiert.') }}</flux:description>
|
||||||
<flux:error name="logo" />
|
<flux:error name="logo" />
|
||||||
|
|
||||||
|
|
@ -371,7 +378,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
||||||
<img src="{{ $current_logo_url }}" width="128" height="128"
|
<img src="{{ $current_logo_url }}" width="128" height="128"
|
||||||
class="h-32 max-h-32 w-32 max-w-32 rounded-[6px] border border-[color:var(--color-bg-rule)] object-contain bg-[color:var(--color-bg-elev)]">
|
class="h-32 max-h-32 w-32 max-w-32 rounded-[6px] border border-[color:var(--color-bg-rule)] object-contain bg-[color:var(--color-bg-elev)]">
|
||||||
</div>
|
</div>
|
||||||
<flux:button type="button" size="sm" variant="ghost" wire:click="$set('remove_logo', true)">
|
<flux:button type="button" size="sm" variant="filled" wire:click="$set('remove_logo', true)">
|
||||||
{{ __('Logo entfernen') }}
|
{{ __('Logo entfernen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -382,7 +389,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
{{ __('Logo wird beim Speichern entfernt.') }}
|
{{ __('Logo wird beim Speichern entfernt.') }}
|
||||||
</div>
|
</div>
|
||||||
<flux:button type="button" size="sm" variant="ghost" wire:click="$set('remove_logo', false)">
|
<flux:button type="button" size="sm" variant="filled" wire:click="$set('remove_logo', false)">
|
||||||
{{ __('Rückgängig') }}
|
{{ __('Rückgängig') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -413,7 +420,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.companies.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.companies.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearUserSearch"
|
wire:click="clearUserSearch"
|
||||||
title="{{ __('Usersuche zurücksetzen') }}"
|
title="{{ __('Usersuche zurücksetzen') }}"
|
||||||
|
|
@ -437,7 +437,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearContactSearch"
|
wire:click="clearContactSearch"
|
||||||
title="{{ __('Kontaktsuche zurücksetzen') }}"
|
title="{{ __('Kontaktsuche zurücksetzen') }}"
|
||||||
|
|
@ -481,9 +481,9 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
|
||||||
|
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
<flux:button size="sm" variant="filled" icon="pencil"
|
||||||
href="{{ route('admin.companies.edit', $company->id) }}" wire:navigate />
|
href="{{ route('admin.companies.edit', $company->id) }}" wire:navigate />
|
||||||
<flux:button size="sm" variant="ghost" icon="eye"
|
<flux:button size="sm" variant="filled" icon="eye"
|
||||||
href="{{ route('admin.companies.show', $company->id) }}" wire:navigate />
|
href="{{ route('admin.companies.show', $company->id) }}" wire:navigate />
|
||||||
</div>
|
</div>
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
|
|
@ -532,7 +532,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
|
||||||
@if ($company->press_releases_count > 0)
|
@if ($company->press_releases_count > 0)
|
||||||
<flux:button
|
<flux:button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}"
|
href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
>
|
>
|
||||||
|
|
@ -547,7 +547,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
|
||||||
@if ($company->contacts_count > 0)
|
@if ($company->contacts_count > 0)
|
||||||
<flux:button
|
<flux:button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
href="{{ route('admin.contacts.index', ['company' => $company->id]) }}"
|
href="{{ route('admin.contacts.index', ['company' => $company->id]) }}"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -253,11 +253,11 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.companies.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@if (\Illuminate\Support\Facades\Route::has('admin.companies.contacts.create'))
|
@if (\Illuminate\Support\Facades\Route::has('admin.companies.contacts.create'))
|
||||||
<flux:button variant="ghost" icon="user-plus" href="{{ route('admin.companies.contacts.create', ['companyId' => $company->id]) }}" wire:navigate>
|
<flux:button variant="filled" icon="user-plus" href="{{ route('admin.companies.contacts.create', ['companyId' => $company->id]) }}" wire:navigate>
|
||||||
{{ __('Kontakt hinzufügen') }}
|
{{ __('Kontakt hinzufügen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -344,7 +344,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
||||||
<article class="panel lg:col-span-2">
|
<article class="panel lg:col-span-2">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Aktuelle Pressemitteilungen') }}</span>
|
<span class="section-eyebrow">{{ __('Aktuelle Pressemitteilungen') }}</span>
|
||||||
<flux:button size="sm" variant="ghost" href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}" wire:navigate>
|
<flux:button size="sm" variant="filled" href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}" wire:navigate>
|
||||||
{{ __('Alle anzeigen') }}
|
{{ __('Alle anzeigen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -454,7 +454,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
|
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate />
|
<flux:button size="sm" variant="filled" icon="pencil" href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -283,7 +283,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -330,7 +330,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.contacts.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -490,7 +490,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearCompanySearch"
|
wire:click="clearCompanySearch"
|
||||||
title="{{ __('Firmensuche zurücksetzen') }}"
|
title="{{ __('Firmensuche zurücksetzen') }}"
|
||||||
|
|
@ -526,7 +526,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearUserSearch"
|
wire:click="clearUserSearch"
|
||||||
title="{{ __('Usersuche zurücksetzen') }}"
|
title="{{ __('Usersuche zurücksetzen') }}"
|
||||||
|
|
@ -559,7 +559,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
||||||
<div class="flex flex-1 gap-3">
|
<div class="flex flex-1 gap-3">
|
||||||
<flux:input wire:model="presetName" placeholder="{{ __('Neues Preset speichern...') }}"
|
<flux:input wire:model="presetName" placeholder="{{ __('Neues Preset speichern...') }}"
|
||||||
class="flex-1" />
|
class="flex-1" />
|
||||||
<flux:button wire:click="savePreset" variant="ghost" icon="bookmark">
|
<flux:button wire:click="savePreset" variant="filled" icon="bookmark">
|
||||||
{{ __('Preset speichern') }}
|
{{ __('Preset speichern') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -573,8 +573,8 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
||||||
</option>
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</flux:select>
|
||||||
<flux:button wire:click="applyPreset" variant="ghost">{{ __('Anwenden') }}</flux:button>
|
<flux:button wire:click="applyPreset" variant="filled">{{ __('Anwenden') }}</flux:button>
|
||||||
<flux:button wire:click="setDefaultPreset" variant="ghost">{{ __('Als Standard') }}</flux:button>
|
<flux:button wire:click="setDefaultPreset" variant="filled">{{ __('Als Standard') }}</flux:button>
|
||||||
<flux:button wire:click="deletePreset" variant="danger">{{ __('Löschen') }}</flux:button>
|
<flux:button wire:click="deletePreset" variant="danger">{{ __('Löschen') }}</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -622,11 +622,11 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
|
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
<flux:button size="sm" variant="filled" icon="pencil"
|
||||||
href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate />
|
href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate />
|
||||||
@endif
|
@endif
|
||||||
@if ($contact->company && \Illuminate\Support\Facades\Route::has('admin.companies.show'))
|
@if ($contact->company && \Illuminate\Support\Facades\Route::has('admin.companies.show'))
|
||||||
<flux:button size="sm" variant="ghost" icon="building-office"
|
<flux:button size="sm" variant="filled" icon="building-office"
|
||||||
href="{{ route('admin.companies.show', $contact->company_id) }}"
|
href="{{ route('admin.companies.show', $contact->company_id) }}"
|
||||||
wire:navigate />
|
wire:navigate />
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -674,7 +674,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
||||||
@if ($contact->press_releases_count > 0)
|
@if ($contact->press_releases_count > 0)
|
||||||
<flux:button
|
<flux:button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
href="{{ route('admin.press-releases.index', ['contact' => $contact->id]) }}"
|
href="{{ route('admin.press-releases.index', ['contact' => $contact->id]) }}"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
>
|
>
|
||||||
|
|
@ -694,11 +694,11 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<flux:modal.trigger name="confirm-contact-delete-{{ $contact->id }}">
|
<flux:modal.trigger name="confirm-contact-delete-{{ $contact->id }}">
|
||||||
<flux:button size="sm" variant="ghost" icon="trash" type="button"
|
<flux:button size="sm" variant="filled" icon="trash" type="button"
|
||||||
x-data=""
|
x-data=""
|
||||||
x-on:click.prevent="$dispatch('open-modal', 'confirm-contact-delete-{{ $contact->id }}')" />
|
x-on:click.prevent="$dispatch('open-modal', 'confirm-contact-delete-{{ $contact->id }}')" />
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<flux:button size="sm" variant="ghost" icon="envelope"
|
<flux:button size="sm" variant="filled" icon="envelope"
|
||||||
href="mailto:{{ $contact->email }}" />
|
href="mailto:{{ $contact->email }}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" :href="route('admin.footer-codes.index')" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" :href="route('admin.footer-codes.index')" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -194,7 +194,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex items-center justify-end gap-2">
|
<div class="p-5 flex items-center justify-end gap-2">
|
||||||
<flux:button variant="ghost" :href="route('admin.footer-codes.index')" wire:navigate>
|
<flux:button variant="filled" :href="route('admin.footer-codes.index')" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary" icon="check">
|
<flux:button type="submit" variant="primary" icon="check">
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" :href="route('admin.footer-codes.index')" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" :href="route('admin.footer-codes.index')" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -233,7 +233,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<flux:button variant="ghost" :href="route('admin.footer-codes.index')" wire:navigate>
|
<flux:button variant="filled" :href="route('admin.footer-codes.index')" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary" icon="check">
|
<flux:button type="submit" variant="primary" icon="check">
|
||||||
|
|
|
||||||
|
|
@ -215,14 +215,14 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<flux:button
|
<flux:button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
:icon="$code->is_active ? 'pause' : 'play'"
|
:icon="$code->is_active ? 'pause' : 'play'"
|
||||||
wire:click="toggleActive({{ $code->id }})"
|
wire:click="toggleActive({{ $code->id }})"
|
||||||
:title="$code->is_active ? __('Deaktivieren') : __('Aktivieren')"
|
:title="$code->is_active ? __('Deaktivieren') : __('Aktivieren')"
|
||||||
/>
|
/>
|
||||||
<flux:button
|
<flux:button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="pencil"
|
icon="pencil"
|
||||||
:href="route('admin.footer-codes.edit', $code->id)"
|
:href="route('admin.footer-codes.edit', $code->id)"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
|
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
|
||||||
<flux:button size="sm" variant="ghost" icon="arrow-path" wire:click="resetFilters">
|
<flux:button size="sm" variant="filled" icon="arrow-path" wire:click="resetFilters">
|
||||||
{{ __('Filter zurücksetzen') }}
|
{{ __('Filter zurücksetzen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -260,7 +260,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<flux:button
|
<flux:button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
:href="route('admin.users.show', $invoice->user)"
|
:href="route('admin.users.show', $invoice->user)"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
>
|
>
|
||||||
|
|
@ -297,7 +297,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<flux:button
|
<flux:button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="arrow-top-right-on-square"
|
icon="arrow-top-right-on-square"
|
||||||
:href="route('admin.legacy-invoices.pdf', $invoice)"
|
:href="route('admin.legacy-invoices.pdf', $invoice)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button size="sm" variant="ghost" icon="eye" wire:click="triggerDryRun">
|
<flux:button size="sm" variant="filled" icon="eye" wire:click="triggerDryRun">
|
||||||
{{ __('Dry Run') }}
|
{{ __('Dry Run') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button size="sm" variant="primary" icon="play" wire:click="triggerTestSync">
|
<flux:button size="sm" variant="primary" icon="play" wire:click="triggerTestSync">
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ new #[Layout('components.layouts.app'), Title('Neue Voreinstellung')] class exte
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.presets.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.presets.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,7 +77,7 @@ new #[Layout('components.layouts.app'), Title('Neue Voreinstellung')] class exte
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.presets.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.presets.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellung bearbeiten')] clas
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.presets.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.presets.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,7 +99,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellung bearbeiten')] clas
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.presets.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.presets.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellungen')] class extends
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
|
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.presets.edit', $preset->id) }}" wire:navigate />
|
<flux:button size="sm" variant="filled" icon="pencil" href="{{ route('admin.presets.edit', $preset->id) }}" wire:navigate />
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
</flux:table.row>
|
</flux:table.row>
|
||||||
@empty
|
@empty
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component
|
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component {
|
||||||
{
|
|
||||||
public string $portal = 'presseecho';
|
public string $portal = 'presseecho';
|
||||||
|
|
||||||
public string $language = 'de';
|
public string $language = 'de';
|
||||||
|
|
@ -52,6 +51,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
|
|
||||||
public ?string $scheduledAt = null;
|
public ?string $scheduledAt = null;
|
||||||
|
|
||||||
|
public ?string $scheduledDate = null;
|
||||||
|
|
||||||
|
public ?string $scheduledTime = null;
|
||||||
|
|
||||||
public bool $useEmbargo = false;
|
public bool $useEmbargo = false;
|
||||||
|
|
||||||
public ?string $embargoAt = null;
|
public ?string $embargoAt = null;
|
||||||
|
|
@ -61,9 +64,33 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
$this->resetErrorBag('companyId');
|
$this->resetErrorBag('companyId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedPublishMode(): void
|
||||||
|
{
|
||||||
|
$this->syncScheduledAt();
|
||||||
|
|
||||||
|
if ($this->publishMode === 'now') {
|
||||||
|
$this->scheduledDate = null;
|
||||||
|
$this->scheduledTime = null;
|
||||||
|
$this->scheduledAt = null;
|
||||||
|
$this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedScheduledDate(): void
|
||||||
|
{
|
||||||
|
$this->syncScheduledAt();
|
||||||
|
$this->validateScheduledAtWhenReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedScheduledTime(): void
|
||||||
|
{
|
||||||
|
$this->syncScheduledAt();
|
||||||
|
$this->validateScheduledAtWhenReady();
|
||||||
|
}
|
||||||
|
|
||||||
public function updatedCompanyId(): void
|
public function updatedCompanyId(): void
|
||||||
{
|
{
|
||||||
if (! $this->companyId) {
|
if (!$this->companyId) {
|
||||||
$this->contactId = null;
|
$this->contactId = null;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
@ -71,7 +98,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
|
|
||||||
$contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId);
|
$contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId);
|
||||||
|
|
||||||
if (! $contactStillValid) {
|
if (!$contactStillValid) {
|
||||||
$this->contactId = $this->defaultContactIdFor((int) $this->companyId);
|
$this->contactId = $this->defaultContactIdFor((int) $this->companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,10 +136,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
|
|
||||||
public function removeTag(string $tag): void
|
public function removeTag(string $tag): void
|
||||||
{
|
{
|
||||||
$existing = array_values(array_filter(
|
$existing = array_values(array_filter($this->tagsArray(), fn(string $existingTag): bool => $existingTag !== $tag));
|
||||||
$this->tagsArray(),
|
|
||||||
fn (string $existingTag): bool => $existingTag !== $tag,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->keywords = implode(', ', $existing);
|
$this->keywords = implode(', ', $existing);
|
||||||
|
|
||||||
|
|
@ -128,7 +152,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
protected function formRules(): array
|
protected function formRules(): array
|
||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))],
|
'portal' => ['required', Rule::in(array_map(fn(Portal $p) => $p->value, Portal::cases()))],
|
||||||
'language' => ['required', Rule::in(['de', 'en'])],
|
'language' => ['required', Rule::in(['de', 'en'])],
|
||||||
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
|
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
|
||||||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||||||
|
|
@ -143,26 +167,103 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->publishMode === 'scheduled') {
|
if ($this->publishMode === 'scheduled') {
|
||||||
$rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()];
|
$rules['scheduledDate'] = ['required', 'date'];
|
||||||
|
$rules['scheduledTime'] = ['required', 'date_format:H:i'];
|
||||||
|
$rules['scheduledAt'] = [
|
||||||
|
'required',
|
||||||
|
'date',
|
||||||
|
// Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen-
|
||||||
|
// bewusst prüfen statt über die naive `after:`-Regel.
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||||
|
$scheduledAt = $this->scheduledAtUtc();
|
||||||
|
|
||||||
|
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||||||
|
$fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
|
$rules['scheduledDate'] = ['nullable'];
|
||||||
|
$rules['scheduledTime'] = ['nullable'];
|
||||||
$rules['scheduledAt'] = ['nullable'];
|
$rules['scheduledAt'] = ['nullable'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->useEmbargo) {
|
$rules['embargoAt'] = ['nullable'];
|
||||||
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
|
|
||||||
} else {
|
|
||||||
$rules['embargoAt'] = ['nullable'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rules;
|
return $rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function syncScheduledAt(): void
|
||||||
|
{
|
||||||
|
if ($this->publishMode !== 'scheduled') {
|
||||||
|
$this->scheduledAt = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) {
|
||||||
|
$scheduledAt = \Carbon\Carbon::parse($this->scheduledAt);
|
||||||
|
$this->scheduledDate = $scheduledAt->format('Y-m-d');
|
||||||
|
$this->scheduledTime = $scheduledAt->format('H:i');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blank($this->scheduledDate) || blank($this->scheduledTime)) {
|
||||||
|
$this->scheduledAt = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt,
|
||||||
|
* wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt.
|
||||||
|
*/
|
||||||
|
protected function scheduledAtUtc(): ?\Carbon\Carbon
|
||||||
|
{
|
||||||
|
if (blank($this->scheduledAt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateScheduledAtWhenReady(): void
|
||||||
|
{
|
||||||
|
if (blank($this->scheduledAt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetErrorBag('scheduledAt');
|
||||||
|
|
||||||
|
$scheduledAt = $this->scheduledAtUtc();
|
||||||
|
|
||||||
|
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||||||
|
$this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->validateOnly('scheduledAt', $this->formRules());
|
||||||
|
} catch (\Illuminate\Validation\ValidationException) {
|
||||||
|
// Termin bleibt invalid; Bag wird automatisch befüllt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live-Re-Validation für bereits invalide Felder.
|
* Live-Re-Validation für bereits invalide Felder.
|
||||||
|
*
|
||||||
|
* Die Termin-Synchronisierung liegt vollständig in den spezifischen
|
||||||
|
* `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt
|
||||||
|
* nur die generische Re-Validierung bereits fehlerhafter Felder.
|
||||||
*/
|
*/
|
||||||
public function updated(string $property): void
|
public function updated(string $property): void
|
||||||
{
|
{
|
||||||
if (! $this->getErrorBag()->has($property)) {
|
if (!$this->getErrorBag()->has($property)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,22 +276,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
|
|
||||||
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
|
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
|
||||||
{
|
{
|
||||||
$count = $exception
|
$count = $exception ? array_sum(array_map('count', $exception->errors())) : count($this->getErrorBag()->all());
|
||||||
? array_sum(array_map('count', $exception->errors()))
|
|
||||||
: count($this->getErrorBag()->all());
|
|
||||||
|
|
||||||
Flux::toast(
|
Flux::toast(heading: __('Bitte Eingaben prüfen'), text: $count > 1 ? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count]) : __('Ein Feld benötigt deine Aufmerksamkeit.'), variant: 'danger', duration: 6000);
|
||||||
heading: __('Bitte Eingaben prüfen'),
|
|
||||||
text: $count > 1
|
|
||||||
? __(':count Felder benötigen deine Aufmerksamkeit.', ['count' => $count])
|
|
||||||
: __('Ein Feld benötigt deine Aufmerksamkeit.'),
|
|
||||||
variant: 'danger',
|
|
||||||
duration: 6000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(string $submitStatus = 'draft'): void
|
public function save(string $submitStatus = 'draft'): void
|
||||||
{
|
{
|
||||||
|
$this->syncScheduledAt();
|
||||||
|
$this->useEmbargo = false;
|
||||||
|
$this->embargoAt = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->validate($this->formRules());
|
$this->validate($this->formRules());
|
||||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||||
|
|
@ -204,7 +300,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
default => PressReleaseStatus::Draft,
|
default => PressReleaseStatus::Draft,
|
||||||
};
|
};
|
||||||
|
|
||||||
$slug = (new PressRelease)->generateUniqueSlug($this->title, [
|
$slug = new PressRelease()->generateUniqueSlug($this->title, [
|
||||||
'portal' => $this->portal,
|
'portal' => $this->portal,
|
||||||
'language' => $this->language,
|
'language' => $this->language,
|
||||||
]);
|
]);
|
||||||
|
|
@ -222,17 +318,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
'subtitle' => trim($this->subtitle) ?: null,
|
'subtitle' => trim($this->subtitle) ?: null,
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'text' => $cleanText,
|
'text' => $cleanText,
|
||||||
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
|
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null,
|
||||||
? trim($this->boilerplateOverride)
|
|
||||||
: null,
|
|
||||||
'keywords' => $this->keywords ?: null,
|
'keywords' => $this->keywords ?: null,
|
||||||
'backlink_url' => $this->backlinkUrl ?: null,
|
'backlink_url' => $this->backlinkUrl ?: null,
|
||||||
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
|
'scheduled_at' => $this->publishMode === 'scheduled' ? $this->scheduledAtUtc() : null,
|
||||||
? \Carbon\Carbon::parse($this->scheduledAt)
|
'embargo_at' => null,
|
||||||
: null,
|
|
||||||
'embargo_at' => $this->useEmbargo && $this->embargoAt
|
|
||||||
? \Carbon\Carbon::parse($this->embargoAt)
|
|
||||||
: null,
|
|
||||||
'status' => $status->value,
|
'status' => $status->value,
|
||||||
'no_export' => $this->noExport,
|
'no_export' => $this->noExport,
|
||||||
]);
|
]);
|
||||||
|
|
@ -244,20 +334,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Flux::toast(
|
Flux::toast(heading: $status === PressReleaseStatus::Review ? __('Eingereicht') : __('Entwurf gespeichert'), text: $status === PressReleaseStatus::Review ? __('Pressemitteilung zur Prüfung eingereicht.') : __('Pressemitteilung als Entwurf gespeichert.'), variant: 'success');
|
||||||
heading: $status === PressReleaseStatus::Review ? __('Eingereicht') : __('Entwurf gespeichert'),
|
|
||||||
text: $status === PressReleaseStatus::Review
|
|
||||||
? __('Pressemitteilung zur Prüfung eingereicht.')
|
|
||||||
: __('Pressemitteilung als Entwurf gespeichert.'),
|
|
||||||
variant: 'success',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->redirect(route('admin.press-releases.edit', $pr->id), navigate: true);
|
$this->redirect(route('admin.press-releases.edit', $pr->id), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function with(): array
|
public function with(): array
|
||||||
{
|
{
|
||||||
$term = trim($this->companySearch);
|
$term = Portal::stripTrailingAbbreviation($this->companySearch);
|
||||||
|
|
||||||
$companies = Company::withoutGlobalScopes()
|
$companies = Company::withoutGlobalScopes()
|
||||||
->when(filled($term), function ($q) use ($term): void {
|
->when(filled($term), function ($q) use ($term): void {
|
||||||
|
|
@ -267,27 +351,22 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$q->where('name', 'like', '%'.$term.'%')
|
$q->where('name', 'like', '%' . $term . '%')->orWhere('slug', 'like', '%' . $term . '%');
|
||||||
->orWhere('slug', 'like', '%'.$term.'%');
|
|
||||||
})
|
})
|
||||||
->when(blank($term) && $this->companyId, fn ($q) => $q->whereIn('id', [(int) $this->companyId]))
|
->when(blank($term) && $this->companyId, fn($q) => $q->whereIn('id', [(int) $this->companyId]))
|
||||||
->when(blank($term) && ! $this->companyId, fn ($q) => $q->whereRaw('0 = 1'))
|
->when(blank($term) && !$this->companyId, fn($q) => $q->whereRaw('0 = 1'))
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->limit(50)
|
->limit(50)
|
||||||
->get(['id', 'name']);
|
->get(['id', 'name']);
|
||||||
|
|
||||||
$selectedCompany = $this->companyId
|
$selectedCompany = $this->companyId ? Company::withoutGlobalScopes()->find((int) $this->companyId) : null;
|
||||||
? Company::withoutGlobalScopes()->find((int) $this->companyId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'companies' => $companies,
|
'companies' => $companies,
|
||||||
'categories' => $this->categoryOptions(),
|
'categories' => $this->categoryOptions(),
|
||||||
'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both),
|
'portalOptions' => array_filter(Portal::cases(), fn(Portal $p) => $p !== Portal::Both),
|
||||||
'selectedCompany' => $selectedCompany,
|
'selectedCompany' => $selectedCompany,
|
||||||
'selectedCompanyContacts' => $selectedCompany
|
'selectedCompanyContacts' => $selectedCompany ? $this->companyContacts((int) $selectedCompany->id) : Contact::query()->whereRaw('0 = 1')->get(),
|
||||||
? $this->companyContacts((int) $selectedCompany->id)
|
|
||||||
: Contact::query()->whereRaw('0 = 1')->get(),
|
|
||||||
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
|
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -340,9 +419,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
'key' => 'tags',
|
'key' => 'tags',
|
||||||
'status' => $tagsCount >= 1 ? 'ok' : 'warn',
|
'status' => $tagsCount >= 1 ? 'ok' : 'warn',
|
||||||
'label' => __('Themen-Tags vergeben'),
|
'label' => __('Themen-Tags vergeben'),
|
||||||
'sub' => $tagsCount >= 1
|
'sub' => $tagsCount >= 1 ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) : __('empfohlen für SEO & Auffindbarkeit'),
|
||||||
? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount])
|
|
||||||
: __('empfohlen für SEO & Auffindbarkeit'),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -357,7 +434,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect(explode(',', $this->keywords))
|
return collect(explode(',', $this->keywords))
|
||||||
->map(fn (string $tag): string => trim($tag))
|
->map(fn(string $tag): string => trim($tag))
|
||||||
->filter()
|
->filter()
|
||||||
->unique()
|
->unique()
|
||||||
->values()
|
->values()
|
||||||
|
|
@ -391,10 +468,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Contact::withoutGlobalScopes()
|
return Contact::withoutGlobalScopes()->where('company_id', $companyId)->whereKey($contactId)->first();
|
||||||
->where('company_id', $companyId)
|
|
||||||
->whereKey($contactId)
|
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -402,43 +476,27 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
*/
|
*/
|
||||||
private function tagSuggestionsFor(?Company $company): array
|
private function tagSuggestionsFor(?Company $company): array
|
||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [__('Mittelstand'), __('Unternehmen'), __('Eröffnung'), __('Innovation'), __('Nachhaltigkeit')];
|
||||||
__('Mittelstand'),
|
|
||||||
__('Unternehmen'),
|
|
||||||
__('Eröffnung'),
|
|
||||||
__('Innovation'),
|
|
||||||
__('Nachhaltigkeit'),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! $company) {
|
if (!$company) {
|
||||||
return $defaults;
|
return $defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique(array_filter([
|
return array_values(array_unique(array_filter([$company->portal?->label(), $company->country_code === 'DE' ? __('Deutschland') : null, ...$defaults])));
|
||||||
$company->portal?->label(),
|
|
||||||
$company->country_code === 'DE' ? __('Deutschland') : null,
|
|
||||||
...$defaults,
|
|
||||||
])));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function categoryOptions(): Collection
|
private function categoryOptions(): Collection
|
||||||
{
|
{
|
||||||
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
|
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn() => Category::query()->with('translations')->where('is_active', true)->orderBy('id')->get());
|
||||||
->with('translations')
|
|
||||||
->where('is_active', true)
|
|
||||||
->orderBy('id')
|
|
||||||
->get());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function supportsFullTextSearch(string $term): bool
|
private function supportsFullTextSearch(string $term): bool
|
||||||
{
|
{
|
||||||
return mb_strlen($term) >= 3
|
return mb_strlen($term) >= 3 && in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
|
||||||
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div class="space-y-8" x-data="{ tagInput: '' }">
|
<div class="space-y-8 pr-editor-shell" x-data="{ tagInput: '' }">
|
||||||
{{-- ============== PAGE HEADER ============== --}}
|
{{-- ============== PAGE HEADER ============== --}}
|
||||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|
@ -455,39 +513,37 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.press-releases.index') }}"
|
||||||
|
wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{-- ============== 2-COLUMN GRID ============== --}}
|
{{-- ============== 2-COLUMN GRID ============== --}}
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
<div class="grid gap-6 pr-editor-layout">
|
||||||
|
|
||||||
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
||||||
<div class="space-y-6 min-w-0">
|
<div class="space-y-6 min-w-0">
|
||||||
|
|
||||||
{{-- 1) FIRMA-SELEKTOR --}}
|
{{-- 1) FIRMA-SELEKTOR --}}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="p-4 flex flex-wrap items-center gap-4">
|
<div class="p-4 space-y-3">
|
||||||
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }} <span class="req">*</span></span>
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<div class="min-w-[260px]">
|
<span class="pr-form-label shrink-0" style="margin-bottom:0;">
|
||||||
<flux:select
|
{{ __('Für Firma') }} <span class="req">*</span>
|
||||||
wire:model.live="companyId"
|
</span>
|
||||||
variant="combobox"
|
<flux:select wire:model.live="companyId" variant="combobox" :filter="false" clearable
|
||||||
:filter="false"
|
placeholder="{{ __('Firma suchen…') }}" class="w-full sm:flex-1">
|
||||||
clearable
|
|
||||||
placeholder="{{ __('Firma suchen…') }}"
|
|
||||||
>
|
|
||||||
<x-slot name="input">
|
<x-slot name="input">
|
||||||
<flux:select.input
|
<flux:select.input wire:model.live.debounce.300ms="companySearch"
|
||||||
wire:model.live.debounce.300ms="companySearch"
|
placeholder="{{ __('Name eingeben…') }}" />
|
||||||
placeholder="{{ __('Name eingeben…') }}"
|
|
||||||
/>
|
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@foreach ($companies as $company)
|
@foreach ($companies as $company)
|
||||||
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
|
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
|
||||||
{{ $company->name }}
|
{{ $company->name }} @if ($company->portal)
|
||||||
|
({{ $company->portal->abbreviation() }})
|
||||||
|
@endif
|
||||||
</flux:select.option>
|
</flux:select.option>
|
||||||
@endforeach
|
@endforeach
|
||||||
<x-slot name="empty">
|
<x-slot name="empty">
|
||||||
|
|
@ -501,16 +557,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</flux:select>
|
</flux:select>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }}
|
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||||
</span>
|
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }}
|
||||||
<span class="flex-1"></span>
|
</span>
|
||||||
@if ($selectedCompany)
|
@if ($selectedCompany)
|
||||||
<flux:button size="sm" variant="ghost" icon="building-office"
|
<flux:button size="sm" variant="filled" icon="building-office"
|
||||||
href="{{ route('admin.companies.show', $selectedCompany->id) }}" wire:navigate>
|
href="{{ route('admin.companies.show', $selectedCompany->id) }}" wire:navigate>
|
||||||
{{ __('Firmenprofil') }}
|
{{ __('Firmenprofil') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<flux:error name="companyId" />
|
<flux:error name="companyId" />
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -525,7 +582,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@php
|
@php
|
||||||
$titleLen = mb_strlen($title);
|
$titleLen = mb_strlen($title);
|
||||||
$titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : '');
|
$titleClass =
|
||||||
|
$titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : '');
|
||||||
$titleBar = min(100, max(0, ($titleLen / 100) * 100));
|
$titleBar = min(100, max(0, ($titleLen / 100) * 100));
|
||||||
@endphp
|
@endphp
|
||||||
<span class="pr-meter {{ $titleClass }}">
|
<span class="pr-meter {{ $titleClass }}">
|
||||||
|
|
@ -535,11 +593,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<span class="pr-bald-badge">{{ __('KI-Titel · bald') }}</span>
|
<span class="pr-bald-badge">{{ __('KI-Titel · bald') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<flux:input
|
<flux:input wire:model.live.debounce.300ms="title"
|
||||||
wire:model.live.debounce.300ms="title"
|
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}" size="lg" />
|
||||||
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<p class="pr-form-help">
|
<p class="pr-form-help">
|
||||||
{{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
|
{{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -553,7 +608,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<div class="flex items-center justify-between mb-2 gap-4">
|
<div class="flex items-center justify-between mb-2 gap-4">
|
||||||
<span class="pr-form-label" style="margin-bottom:0;">
|
<span class="pr-form-label" style="margin-bottom:0;">
|
||||||
{{ __('Untertitel') }}
|
{{ __('Untertitel') }}
|
||||||
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
|
<span class="text-[color:var(--color-ink-4)] font-normal"
|
||||||
|
style="letter-spacing:0;text-transform:none;">
|
||||||
— {{ __('optional') }}
|
— {{ __('optional') }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -566,10 +622,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
{{ $subLen }} / 200
|
{{ $subLen }} / 200
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<flux:input
|
<flux:input wire:model.live.debounce.300ms="subtitle"
|
||||||
wire:model.live.debounce.300ms="subtitle"
|
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}" />
|
||||||
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}"
|
|
||||||
/>
|
|
||||||
<flux:error name="subtitle" />
|
<flux:error name="subtitle" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -583,7 +637,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@php
|
@php
|
||||||
$textLen = app(\App\Services\PressRelease\PressReleaseHtmlSanitizer::class)->plainTextLength($text);
|
$textLen = app(
|
||||||
|
\App\Services\PressRelease\PressReleaseHtmlSanitizer::class,
|
||||||
|
)->plainTextLength($text);
|
||||||
$textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : '');
|
$textClass = $textLen >= 600 ? 'good' : ($textLen >= 50 ? 'warn' : '');
|
||||||
$textBar = min(100, max(0, ($textLen / 3500) * 100));
|
$textBar = min(100, max(0, ($textLen / 3500) * 100));
|
||||||
@endphp
|
@endphp
|
||||||
|
|
@ -594,11 +650,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
|
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<flux:editor
|
<flux:editor wire:model.live.debounce.500ms="text"
|
||||||
wire:model.live.debounce.500ms="text"
|
|
||||||
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
|
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
|
||||||
placeholder="{{ __('Hier weiterschreiben…') }}"
|
placeholder="{{ __('Hier weiterschreiben…') }}" />
|
||||||
/>
|
|
||||||
<flux:error name="text" />
|
<flux:error name="text" />
|
||||||
|
|
||||||
<div class="pr-ai-hint mt-4">
|
<div class="pr-ai-hint mt-4">
|
||||||
|
|
@ -619,14 +673,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<div class="flex items-center justify-between mb-3 gap-4">
|
<div class="flex items-center justify-between mb-3 gap-4">
|
||||||
<span class="pr-form-label" style="margin-bottom:0;">
|
<span class="pr-form-label" style="margin-bottom:0;">
|
||||||
{{ __('Über das Unternehmen') }}
|
{{ __('Über das Unternehmen') }}
|
||||||
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
|
<span class="text-[color:var(--color-ink-4)] font-normal"
|
||||||
|
style="letter-spacing:0;text-transform:none;">
|
||||||
— {{ __('Boilerplate aus Firma') }}
|
— {{ __('Boilerplate aus Firma') }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<flux:checkbox
|
<flux:checkbox wire:model.live="useBoilerplateOverride"
|
||||||
wire:model.live="useBoilerplateOverride"
|
:label="__('Für diese PM überschreiben')" />
|
||||||
:label="__('Für diese PM überschreiben')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($selectedCompany?->boilerplate)
|
@if ($selectedCompany?->boilerplate)
|
||||||
|
|
@ -634,7 +687,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<p class="m-0">{!! nl2br(e($selectedCompany->boilerplate)) !!}</p>
|
<p class="m-0">{!! nl2br(e($selectedCompany->boilerplate)) !!}</p>
|
||||||
@if ($selectedCompany->website)
|
@if ($selectedCompany->website)
|
||||||
<p class="m-0 text-[12px] text-[color:var(--color-ink-3)] mt-3">
|
<p class="m-0 text-[12px] text-[color:var(--color-ink-3)] mt-3">
|
||||||
<span class="font-semibold text-[color:var(--color-ink-2)]">{{ __('Web') }}:</span>
|
<span
|
||||||
|
class="font-semibold text-[color:var(--color-ink-2)]">{{ __('Web') }}:</span>
|
||||||
{{ $selectedCompany->website }}
|
{{ $selectedCompany->website }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -647,11 +701,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
|
|
||||||
@if ($useBoilerplateOverride)
|
@if ($useBoilerplateOverride)
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<flux:textarea
|
<flux:textarea wire:model.live.debounce.500ms="boilerplateOverride" rows="5"
|
||||||
wire:model.live.debounce.500ms="boilerplateOverride"
|
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}" />
|
||||||
rows="5"
|
|
||||||
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}"
|
|
||||||
/>
|
|
||||||
<flux:error name="boilerplateOverride" />
|
<flux:error name="boilerplateOverride" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -666,7 +717,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
{{-- /Schreibfläche --}}
|
{{-- /Schreibfläche --}}
|
||||||
|
|
||||||
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
|
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
|
||||||
<aside class="space-y-4 lg:sticky lg:top-4 self-start">
|
<aside class="space-y-4 pr-editor-side self-start">
|
||||||
|
|
||||||
{{-- Aktionen + Pre-Submit-Check --}}
|
{{-- Aktionen + Pre-Submit-Check --}}
|
||||||
<article class="panel" style="border-color:var(--color-hub);">
|
<article class="panel" style="border-color:var(--color-hub);">
|
||||||
|
|
@ -675,7 +726,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<span class="badge muted dot">{{ __('Neu') }}</span>
|
<span class="badge muted dot">{{ __('Neu') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 mb-3">
|
<div
|
||||||
|
class="rounded-[4px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 mb-3">
|
||||||
@php
|
@php
|
||||||
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
|
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
|
||||||
$totalCount = count($this->presubmitChecks);
|
$totalCount = count($this->presubmitChecks);
|
||||||
|
|
@ -702,7 +754,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</span>
|
</span>
|
||||||
<span class="lbl">
|
<span class="lbl">
|
||||||
{{ $check['label'] }}
|
{{ $check['label'] }}
|
||||||
@if (! empty($check['sub']))
|
@if (!empty($check['sub']))
|
||||||
<span class="sub">{{ $check['sub'] }}</span>
|
<span class="sub">{{ $check['sub'] }}</span>
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -710,14 +762,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<flux:button
|
<flux:button type="button" variant="primary" icon="paper-airplane" class="w-full"
|
||||||
type="button"
|
wire:click="save('review')" wire:loading.attr="disabled">
|
||||||
variant="primary"
|
|
||||||
icon="paper-airplane"
|
|
||||||
class="w-full"
|
|
||||||
wire:click="save('review')"
|
|
||||||
wire:loading.attr="disabled"
|
|
||||||
>
|
|
||||||
{{ __('Zur Prüfung einreichen') }}
|
{{ __('Zur Prüfung einreichen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
|
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
|
||||||
|
|
@ -725,14 +771,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
|
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
|
||||||
<flux:button
|
<flux:button type="button" variant="filled" icon="bookmark" class="w-full"
|
||||||
type="button"
|
wire:click="save('draft')" wire:loading.attr="disabled">
|
||||||
variant="ghost"
|
|
||||||
icon="bookmark"
|
|
||||||
class="w-full"
|
|
||||||
wire:click="save('draft')"
|
|
||||||
wire:loading.attr="disabled"
|
|
||||||
>
|
|
||||||
{{ __('Als Entwurf speichern') }}
|
{{ __('Als Entwurf speichern') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -751,11 +791,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<flux:select wire:model.live="categoryId">
|
<flux:select wire:model.live="categoryId">
|
||||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||||
@foreach ($categories as $cat)
|
@foreach ($categories as $cat)
|
||||||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
<option value="{{ $cat->id }}">
|
||||||
|
{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</flux:select>
|
||||||
<flux:error name="categoryId" />
|
<flux:error name="categoryId" />
|
||||||
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
|
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}
|
||||||
|
</flux:description>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -785,14 +827,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Portal-Override') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
<flux:label>{{ __('Portal-Override') }} <span class="text-[color:var(--color-err)]">*</span>
|
||||||
|
</flux:label>
|
||||||
<flux:select wire:model.live="portal">
|
<flux:select wire:model.live="portal">
|
||||||
@foreach ($portalOptions as $p)
|
@foreach ($portalOptions as $p)
|
||||||
<option value="{{ $p->value }}">{{ $p->label() }}</option>
|
<option value="{{ $p->value }}">{{ $p->label() }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</flux:select>
|
||||||
<flux:error name="portal" />
|
<flux:error name="portal" />
|
||||||
<flux:description>{{ __('Admin-Befugnis: Portal kann unabhängig von der Firma geändert werden.') }}</flux:description>
|
<flux:description>
|
||||||
|
{{ __('Admin-Befugnis: Portal kann unabhängig von der Firma geändert werden.') }}
|
||||||
|
</flux:description>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -812,7 +857,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
@endif
|
@endif
|
||||||
</p>
|
</p>
|
||||||
@if ($selectedCompany)
|
@if ($selectedCompany)
|
||||||
<flux:button size="sm" variant="ghost" icon="plus" class="w-full"
|
<flux:button size="sm" variant="filled" icon="plus" class="w-full"
|
||||||
href="{{ route('admin.companies.show', $selectedCompany->id) }}" wire:navigate>
|
href="{{ route('admin.companies.show', $selectedCompany->id) }}" wire:navigate>
|
||||||
{{ __('Kontakt im Firmenprofil anlegen') }}
|
{{ __('Kontakt im Firmenprofil anlegen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
|
@ -824,8 +869,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||||
@foreach ($selectedCompanyContacts as $contact)
|
@foreach ($selectedCompanyContacts as $contact)
|
||||||
@php
|
@php
|
||||||
$contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''))
|
$contactName =
|
||||||
?: __('Kontakt #:n', ['n' => $contact->id]);
|
trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?:
|
||||||
|
__('Kontakt #:n', ['n' => $contact->id]);
|
||||||
$contactRole = $contact->responsibility ?: __('Kontakt');
|
$contactRole = $contact->responsibility ?: __('Kontakt');
|
||||||
@endphp
|
@endphp
|
||||||
<option value="{{ $contact->id }}">
|
<option value="{{ $contact->id }}">
|
||||||
|
|
@ -836,16 +882,18 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<flux:error name="contactId" />
|
<flux:error name="contactId" />
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
@if (! $contactId)
|
@if (!$contactId)
|
||||||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||||||
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
|
<flux:icon name="exclamation-triangle" variant="mini"
|
||||||
|
class="size-4 flex-shrink-0 mt-0.5" />
|
||||||
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
|
<span>{{ __('Noch kein Pressekontakt ausgewählt. Empfohlen, aber nicht zwingend.') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
|
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
|
||||||
@if ($activeContact && empty($activeContact->phone))
|
@if ($activeContact && empty($activeContact->phone))
|
||||||
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
<div class="flex items-start gap-2 text-[11.5px] text-[color:var(--color-warn)]">
|
||||||
<flux:icon name="exclamation-triangle" variant="mini" class="size-4 flex-shrink-0 mt-0.5" />
|
<flux:icon name="exclamation-triangle" variant="mini"
|
||||||
|
class="size-4 flex-shrink-0 mt-0.5" />
|
||||||
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
|
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -859,15 +907,19 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
|
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
|
||||||
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
|
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
|
||||||
<strong class="font-mono text-[color:var(--color-ink-2)]">{{ count($this->tags) }}</strong> / 5
|
<strong class="font-mono text-[color:var(--color-ink-2)]">{{ count($this->tags) }}</strong> /
|
||||||
|
5
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 space-y-3">
|
<div class="p-5 space-y-3">
|
||||||
<div class="border border-[color:var(--color-bg-rule)] rounded-[4px] bg-[color:var(--color-bg-card)] px-2 py-2 min-h-[58px] flex flex-wrap items-center gap-1.5">
|
<div
|
||||||
|
class="border border-[color:var(--color-bg-rule)] rounded-[4px] bg-[color:var(--color-bg-card)] px-2 py-2 min-h-[58px] flex flex-wrap items-center gap-1.5">
|
||||||
@forelse ($this->tags as $tag)
|
@forelse ($this->tags as $tag)
|
||||||
<span class="pr-tag-chip" wire:key="tag-{{ $tag }}">
|
<span class="pr-tag-chip" wire:key="tag-{{ $tag }}">
|
||||||
{{ $tag }}
|
{{ $tag }}
|
||||||
<button type="button" class="x" wire:click="removeTag(@js($tag))" title="{{ __('Entfernen') }}">×</button>
|
<button type="button" class="x"
|
||||||
|
wire:click="removeTag(@js($tag))"
|
||||||
|
title="{{ __('Entfernen') }}">×</button>
|
||||||
</span>
|
</span>
|
||||||
@empty
|
@empty
|
||||||
@if (count($this->tags) === 0)
|
@if (count($this->tags) === 0)
|
||||||
|
|
@ -876,28 +928,23 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
@endforelse
|
@endforelse
|
||||||
<input
|
<input type="text" x-model="tagInput"
|
||||||
type="text"
|
|
||||||
x-model="tagInput"
|
|
||||||
@keydown.enter.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
|
@keydown.enter.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
|
||||||
@keydown.comma.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
|
@keydown.comma.prevent="if (tagInput.trim()) { $wire.addTag(tagInput.trim()); tagInput = ''; }"
|
||||||
class="flex-1 min-w-[80px] border-0 bg-transparent text-[12px] text-[color:var(--color-ink)] focus:outline-none p-1"
|
class="flex-1 min-w-[80px] border-0 bg-transparent text-[12px] text-[color:var(--color-ink)] focus:outline-none p-1"
|
||||||
placeholder="{{ count($this->tags) === 0 ? '' : '+ Tag' }}"
|
placeholder="{{ count($this->tags) === 0 ? '' : '+ Tag' }}"
|
||||||
@disabled(count($this->tags) >= 5)
|
@disabled(count($this->tags) >= 5) />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (! empty($tagSuggestions))
|
@if (!empty($tagSuggestions))
|
||||||
<div>
|
<div>
|
||||||
<div class="eyebrow muted mb-1.5" style="font-size:9.5px;">{{ __('Vorschläge') }}</div>
|
<div class="eyebrow muted mb-1.5" style="font-size:9.5px;">{{ __('Vorschläge') }}</div>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
@foreach ($tagSuggestions as $suggestion)
|
@foreach ($tagSuggestions as $suggestion)
|
||||||
@if (! in_array($suggestion, $this->tags, true))
|
@if (!in_array($suggestion, $this->tags, true))
|
||||||
<button
|
<button type="button" class="pr-tag-suggest"
|
||||||
type="button"
|
wire:click="addTag(@js($suggestion))">+
|
||||||
class="pr-tag-suggest"
|
{{ $suggestion }}</button>
|
||||||
wire:click="addTag(@js($suggestion))"
|
|
||||||
>+ {{ $suggestion }}</button>
|
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -919,7 +966,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
|
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
|
||||||
<span class="dot-out"></span>
|
<span class="dot-out"></span>
|
||||||
<span>
|
<span>
|
||||||
<span class="text-[12.5px] font-semibold text-[color:var(--color-hub)] block leading-tight">
|
<span
|
||||||
|
class="text-[12.5px] font-semibold text-[color:var(--color-hub)] block leading-tight">
|
||||||
{{ __('Sofort nach Freigabe') }}
|
{{ __('Sofort nach Freigabe') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
|
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
|
||||||
|
|
@ -941,39 +989,31 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@if ($publishMode === 'scheduled')
|
@if ($publishMode === 'scheduled')
|
||||||
<flux:field>
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
|
<flux:field>
|
||||||
<flux:input
|
<flux:label>{{ __('Datum') }}</flux:label>
|
||||||
wire:model.live="scheduledAt"
|
<flux:date-picker
|
||||||
type="datetime-local"
|
wire:model.live="scheduledDate"
|
||||||
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
|
type="input"
|
||||||
/>
|
:placeholder="__('Datum wählen')"
|
||||||
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
|
with-today
|
||||||
<flux:error name="scheduledAt" />
|
|
||||||
</flux:field>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="border-t pt-3" style="border-color: var(--color-line);">
|
|
||||||
<flux:switch
|
|
||||||
wire:model.live="useEmbargo"
|
|
||||||
:label="__('Sperrfrist (Embargo) setzen')"
|
|
||||||
/>
|
|
||||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
|
|
||||||
{{ __('PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if ($useEmbargo)
|
|
||||||
<flux:field class="mt-3">
|
|
||||||
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
|
|
||||||
<flux:input
|
|
||||||
wire:model.live="embargoAt"
|
|
||||||
type="datetime-local"
|
|
||||||
:min="now()->format('Y-m-d\\TH:i')"
|
|
||||||
/>
|
/>
|
||||||
<flux:error name="embargoAt" />
|
<flux:error name="scheduledDate" />
|
||||||
</flux:field>
|
</flux:field>
|
||||||
@endif
|
|
||||||
</div>
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Uhrzeit') }}</flux:label>
|
||||||
|
<flux:time-picker
|
||||||
|
wire:model.live="scheduledTime"
|
||||||
|
type="input"
|
||||||
|
:placeholder="__('Uhrzeit wählen')"
|
||||||
|
/>
|
||||||
|
<flux:error name="scheduledTime" />
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
<flux:error name="scheduledAt" />
|
||||||
|
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
@ -1005,10 +1045,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
<div class="rounded-[5px] border p-3.5"
|
<div class="rounded-[5px] border p-3.5"
|
||||||
style="background:var(--color-accent-soft);border-color:color-mix(in srgb, var(--color-accent) 50%, transparent);">
|
style="background:var(--color-accent-soft);border-color:color-mix(in srgb, var(--color-accent) 50%, transparent);">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<flux:icon name="sparkles" variant="micro" class="size-3.5 text-[color:var(--color-accent-deep)]" />
|
<flux:icon name="sparkles" variant="micro"
|
||||||
|
class="size-3.5 text-[color:var(--color-accent-deep)]" />
|
||||||
<span class="eyebrow accent">{{ __('Phase 2 — bald') }}</span>
|
<span class="eyebrow accent">{{ __('Phase 2 — bald') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
|
<ul
|
||||||
|
class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
|
||||||
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
|
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
|
||||||
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
|
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
|
||||||
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
|
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
#[Url(as: 'status', except: 'all')]
|
#[Url(as: 'status', except: 'all')]
|
||||||
public string $statusFilter = 'all';
|
public string $statusFilter = 'all';
|
||||||
|
|
||||||
|
#[Url(as: 'classification', except: 'all')]
|
||||||
|
public string $classificationFilter = 'all';
|
||||||
|
|
||||||
public string $portalFilter = 'all';
|
public string $portalFilter = 'all';
|
||||||
|
|
||||||
public string $languageFilter = 'all';
|
public string $languageFilter = 'all';
|
||||||
|
|
@ -74,6 +77,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedClassificationFilter(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function updatedPortalFilter(): void
|
public function updatedPortalFilter(): void
|
||||||
{
|
{
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
|
|
@ -142,6 +150,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
{
|
{
|
||||||
$this->search = '';
|
$this->search = '';
|
||||||
$this->statusFilter = 'all';
|
$this->statusFilter = 'all';
|
||||||
|
$this->classificationFilter = 'all';
|
||||||
$this->portalFilter = 'all';
|
$this->portalFilter = 'all';
|
||||||
$this->languageFilter = 'all';
|
$this->languageFilter = 'all';
|
||||||
$this->categoryFilter = 'all';
|
$this->categoryFilter = 'all';
|
||||||
|
|
@ -225,6 +234,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
|
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
|
||||||
|
->when($this->classificationFilter !== 'all', fn ($q) => $q->where('classification', $this->classificationFilter))
|
||||||
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
|
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
|
||||||
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
|
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
|
||||||
->when($this->categoryFilter !== 'all', fn ($q) => $q->where('category_id', (int) $this->categoryFilter))
|
->when($this->categoryFilter !== 'all', fn ($q) => $q->where('category_id', (int) $this->categoryFilter))
|
||||||
|
|
@ -472,6 +482,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="classificationFilter" class="w-full">
|
||||||
|
<option value="all">{{ __('Alle KI-Bewertungen') }}</option>
|
||||||
|
@foreach (\App\Enums\PressReleaseClassification::cases() as $c)
|
||||||
|
<option value="{{ $c->value }}">{{ __('KI: :label', ['label' => $c->label()]) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
<flux:select wire:model.live="portalFilter" class="w-full">
|
<flux:select wire:model.live="portalFilter" class="w-full">
|
||||||
<option value="all">{{ __('Alle Portale') }}</option>
|
<option value="all">{{ __('Alle Portale') }}</option>
|
||||||
@foreach ($portalOptions as $p)
|
@foreach ($portalOptions as $p)
|
||||||
|
|
@ -532,7 +549,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearUserFilter"
|
wire:click="clearUserFilter"
|
||||||
title="{{ __('Usersuche zurücksetzen') }}"
|
title="{{ __('Usersuche zurücksetzen') }}"
|
||||||
|
|
@ -572,7 +589,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearCompanyFilter"
|
wire:click="clearCompanyFilter"
|
||||||
title="{{ __('Firmensuche zurücksetzen') }}"
|
title="{{ __('Firmensuche zurücksetzen') }}"
|
||||||
|
|
@ -618,7 +635,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearContactFilter"
|
wire:click="clearContactFilter"
|
||||||
title="{{ __('Kontaktsuche zurücksetzen') }}"
|
title="{{ __('Kontaktsuche zurücksetzen') }}"
|
||||||
|
|
@ -630,6 +647,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
@php
|
@php
|
||||||
$hasAnyFilter = $search !== ''
|
$hasAnyFilter = $search !== ''
|
||||||
|| $statusFilter !== 'all'
|
|| $statusFilter !== 'all'
|
||||||
|
|| $classificationFilter !== 'all'
|
||||||
|| $portalFilter !== 'all'
|
|| $portalFilter !== 'all'
|
||||||
|| $languageFilter !== 'all'
|
|| $languageFilter !== 'all'
|
||||||
|| $categoryFilter !== 'all'
|
|| $categoryFilter !== 'all'
|
||||||
|
|
@ -670,6 +688,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($classificationFilter !== 'all')
|
||||||
|
@php $classificationEnum = \App\Enums\PressReleaseClassification::tryFrom($classificationFilter); @endphp
|
||||||
|
<span class="active-chip">
|
||||||
|
<span>{{ __('KI-Bewertung') }}:
|
||||||
|
<strong>{{ $classificationEnum?->label() ?? $classificationFilter }}</strong></span>
|
||||||
|
<button type="button" class="x" wire:click="$set('classificationFilter', 'all')"
|
||||||
|
aria-label="{{ __('Filter entfernen') }}">
|
||||||
|
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($portalFilter !== 'all')
|
@if ($portalFilter !== 'all')
|
||||||
@php $portalEnum = \App\Enums\Portal::tryFrom($portalFilter); @endphp
|
@php $portalEnum = \App\Enums\Portal::tryFrom($portalFilter); @endphp
|
||||||
<span class="active-chip">
|
<span class="active-chip">
|
||||||
|
|
@ -834,6 +867,26 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<div class="flex items-center gap-1.5 flex-wrap">
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
|
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
|
||||||
|
@if ($pr->classification)
|
||||||
|
@php
|
||||||
|
$kiBadge = match ($pr->classification) {
|
||||||
|
\App\Enums\PressReleaseClassification::Green => 'ok',
|
||||||
|
\App\Enums\PressReleaseClassification::Yellow => 'warn',
|
||||||
|
\App\Enums\PressReleaseClassification::Red => 'err',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="badge {{ $kiBadge }}" title="{{ __('KI-Bewertung') }}">{{ __('KI: :label', ['label' => $pr->classification->label()]) }}</span>
|
||||||
|
@endif
|
||||||
|
@if (! is_null($pr->content_score) && $pr->content_tier)
|
||||||
|
@php
|
||||||
|
$tierBadge = match ($pr->content_tier) {
|
||||||
|
\App\Enums\PressReleaseContentTier::Hochwertig => 'ok',
|
||||||
|
\App\Enums\PressReleaseContentTier::Geprueft => 'hub',
|
||||||
|
\App\Enums\PressReleaseContentTier::Standard => 'muted',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span class="badge {{ $tierBadge }}" title="{{ __('Content-Score') }}">{{ $pr->content_score }} · {{ $pr->content_tier->label() }}</span>
|
||||||
|
@endif
|
||||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
|
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||||
<flux:modal.trigger name="confirm-index-publish-{{ $pr->id }}">
|
<flux:modal.trigger name="confirm-index-publish-{{ $pr->id }}">
|
||||||
<button type="button" class="inline-action"
|
<button type="button" class="inline-action"
|
||||||
|
|
@ -903,13 +956,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
|
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
|
||||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||||
<flux:icon.calendar variant="micro" class="size-3" />
|
<flux:icon.calendar variant="micro" class="size-3" />
|
||||||
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
|
<span>{{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||||
<flux:icon.lock-closed variant="micro" class="size-3" />
|
<flux:icon.lock-closed variant="micro" class="size-3" />
|
||||||
<span>{{ __('Embargo bis') }} {{ $pr->embargo_at->format('d.m.') }}</span>
|
<span>{{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
|
|
@ -921,10 +974,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
||||||
|
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<flux:button size="sm" variant="ghost" icon="eye"
|
<flux:button size="sm" variant="filled" icon="eye"
|
||||||
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
|
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
|
||||||
title="{{ __('Ansehen') }}" />
|
title="{{ __('Ansehen') }}" />
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
<flux:button size="sm" variant="filled" icon="pencil"
|
||||||
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate
|
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate
|
||||||
title="{{ __('Bearbeiten') }}" />
|
title="{{ __('Bearbeiten') }}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
|
use App\Services\PressRelease\PressReleaseCoverImage;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
|
@ -81,20 +82,29 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
->orderBy('first_name')
|
->orderBy('first_name')
|
||||||
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
|
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
|
||||||
'statusLogs.changedBy:id,name',
|
'statusLogs.changedBy:id,name',
|
||||||
|
'kiAudits',
|
||||||
])
|
])
|
||||||
->findOrFail($this->id);
|
->findOrFail($this->id);
|
||||||
|
|
||||||
|
$latestClassification = $pr->kiAudits
|
||||||
|
->firstWhere('type', \App\Models\KiAudit::TYPE_CLASSIFICATION);
|
||||||
|
|
||||||
$latestRejection = null;
|
$latestRejection = null;
|
||||||
if ($pr->status->value === 'rejected') {
|
if ($pr->status->value === 'rejected') {
|
||||||
$latestRejection = $pr->statusLogs
|
$latestRejection = $pr->statusLogs
|
||||||
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
|
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cover = app(PressReleaseCoverImage::class);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pr' => $pr,
|
'pr' => $pr,
|
||||||
'statusLogs' => $pr->statusLogs,
|
'statusLogs' => $pr->statusLogs,
|
||||||
'contacts' => $pr->contacts,
|
'contacts' => $pr->contacts,
|
||||||
|
'latestClassification' => $latestClassification,
|
||||||
'latestRejection' => $latestRejection,
|
'latestRejection' => $latestRejection,
|
||||||
|
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
||||||
|
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
||||||
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
|
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
|
||||||
?? $pr->category?->translations->first()?->name
|
?? $pr->category?->translations->first()?->name
|
||||||
?? '–',
|
?? '–',
|
||||||
|
|
@ -128,6 +138,26 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
||||||
<span class="eyebrow muted">{{ __('Content · Pressemitteilung') }}</span>
|
<span class="eyebrow muted">{{ __('Content · Pressemitteilung') }}</span>
|
||||||
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
|
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
|
||||||
|
@if ($pr->classification)
|
||||||
|
@php
|
||||||
|
$kiBadgeClass = match ($pr->classification) {
|
||||||
|
\App\Enums\PressReleaseClassification::Green => 'ok',
|
||||||
|
\App\Enums\PressReleaseClassification::Yellow => 'warn',
|
||||||
|
\App\Enums\PressReleaseClassification::Red => 'err',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span @class(['badge', $kiBadgeClass])>{{ __('KI: :label', ['label' => $pr->classification->label()]) }}</span>
|
||||||
|
@endif
|
||||||
|
@if (! is_null($pr->content_score) && $pr->content_tier)
|
||||||
|
@php
|
||||||
|
$tierBadge = match ($pr->content_tier) {
|
||||||
|
\App\Enums\PressReleaseContentTier::Hochwertig => 'ok',
|
||||||
|
\App\Enums\PressReleaseContentTier::Geprueft => 'hub',
|
||||||
|
\App\Enums\PressReleaseContentTier::Standard => 'muted',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
<span @class(['badge', $tierBadge])>{{ __('Score :score · :tier', ['score' => $pr->content_score, 'tier' => $pr->content_tier->label()]) }}</span>
|
||||||
|
@endif
|
||||||
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
|
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
|
||||||
<span class="badge hub">{{ $pr->portal->label() }}</span>
|
<span class="badge hub">{{ $pr->portal->label() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -152,15 +182,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="pencil" href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate>
|
<flux:button variant="filled" icon="pencil" href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||||
{{ __('Bearbeiten') }}
|
{{ __('Bearbeiten') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{{-- ============== TITELBILD (Hero) ============== --}}
|
||||||
|
{{-- Harte Obergrenze 1280x580 px: Container deckelt Breite und Seitenverhältnis,
|
||||||
|
damit das Bild auf großen Screens nicht über die Detailgröße hinauswächst. --}}
|
||||||
|
<article class="panel overflow-hidden mx-auto w-full max-w-[1280px]">
|
||||||
|
<div class="relative aspect-[1280/580] w-full">
|
||||||
|
<img src="{{ $coverUrl }}" alt="{{ $pr->title }}"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
@if ($coverIsPlaceholder)
|
||||||
|
<div class="flex items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
|
||||||
|
<flux:icon.photo variant="micro" class="size-3.5" />
|
||||||
|
<span>{{ __('Platzhalter-Titelbild (kein eigenes Bild hochgeladen).') }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
|
||||||
{{-- ============== REJECTION-HINWEIS ============== --}}
|
{{-- ============== REJECTION-HINWEIS ============== --}}
|
||||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
|
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
|
||||||
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
|
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
|
||||||
|
|
@ -205,10 +251,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
|
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
|
||||||
<p class="m-0">{{ __('Diese PM wartet auf eine redaktionelle Entscheidung.') }}</p>
|
<p class="m-0">{{ __('Diese PM wartet auf eine redaktionelle Entscheidung.') }}</p>
|
||||||
|
@if ($latestClassification && $latestClassification->reason)
|
||||||
|
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||||
|
<strong class="text-[color:var(--color-ink-2)]">{{ __('KI-Hinweis') }}:</strong>
|
||||||
|
{{ $latestClassification->reason }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
@if ($pr->scheduled_at)
|
@if ($pr->scheduled_at)
|
||||||
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||||
<flux:icon.calendar variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
|
<flux:icon.calendar variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
|
||||||
{{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduled_at->format('d.m.Y H:i')]) }}
|
{{ __('Geplante Veröffentlichung: :date', ['date' => $pr->scheduledAtLocal()->format('d.m.Y H:i')]) }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -243,7 +295,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||||
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||||
<flux:icon.lock-closed variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
|
<flux:icon.lock-closed variant="micro" class="inline-block size-3.5 -mt-0.5 mr-0.5" />
|
||||||
{{ __('Sperrfrist bis: :date', ['date' => $pr->embargo_at->format('d.m.Y H:i')]) }}
|
{{ __('Sperrfrist bis: :date', ['date' => $pr->embargoAtLocal()->format('d.m.Y H:i')]) }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($pr->hits > 0)
|
@if ($pr->hits > 0)
|
||||||
|
|
@ -254,7 +306,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<flux:modal.trigger name="confirm-show-archive">
|
<flux:modal.trigger name="confirm-show-archive">
|
||||||
<flux:button type="button" variant="ghost">{{ __('Archivieren') }}</flux:button>
|
<flux:button type="button" variant="filled">{{ __('Archivieren') }}</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -266,7 +318,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
|
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
|
||||||
@if ($pr->company)
|
@if ($pr->company)
|
||||||
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate>
|
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('admin.companies.show', $pr->company->id) }}" wire:navigate>
|
||||||
{{ __('Firma') }}
|
{{ __('Firma') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -340,7 +392,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplant') }}</div>
|
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplant') }}</div>
|
||||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||||
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
|
{{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -348,7 +400,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
|
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
|
||||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||||
{{ $pr->embargo_at->format('d.m.Y H:i') }}
|
{{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ new #[Layout('components.layouts.app'), Title('Performance Reports')] class exte
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Filter') }}</span>
|
<span class="section-eyebrow">{{ __('Filter') }}</span>
|
||||||
<flux:button size="sm" variant="ghost" icon="arrow-path" wire:click="resetFilters">
|
<flux:button size="sm" variant="filled" icon="arrow-path" wire:click="resetFilters">
|
||||||
{{ __('Filter zurücksetzen') }}
|
{{ __('Filter zurücksetzen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,7 +143,7 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.roles.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.roles.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.roles.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -178,7 +178,7 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.roles.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.roles.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary">
|
<flux:button type="submit" variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ new #[Layout('components.layouts.app'), Title('Rollen & Rechte')] class extends
|
||||||
|
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
@if (\Illuminate\Support\Facades\Route::has('admin.roles.edit'))
|
@if (\Illuminate\Support\Facades\Route::has('admin.roles.edit'))
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.roles.edit', $role->id) }}" wire:navigate />
|
<flux:button size="sm" variant="filled" icon="pencil" href="{{ route('admin.roles.edit', $role->id) }}" wire:navigate />
|
||||||
@endif
|
@endif
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
</flux:table.row>
|
</flux:table.row>
|
||||||
|
|
|
||||||
|
|
@ -359,7 +359,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
||||||
@if ($search || $activeFilter !== 'all' || $portalFilter !== 'all' || $roleFilter !== 'all' || $qualityFilter !== 'all' || $permissionFilter !== 'all')
|
@if ($search || $activeFilter !== 'all' || $portalFilter !== 'all' || $roleFilter !== 'all' || $qualityFilter !== 'all' || $permissionFilter !== 'all')
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge hub dot">{{ __('Filter aktiv') }}</span>
|
<span class="badge hub dot">{{ __('Filter aktiv') }}</span>
|
||||||
<flux:button size="sm" variant="ghost" icon="arrow-path" type="button" wire:click="resetFilters">
|
<flux:button size="sm" variant="filled" icon="arrow-path" type="button" wire:click="resetFilters">
|
||||||
{{ __('Zurücksetzen') }}
|
{{ __('Zurücksetzen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -464,14 +464,14 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
||||||
|
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<flux:button size="sm" variant="ghost" icon="eye"
|
<flux:button size="sm" variant="filled" icon="eye"
|
||||||
wire:click="showUserDetails({{ $user->id }})" />
|
wire:click="showUserDetails({{ $user->id }})" />
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
<flux:button size="sm" variant="filled" icon="pencil"
|
||||||
href="{{ route('admin.users.edit', $user->id) }}" wire:navigate />
|
href="{{ route('admin.users.edit', $user->id) }}" wire:navigate />
|
||||||
@if($canLoginAsUser)
|
@if($canLoginAsUser)
|
||||||
<flux:button
|
<flux:button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="user"
|
icon="user"
|
||||||
square
|
square
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -643,7 +643,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
||||||
{{ __('Bearbeiten') }}
|
{{ __('Bearbeiten') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:modal.close>
|
<flux:modal.close>
|
||||||
<flux:button variant="ghost">{{ __('Schließen') }}</flux:button>
|
<flux:button variant="filled">{{ __('Schließen') }}</flux:button>
|
||||||
</flux:modal.close>
|
</flux:modal.close>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -663,7 +663,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
||||||
@if($selectedUser->published_press_releases_count > 0)
|
@if($selectedUser->published_press_releases_count > 0)
|
||||||
<flux:button
|
<flux:button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="arrow-top-right-on-square"
|
icon="arrow-top-right-on-square"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
href="{{ route('admin.press-releases.index', ['user' => $selectedUser->id, 'status' => \App\Enums\PressReleaseStatus::Published->value]) }}"
|
href="{{ route('admin.press-releases.index', ['user' => $selectedUser->id, 'status' => \App\Enums\PressReleaseStatus::Published->value]) }}"
|
||||||
|
|
@ -898,7 +898,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
|
@if (\Illuminate\Support\Facades\Route::has('admin.contacts.edit'))
|
||||||
<flux:button size="sm" variant="ghost" icon="pencil" href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate>
|
<flux:button size="sm" variant="filled" icon="pencil" href="{{ route('admin.contacts.edit', $contact->id) }}" wire:navigate>
|
||||||
{{ __('Bearbeiten') }}
|
{{ __('Bearbeiten') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.users.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.users.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -365,7 +365,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
|
||||||
<option value="owner">{{ __('Owner') }}</option>
|
<option value="owner">{{ __('Owner') }}</option>
|
||||||
</flux:select>
|
</flux:select>
|
||||||
|
|
||||||
<flux:button size="sm" variant="ghost" icon="x-mark"
|
<flux:button size="sm" variant="filled" icon="x-mark"
|
||||||
wire:click="removeLinkedCompany({{ $company->id }})">
|
wire:click="removeLinkedCompany({{ $company->id }})">
|
||||||
{{ __('Entfernen') }}
|
{{ __('Entfernen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
|
@ -439,7 +439,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.users.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.users.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary" icon="check">
|
<flux:button type="submit" variant="primary" icon="check">
|
||||||
|
|
|
||||||
|
|
@ -739,7 +739,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.users.index') }}" wire:navigate>
|
<flux:button variant="filled" icon="arrow-left" href="{{ route('admin.users.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zurück') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1035,7 +1035,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearCompanyLookup"
|
wire:click="clearCompanyLookup"
|
||||||
title="{{ __('Firmensuche zurücksetzen') }}"
|
title="{{ __('Firmensuche zurücksetzen') }}"
|
||||||
|
|
@ -1056,7 +1056,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
||||||
<option value="owner">{{ __('Owner') }}</option>
|
<option value="owner">{{ __('Owner') }}</option>
|
||||||
</flux:select>
|
</flux:select>
|
||||||
|
|
||||||
<flux:button type="button" variant="ghost" icon="x-mark" wire:click="removeLinkedCompany({{ $company->id }})">
|
<flux:button type="button" variant="filled" icon="x-mark" wire:click="removeLinkedCompany({{ $company->id }})">
|
||||||
{{ __('Entfernen') }}
|
{{ __('Entfernen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1113,7 +1113,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
||||||
<flux:button
|
<flux:button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
wire:click="clearContactLookup"
|
wire:click="clearContactLookup"
|
||||||
title="{{ __('Kontaktsuche zurücksetzen') }}"
|
title="{{ __('Kontaktsuche zurücksetzen') }}"
|
||||||
|
|
@ -1128,7 +1128,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
||||||
</flux:text>
|
</flux:text>
|
||||||
<flux:button
|
<flux:button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="filled"
|
||||||
icon="x-mark"
|
icon="x-mark"
|
||||||
type="button"
|
type="button"
|
||||||
wire:click="removeLinkedContact({{ $contactForm['id'] }})"
|
wire:click="removeLinkedContact({{ $contactForm['id'] }})"
|
||||||
|
|
@ -1230,7 +1230,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="p-5 flex justify-end gap-3">
|
<div class="p-5 flex justify-end gap-3">
|
||||||
<flux:button variant="ghost" href="{{ route('admin.users.index') }}" wire:navigate>
|
<flux:button variant="filled" href="{{ route('admin.users.index') }}" wire:navigate>
|
||||||
{{ __('Abbrechen') }}
|
{{ __('Abbrechen') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
<flux:button type="submit" variant="primary" icon="check">
|
<flux:button type="submit" variant="primary" icon="check">
|
||||||
|
|
|
||||||