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>
This commit is contained in:
Kevin Adametz 2026-06-12 08:30:13 +00:00
parent ba0ee120f1
commit 48993f5506
141 changed files with 5922 additions and 1001 deletions

View file

@ -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')

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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',
};
}
}

View 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;
}
}

View 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;
}
}

View file

@ -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);

View file

@ -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'],

View file

@ -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'],

View 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);
}
}
}

View 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
View 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);
}
}

View file

@ -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.

View file

@ -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',

View file

@ -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
*/ */

View file

@ -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)

View file

@ -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(),
]; ];
} }

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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],
);
}
}

View file

@ -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}";
}
}

View file

@ -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);
}
}

View file

@ -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,
) {}
}

View file

@ -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;
}

View file

@ -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],
);
}
}

View file

@ -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}";
}
}

View 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();
}
}

View file

@ -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
View 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'
],
],
];

View file

@ -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),
],
]; ];

View 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,
]);
}
}

View file

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

View file

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

View file

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

View file

@ -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',
]);
});
}
};

View file

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

View file

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

View file

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

View file

@ -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"/>

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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">

View file

@ -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">

View file

@ -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
> >

View file

@ -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>

View file

@ -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">

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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">

View file

@ -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

View file

@ -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"

View file

@ -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">

View file

@ -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">

View file

@ -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">

View file

@ -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

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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">

View file

@ -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">

View file

@ -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>

View file

@ -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

View file

@ -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">

View file

@ -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">

Some files were not shown because too many files have changed in this diff Show more