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 0efabaf446
commit a000238ca8
141 changed files with 5922 additions and 1001 deletions

View file

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
@ -11,11 +12,13 @@ use Illuminate\Support\Str;
use Throwable;
/**
* Veröffentlicht Pressemitteilungen mit Status `review` und einem
* `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
* Veröffentlicht Pressemitteilungen mit Status `review`, der KI-Klassifikation
* `green` und einem `scheduled_at`-Zeitpunkt, der erreicht/überschritten wurde.
*
* 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
* Mail-Benachrichtigung des Autors.
@ -43,6 +46,7 @@ class PublishScheduledPressReleases extends Command
$candidates = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Review->value)
->where('classification', PressReleaseClassification::Green->value)
->whereNotNull('scheduled_at')
->where('scheduled_at', '<=', $now)
->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 (0100) 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;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StorePressReleaseRequest;
use App\Http\Requests\Api\V1\UpdatePressReleaseRequest;
use App\Http\Resources\PressReleaseResource;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -46,7 +49,10 @@ class PressReleaseController extends Controller
$company->portal->value,
$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(
@ -101,11 +107,51 @@ class PressReleaseController extends Controller
$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(
$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
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);

View file

@ -2,10 +2,8 @@
namespace App\Http\Requests\Api\V1;
use App\Enums\PressReleaseStatus;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePressReleaseRequest extends FormRequest
{
@ -32,10 +30,6 @@ class StorePressReleaseRequest extends FormRequest
'text' => ['required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
'keywords' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in([
PressReleaseStatus::Draft->value,
PressReleaseStatus::Review->value,
])],
'teaser_begin' => ['nullable', 'integer', 'min:0'],
'teaser_end' => ['nullable', 'integer', 'min:0'],
'no_export' => ['nullable', 'boolean'],

View file

@ -2,10 +2,8 @@
namespace App\Http\Requests\Api\V1;
use App\Enums\PressReleaseStatus;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePressReleaseRequest extends FormRequest
{
@ -32,10 +30,6 @@ class UpdatePressReleaseRequest extends FormRequest
'text' => ['sometimes', 'required', 'string'],
'backlink_url' => ['nullable', 'url', 'max:255'],
'keywords' => ['nullable', 'string', 'max:255'],
'status' => ['sometimes', Rule::in([
PressReleaseStatus::Draft->value,
PressReleaseStatus::Review->value,
])],
'teaser_begin' => ['nullable', 'integer', 'min:0'],
'teaser_end' => ['nullable', 'integer', 'min:0'],
'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;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseContentTier;
use App\Enums\PressReleasePlaceholder;
use App\Enums\PressReleaseStatus;
use App\Models\Concerns\HasUniqueSlug;
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\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
class PressRelease extends Model
@ -21,6 +25,13 @@ class PressRelease extends Model
/** @use HasFactory<PressReleaseFactory> */
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>
*/
@ -37,6 +48,13 @@ class PressRelease extends Model
protected static function booted(): void
{
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 = [
@ -51,9 +69,15 @@ class PressRelease extends Model
'slug',
'text',
'boilerplate_override',
'placeholder_variant',
'backlink_url',
'keywords',
'status',
'classification',
'classified_at',
'content_score',
'content_tier',
'scored_at',
'hits',
'teaser_begin',
'teaser_end',
@ -69,7 +93,13 @@ class PressRelease extends Model
{
return [
'portal' => Portal::class,
'placeholder_variant' => PressReleasePlaceholder::class,
'status' => PressReleaseStatus::class,
'classification' => PressReleaseClassification::class,
'classified_at' => 'datetime',
'content_score' => 'integer',
'content_tier' => PressReleaseContentTier::class,
'scored_at' => 'datetime',
'hits' => 'integer',
'teaser_begin' => '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
{
return $this->belongsTo(User::class);
@ -116,6 +162,11 @@ class PressRelease extends Model
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
* <p>/<br>-wrapped legacy plain text for older imports.

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\ImageLicenseType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -19,6 +20,16 @@ class PressReleaseImage extends Model
'title',
'description',
'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',
'sort_order',
'width',
@ -32,6 +43,9 @@ class PressReleaseImage extends Model
{
return [
'variants' => 'array',
'license_type' => ImageLicenseType::class,
'persons_consent' => 'boolean',
'rights_confirmed_at' => 'datetime',
'is_preview' => 'boolean',
'sort_order' => 'integer',
'width' => 'integer',

View file

@ -43,6 +43,8 @@ class User extends Authenticatable
'legacy_portal',
'legacy_id',
'password',
'press_release_quota',
'press_release_quota_used_this_month',
];
/**
@ -73,9 +75,23 @@ class User extends Authenticatable
'last_seen_at' => 'datetime',
'deleted_at' => 'datetime',
'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
*/

View file

@ -2,6 +2,7 @@
namespace App\Services\Customer;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
@ -78,6 +79,30 @@ class CustomerCompanyContext
->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
{
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
{
return $this->accessibleCompanyQuery($user)

View file

@ -43,6 +43,8 @@ class ImageService
'thumb' => ['width' => 320, 'height' => 240],
'medium' => ['width' => 800, 'height' => 600],
'large' => ['width' => 1600, 'height' => 1200],
// Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px.
'cover' => ['width' => 1280, 'height' => 580],
];
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_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') {}
@ -99,8 +101,9 @@ class ImageService
}
/**
* Persists a freshly uploaded press release image and generates all
* variants. Original is stored under `press-releases/{id}/images`.
* Persists a freshly uploaded press release image, generates all variants
* and discards the original upload. The canonical stored path points to
* the cover variant to keep storage usage predictable.
*
* @return array{
* path: string,
@ -122,9 +125,6 @@ class ImageService
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$absolute = $disk->path($relativePath);
$size = @getimagesize($absolute) ?: [null, null];
$variants = $this->generateVariants(
$disk,
$relativePath,
@ -133,11 +133,19 @@ class ImageService
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 [
'path' => $relativePath,
'path' => $coverPath,
'variants' => $variants,
'width' => is_int($size[0] ?? null) ? $size[0] : null,
'height' => is_int($size[1] ?? null) ? $size[1] : null,
'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null,
'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null,
'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 0100-Score plus optionalen
* Faktor-Breakdown, damit der Job einheitlich persistieren/auditieren kann.
*/
final class ContentScoreResult
{
/**
* @param int $score 0100
* @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 (0100) 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 0100-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;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
@ -43,9 +46,101 @@ class PressReleaseService
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
$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]);
@ -63,7 +158,7 @@ class PressReleaseService
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($pressRelease),
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
@ -83,14 +178,18 @@ class PressReleaseService
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
* ö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) {
return $pressRelease->published_at;
}
$base = $pressRelease->scheduled_at ?: now();
$base = $pressRelease->scheduled_at ?: ($override ?? now());
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
return $pressRelease->embargo_at;
@ -99,7 +198,7 @@ class PressReleaseService
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]);
@ -107,7 +206,7 @@ class PressReleaseService
$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);
}

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 0100-Score (Standard < 60 Geprüft < 80 Hochwertig).
| Schwellen werden laut Konzept nach 100200 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'),
],
],
'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 (0100, 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="BCRYPT_ROUNDS" value="4"/>
<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_DATABASE" value=":memory:" 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.
*/
@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 {
display: flex;
align-items: center;

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -1,7 +1,7 @@
<x-layouts.app>
<flux:main>
<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') }}
</flux:button>
</div>

View file

@ -16,8 +16,8 @@
</head>
<body class="min-h-screen bg-bg text-ink antialiased">
<flux:sidebar sticky stashable class="border-e border-bg-rule">
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
<flux:sidebar sticky stashable breakpoint="1280px" class="border-e border-bg-rule">
<flux:sidebar.toggle class="xl:hidden" icon="x-mark" />
{{-- Brand-Block: Wortmarke + Hub-Eyebrow --}}
<a href="{{ config('domains.domain_main_url') }}" class="block px-2 pt-1 pb-3 no-underline">
@ -292,8 +292,8 @@
</flux:sidebar>
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<flux:header class="xl:hidden">
<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">
<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 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') }}
</flux:button>
</div>

View file

@ -191,7 +191,7 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext
</div>
<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') }}
</flux:button>
</div>

View file

@ -244,7 +244,7 @@ new #[Layout('components.layouts.app'), Title('Kategorien')] class extends Compo
@endif
<flux:button
size="xs"
variant="ghost"
variant="filled"
icon="pencil"
:href="route('admin.categories.edit', $category->id)"
wire:navigate

View file

@ -134,7 +134,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
</div>
<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') }}
</flux:button>
</div>
@ -271,7 +271,14 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
<div class="p-5 space-y-4">
<flux:field>
<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:error name="logo" />
@ -298,7 +305,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
</div>
<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') }}
</flux:button>
<flux:button type="submit" variant="primary">

View file

@ -213,7 +213,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
</div>
<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') }}
</flux:button>
</div>
@ -350,7 +350,14 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
<div class="p-5 space-y-4">
<flux:field>
<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: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"
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>
<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') }}
</flux:button>
</div>
@ -382,7 +389,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
<div class="flex-1">
{{ __('Logo wird beim Speichern entfernt.') }}
</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') }}
</flux:button>
</div>
@ -413,7 +420,7 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
</flux:button>
</flux:modal.trigger>
<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') }}
</flux:button>
<flux:button type="submit" variant="primary">

View file

@ -396,7 +396,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearUserSearch"
title="{{ __('Usersuche zurücksetzen') }}"
@ -437,7 +437,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearContactSearch"
title="{{ __('Kontaktsuche zurücksetzen') }}"
@ -481,9 +481,9 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
<flux:table.cell>
<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 />
<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 />
</div>
</flux:table.cell>
@ -532,7 +532,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
@if ($company->press_releases_count > 0)
<flux:button
size="sm"
variant="ghost"
variant="filled"
href="{{ route('admin.press-releases.index', ['company' => $company->id]) }}"
wire:navigate
>
@ -547,7 +547,7 @@ new #[Layout('components.layouts.app'), Title('Firmen')] class extends Component
@if ($company->contacts_count > 0)
<flux:button
size="sm"
variant="ghost"
variant="filled"
href="{{ route('admin.contacts.index', ['company' => $company->id]) }}"
wire:navigate
>

View file

@ -253,11 +253,11 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
</div>
<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') }}
</flux:button>
@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') }}
</flux:button>
@endif
@ -344,7 +344,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
<article class="panel lg:col-span-2">
<div class="panel-head">
<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') }}
</flux:button>
</div>
@ -454,7 +454,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
@endif
</div>
@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
</div>
</div>

View file

@ -163,7 +163,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
</div>
<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') }}
</flux:button>
</div>
@ -283,7 +283,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
<article class="panel">
<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') }}
</flux:button>
<flux:button type="submit" variant="primary">

View file

@ -195,7 +195,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
</div>
<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') }}
</flux:button>
</div>
@ -330,7 +330,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
</flux:button>
</flux:modal.trigger>
<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') }}
</flux:button>
<flux:button type="submit" variant="primary">

View file

@ -490,7 +490,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearCompanySearch"
title="{{ __('Firmensuche zurücksetzen') }}"
@ -526,7 +526,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearUserSearch"
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">
<flux:input wire:model="presetName" placeholder="{{ __('Neues Preset speichern...') }}"
class="flex-1" />
<flux:button wire:click="savePreset" variant="ghost" icon="bookmark">
<flux:button wire:click="savePreset" variant="filled" icon="bookmark">
{{ __('Preset speichern') }}
</flux:button>
</div>
@ -573,8 +573,8 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
</option>
@endforeach
</flux:select>
<flux:button wire:click="applyPreset" variant="ghost">{{ __('Anwenden') }}</flux:button>
<flux:button wire:click="setDefaultPreset" variant="ghost">{{ __('Als Standard') }}</flux:button>
<flux:button wire:click="applyPreset" variant="filled">{{ __('Anwenden') }}</flux:button>
<flux:button wire:click="setDefaultPreset" variant="filled">{{ __('Als Standard') }}</flux:button>
<flux:button wire:click="deletePreset" variant="danger">{{ __('Löschen') }}</flux:button>
</div>
</div>
@ -622,11 +622,11 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
<flux:table.cell>
<div class="flex gap-2">
@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 />
@endif
@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) }}"
wire:navigate />
@endif
@ -674,7 +674,7 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
@if ($contact->press_releases_count > 0)
<flux:button
size="sm"
variant="ghost"
variant="filled"
href="{{ route('admin.press-releases.index', ['contact' => $contact->id]) }}"
wire:navigate
>
@ -694,11 +694,11 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
<flux:table.cell>
<div class="flex gap-2">
<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-on:click.prevent="$dispatch('open-modal', 'confirm-contact-delete-{{ $contact->id }}')" />
</flux:modal.trigger>
<flux:button size="sm" variant="ghost" icon="envelope"
<flux:button size="sm" variant="filled" icon="envelope"
href="mailto:{{ $contact->email }}" />
</div>

View file

@ -92,7 +92,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
</div>
<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') }}
</flux:button>
</div>
@ -194,7 +194,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
<article class="panel">
<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') }}
</flux:button>
<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 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') }}
</flux:button>
</div>
@ -233,7 +233,7 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
</flux:button>
<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') }}
</flux:button>
<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">
<flux:button
size="xs"
variant="ghost"
variant="filled"
:icon="$code->is_active ? 'pause' : 'play'"
wire:click="toggleActive({{ $code->id }})"
:title="$code->is_active ? __('Deaktivieren') : __('Aktivieren')"
/>
<flux:button
size="xs"
variant="ghost"
variant="filled"
icon="pencil"
:href="route('admin.footer-codes.edit', $code->id)"
wire:navigate

View file

@ -177,7 +177,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
<article class="panel">
<div class="panel-head">
<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') }}
</flux:button>
</div>
@ -260,7 +260,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
<div class="space-y-0.5">
<flux:button
size="xs"
variant="ghost"
variant="filled"
:href="route('admin.users.show', $invoice->user)"
wire:navigate
>
@ -297,7 +297,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
<div class="flex items-center gap-2">
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="arrow-top-right-on-square"
:href="route('admin.legacy-invoices.pdf', $invoice)"
target="_blank"

View file

@ -108,7 +108,7 @@ new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends
</div>
<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') }}
</flux:button>
<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 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') }}
</flux:button>
</div>
@ -77,7 +77,7 @@ new #[Layout('components.layouts.app'), Title('Neue Voreinstellung')] class exte
<article class="panel">
<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') }}
</flux:button>
<flux:button type="submit" variant="primary">

View file

@ -89,7 +89,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellung bearbeiten')] clas
</div>
<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') }}
</flux:button>
</div>
@ -99,7 +99,7 @@ new #[Layout('components.layouts.app'), Title('Voreinstellung bearbeiten')] clas
<article class="panel">
<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') }}
</flux:button>
<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: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.row>
@empty

View file

@ -18,8 +18,7 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
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 $language = 'de';
@ -52,6 +51,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public ?string $scheduledAt = null;
public ?string $scheduledDate = null;
public ?string $scheduledTime = null;
public bool $useEmbargo = false;
public ?string $embargoAt = null;
@ -61,6 +64,30 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
$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
{
if (!$this->companyId) {
@ -109,10 +136,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$existing = array_values(array_filter($this->tagsArray(), fn(string $existingTag): bool => $existingTag !== $tag));
$this->keywords = implode(', ', $existing);
@ -143,22 +167,99 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
];
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 {
$rules['scheduledDate'] = ['nullable'];
$rules['scheduledTime'] = ['nullable'];
$rules['scheduledAt'] = ['nullable'];
}
if ($this->useEmbargo) {
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
} else {
$rules['embargoAt'] = ['nullable'];
}
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.
*
* 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
{
@ -175,22 +276,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
{
$count = $exception
? array_sum(array_map('count', $exception->errors()))
: count($this->getErrorBag()->all());
$count = $exception ? array_sum(array_map('count', $exception->errors())) : count($this->getErrorBag()->all());
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,
);
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);
}
public function save(string $submitStatus = 'draft'): void
{
$this->syncScheduledAt();
$this->useEmbargo = false;
$this->embargoAt = null;
try {
$this->validate($this->formRules());
} catch (\Illuminate\Validation\ValidationException $e) {
@ -204,7 +300,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
default => PressReleaseStatus::Draft,
};
$slug = (new PressRelease)->generateUniqueSlug($this->title, [
$slug = new PressRelease()->generateUniqueSlug($this->title, [
'portal' => $this->portal,
'language' => $this->language,
]);
@ -222,17 +318,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
'subtitle' => trim($this->subtitle) ?: null,
'slug' => $slug,
'text' => $cleanText,
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
? trim($this->boilerplateOverride)
: null,
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
? \Carbon\Carbon::parse($this->scheduledAt)
: null,
'embargo_at' => $this->useEmbargo && $this->embargoAt
? \Carbon\Carbon::parse($this->embargoAt)
: null,
'scheduled_at' => $this->publishMode === 'scheduled' ? $this->scheduledAtUtc() : null,
'embargo_at' => null,
'status' => $status->value,
'no_export' => $this->noExport,
]);
@ -244,20 +334,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
}
}
Flux::toast(
heading: $status === PressReleaseStatus::Review ? __('Eingereicht') : __('Entwurf gespeichert'),
text: $status === PressReleaseStatus::Review
? __('Pressemitteilung zur Prüfung eingereicht.')
: __('Pressemitteilung als Entwurf gespeichert.'),
variant: 'success',
);
Flux::toast(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);
}
public function with(): array
{
$term = trim($this->companySearch);
$term = Portal::stripTrailingAbbreviation($this->companySearch);
$companies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
@ -267,8 +351,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
return;
}
$q->where('name', 'like', '%'.$term.'%')
->orWhere('slug', 'like', '%'.$term.'%');
$q->where('name', '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->whereRaw('0 = 1'))
@ -276,18 +359,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
->limit(50)
->get(['id', 'name']);
$selectedCompany = $this->companyId
? Company::withoutGlobalScopes()->find((int) $this->companyId)
: null;
$selectedCompany = $this->companyId ? Company::withoutGlobalScopes()->find((int) $this->companyId) : null;
return [
'companies' => $companies,
'categories' => $this->categoryOptions(),
'portalOptions' => array_filter(Portal::cases(), fn(Portal $p) => $p !== Portal::Both),
'selectedCompany' => $selectedCompany,
'selectedCompanyContacts' => $selectedCompany
? $this->companyContacts((int) $selectedCompany->id)
: Contact::query()->whereRaw('0 = 1')->get(),
'selectedCompanyContacts' => $selectedCompany ? $this->companyContacts((int) $selectedCompany->id) : Contact::query()->whereRaw('0 = 1')->get(),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
];
}
@ -340,9 +419,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
'key' => 'tags',
'status' => $tagsCount >= 1 ? 'ok' : 'warn',
'label' => __('Themen-Tags vergeben'),
'sub' => $tagsCount >= 1
? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount])
: __('empfohlen für SEO & Auffindbarkeit'),
'sub' => $tagsCount >= 1 ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) : __('empfohlen für SEO & Auffindbarkeit'),
],
];
}
@ -391,10 +468,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
return null;
}
return Contact::withoutGlobalScopes()
->where('company_id', $companyId)
->whereKey($contactId)
->first();
return Contact::withoutGlobalScopes()->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
{
$defaults = [
__('Mittelstand'),
__('Unternehmen'),
__('Eröffnung'),
__('Innovation'),
__('Nachhaltigkeit'),
];
$defaults = [__('Mittelstand'), __('Unternehmen'), __('Eröffnung'), __('Innovation'), __('Nachhaltigkeit')];
if (!$company) {
return $defaults;
}
return array_values(array_unique(array_filter([
$company->portal?->label(),
$company->country_code === 'DE' ? __('Deutschland') : null,
...$defaults,
])));
return array_values(array_unique(array_filter([$company->portal?->label(), $company->country_code === 'DE' ? __('Deutschland') : null, ...$defaults])));
}
private function categoryOptions(): Collection
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get());
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn() => Category::query()->with('translations')->where('is_active', true)->orderBy('id')->get());
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
return mb_strlen($term) >= 3 && 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 ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
@ -455,39 +513,37 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
<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') }}
</flux:button>
</div>
</header>
{{-- ============== 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 =================== --}}
<div class="space-y-6 min-w-0">
{{-- 1) FIRMA-SELEKTOR --}}
<section class="panel">
<div class="p-4 flex flex-wrap items-center gap-4">
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }} <span class="req">*</span></span>
<div class="min-w-[260px]">
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma suchen…') }}"
>
<div class="p-4 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<span class="pr-form-label shrink-0" style="margin-bottom:0;">
{{ __('Für Firma') }} <span class="req">*</span>
</span>
<flux:select wire:model.live="companyId" variant="combobox" :filter="false" clearable
placeholder="{{ __('Firma suchen…') }}" class="w-full sm:flex-1">
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
<flux:select.input wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}" />
</x-slot>
@foreach ($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
{{ $company->name }} @if ($company->portal)
({{ $company->portal->abbreviation() }})
@endif
</flux:select.option>
@endforeach
<x-slot name="empty">
@ -501,17 +557,18 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</x-slot>
</flux:select>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }}
</span>
<span class="flex-1"></span>
@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>
{{ __('Firmenprofil') }}
</flux:button>
@endif
</div>
</div>
<flux:error name="companyId" />
</section>
@ -525,7 +582,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<div class="flex items-center gap-3">
@php
$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));
@endphp
<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>
</div>
</div>
<flux:input
wire:model.live.debounce.300ms="title"
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}"
size="lg"
/>
<flux:input wire:model.live.debounce.300ms="title"
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}" size="lg" />
<p class="pr-form-help">
{{ __('4090 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
</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">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('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') }}
</span>
</span>
@ -566,10 +622,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ $subLen }} / 200
</span>
</div>
<flux:input
wire:model.live.debounce.300ms="subtitle"
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}"
/>
<flux:input wire:model.live.debounce.300ms="subtitle"
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}" />
<flux:error name="subtitle" />
</div>
</section>
@ -583,7 +637,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</span>
<div class="flex items-center gap-3">
@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' : '');
$textBar = min(100, max(0, ($textLen / 3500) * 100));
@endphp
@ -594,11 +650,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
</div>
</div>
<flux:editor
wire:model.live.debounce.500ms="text"
<flux:editor wire:model.live.debounce.500ms="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
placeholder="{{ __('Hier weiterschreiben…') }}"
/>
placeholder="{{ __('Hier weiterschreiben…') }}" />
<flux:error name="text" />
<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">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Ü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') }}
</span>
</span>
<flux:checkbox
wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')"
/>
<flux:checkbox wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')" />
</div>
@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>
@if ($selectedCompany->website)
<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 }}
</p>
@endif
@ -647,11 +701,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@if ($useBoilerplateOverride)
<div class="mt-3">
<flux:textarea
wire:model.live.debounce.500ms="boilerplateOverride"
rows="5"
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}"
/>
<flux:textarea wire:model.live.debounce.500ms="boilerplateOverride" rows="5"
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}" />
<flux:error name="boilerplateOverride" />
</div>
@endif
@ -666,7 +717,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{-- /Schreibfläche --}}
{{-- =================== 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 --}}
<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>
</div>
<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
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
$totalCount = count($this->presubmitChecks);
@ -710,14 +762,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@endforeach
</div>
<flux:button
type="button"
variant="primary"
icon="paper-airplane"
class="w-full"
wire:click="save('review')"
wire:loading.attr="disabled"
>
<flux:button type="button" variant="primary" icon="paper-airplane" class="w-full"
wire:click="save('review')" wire:loading.attr="disabled">
{{ __('Zur Prüfung einreichen') }}
</flux:button>
<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>
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
<flux:button
type="button"
variant="ghost"
icon="bookmark"
class="w-full"
wire:click="save('draft')"
wire:loading.attr="disabled"
>
<flux:button type="button" variant="filled" icon="bookmark" class="w-full"
wire:click="save('draft')" wire:loading.attr="disabled">
{{ __('Als Entwurf speichern') }}
</flux:button>
</div>
@ -751,11 +791,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@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
</flux:select>
<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>
</div>
</article>
@ -785,14 +827,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
<div class="p-5">
<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">
@foreach ($portalOptions as $p)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endforeach
</flux:select>
<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>
</div>
</article>
@ -812,7 +857,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@endif
</p>
@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>
{{ __('Kontakt im Firmenprofil anlegen') }}
</flux:button>
@ -824,8 +869,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($selectedCompanyContacts as $contact)
@php
$contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''))
?: __('Kontakt #:n', ['n' => $contact->id]);
$contactName =
trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?:
__('Kontakt #:n', ['n' => $contact->id]);
$contactRole = $contact->responsibility ?: __('Kontakt');
@endphp
<option value="{{ $contact->id }}">
@ -838,14 +884,16 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
@if (!$contactId)
<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>
</div>
@else
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
@if ($activeContact && empty($activeContact->phone))
<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>
</div>
@endif
@ -859,15 +907,19 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<div class="panel-head">
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
<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>
</div>
<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)
<span class="pr-tag-chip" wire:key="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>
@empty
@if (count($this->tags) === 0)
@ -876,15 +928,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</span>
@endif
@endforelse
<input
type="text"
x-model="tagInput"
<input type="text" x-model="tagInput"
@keydown.enter.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"
placeholder="{{ count($this->tags) === 0 ? '' : '+ Tag' }}"
@disabled(count($this->tags) >= 5)
/>
@disabled(count($this->tags) >= 5) />
</div>
@if (!empty($tagSuggestions))
@ -893,11 +942,9 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<div class="flex flex-wrap gap-1.5">
@foreach ($tagSuggestions as $suggestion)
@if (!in_array($suggestion, $this->tags, true))
<button
type="button"
class="pr-tag-suggest"
wire:click="addTag(@js($suggestion))"
>+ {{ $suggestion }}</button>
<button type="button" class="pr-tag-suggest"
wire:click="addTag(@js($suggestion))">+
{{ $suggestion }}</button>
@endif
@endforeach
</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" />
<span class="dot-out"></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') }}
</span>
<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>
@if ($publishMode === 'scheduled')
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
<flux:input
wire:model.live="scheduledAt"
type="datetime-local"
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
<flux:label>{{ __('Datum') }}</flux:label>
<flux:date-picker
wire:model.live="scheduledDate"
type="input"
:placeholder="__('Datum wählen')"
with-today
/>
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
<flux:error name="scheduledAt" />
<flux:error name="scheduledDate" />
</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')"
<flux:field>
<flux:label>{{ __('Uhrzeit') }}</flux:label>
<flux:time-picker
wire:model.live="scheduledTime"
type="input"
:placeholder="__('Uhrzeit wählen')"
/>
<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="scheduledTime" />
</flux:field>
@endif
</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>
</article>
@ -1005,10 +1045,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<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);">
<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>
</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>· {{ __('Versionshistorie & Kommentare') }}</li>
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>

View file

@ -2,6 +2,8 @@
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Jobs\ClassifyPressRelease;
use App\Jobs\ScorePressRelease;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
@ -21,8 +23,7 @@ use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
{
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component {
#[Locked]
public int $id;
@ -58,6 +59,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
public ?string $scheduledAt = null;
public ?string $scheduledDate = null;
public ?string $scheduledTime = null;
public bool $useEmbargo = false;
public ?string $embargoAt = null;
@ -66,6 +71,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
public string $targetStatus = '';
/**
* Treiber für den manuellen KI-Re-Check: 'default' nutzt den konfigurierten
* Anbieter, sonst expliziter Override (z. B. 'openai'|'deterministic').
*/
public string $kiProvider = 'default';
public bool $kiRunClassification = true;
public bool $kiRunContentScore = false;
public function mount(int $id): void
{
$this->id = $id;
@ -87,17 +102,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->currentStatus = $pr->status->value;
$this->targetStatus = $this->currentStatus;
$this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id
?? $this->defaultContactIdFor((int) $pr->company_id);
$this->contactId = $pr->contacts()->withoutGlobalScopes()->first()?->id ?? $this->defaultContactIdFor((int) $pr->company_id);
if ($pr->scheduled_at) {
// DB-Wert ist UTC; für die Eingabefelder nach Europe/Berlin wandeln.
$scheduledAt = $pr->scheduled_at->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE);
$this->publishMode = 'scheduled';
$this->scheduledAt = $pr->scheduled_at->format('Y-m-d\TH:i');
}
if ($pr->embargo_at) {
$this->useEmbargo = true;
$this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i');
$this->scheduledAt = $scheduledAt->format('Y-m-d\TH:i');
$this->scheduledDate = $scheduledAt->format('Y-m-d');
$this->scheduledTime = $scheduledAt->format('H:i');
}
}
@ -106,6 +119,30 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$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
{
if (!$this->companyId) {
@ -149,10 +186,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$existing = array_values(array_filter($this->tagsArray(), fn(string $existingTag): bool => $existingTag !== $tag));
$this->keywords = implode(', ', $existing);
@ -180,20 +214,98 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
];
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 {
$rules['scheduledDate'] = ['nullable'];
$rules['scheduledTime'] = ['nullable'];
$rules['scheduledAt'] = ['nullable'];
}
if ($this->useEmbargo) {
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
} else {
$rules['embargoAt'] = ['nullable'];
}
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.
}
}
/**
* 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
{
if (!$this->getErrorBag()->has($property)) {
@ -209,22 +321,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
protected function notifyValidationError(?\Illuminate\Validation\ValidationException $exception = null): void
{
$count = $exception
? array_sum(array_map('count', $exception->errors()))
: count($this->getErrorBag()->all());
$count = $exception ? array_sum(array_map('count', $exception->errors())) : count($this->getErrorBag()->all());
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,
);
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);
}
public function save(): void
{
$this->syncScheduledAt();
$this->useEmbargo = false;
$this->embargoAt = null;
try {
$this->validate($this->formRules());
} catch (\Illuminate\Validation\ValidationException $e) {
@ -255,20 +362,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
'subtitle' => trim($this->subtitle) ?: null,
'slug' => $slug,
'text' => $cleanText,
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
? trim($this->boilerplateOverride)
: null,
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== '' ? trim($this->boilerplateOverride) : null,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
? \Carbon\Carbon::parse($this->scheduledAt)
: null,
'embargo_at' => $this->useEmbargo && $this->embargoAt
? \Carbon\Carbon::parse($this->embargoAt)
: null,
'scheduled_at' => $this->publishMode === 'scheduled' ? $this->scheduledAtUtc() : null,
'embargo_at' => null,
'no_export' => $this->noExport,
]);
$contentChanged = $pr->wasChanged(['title', 'text']);
if ($this->contactId) {
$contact = $this->companyContact((int) $this->contactId, (int) $this->companyId);
if ($contact) {
@ -276,6 +379,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
}
}
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu
// prüfen (Re-Check ohne Routing) und neu bewerten.
if ($contentChanged) {
$service = app(PressReleaseService::class);
$fresh = $pr->fresh();
$service->reclassifyIfClassified($fresh);
$service->rescoreIfScored($fresh);
}
Flux::toast(text: __('Pressemitteilung gespeichert.'), variant: 'success');
}
@ -287,12 +399,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
app(PressReleaseService::class)->submitForReview($pr);
} catch (BlacklistViolationException $e) {
$this->currentStatus = PressReleaseStatus::Rejected->value;
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000);
return;
}
@ -301,6 +408,42 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
Flux::toast(text: __('Zur Prüfung eingereicht.'), variant: 'success');
}
/**
* Stößt eine manuelle KI-Prüfung im Hintergrund an (Re-Check).
*
* Anders als beim Einreichen wird hier NICHT geroutet (Status bleibt
* unverändert): Das Ergebnis aktualisiert nur Klassifikation + Audit, die
* Entscheidung trifft der Admin weiterhin selbst.
*/
public function runKiCheck(): void
{
if (! $this->kiRunClassification && ! $this->kiRunContentScore) {
Flux::toast(text: __('Es wurde keine Prüfung ausgewählt.'), variant: 'warning');
return;
}
$provider = $this->kiProvider === 'default' ? null : $this->kiProvider;
if ($this->kiRunClassification) {
ClassifyPressRelease::dispatch($this->id, route: false, providerOverride: $provider)
->onQueue('classification');
}
if ($this->kiRunContentScore) {
ScorePressRelease::dispatch($this->id, providerOverride: $provider)
->onQueue('classification');
}
Flux::modal('admin-ki-check')->close();
Flux::toast(
heading: __('KI-Prüfung gestartet'),
text: __('Die Prüfung läuft im Hintergrund. Das Ergebnis erscheint nach Abschluss in der Detailansicht.'),
variant: 'success',
duration: 7000,
);
}
public function publish(): void
{
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
@ -309,12 +452,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
app(PressReleaseService::class)->publish($pr);
} catch (BlacklistViolationException $e) {
$this->currentStatus = PressReleaseStatus::Rejected->value;
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
variant: 'danger',
duration: 8000,
);
Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000);
return;
}
@ -379,19 +517,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
app(PressReleaseService::class)->deleteFromAdmin($pr);
Flux::toast(
text: $wasPublished
? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.')
: __('Pressemitteilung wurde gelöscht.'),
variant: 'success',
);
Flux::toast(text: $wasPublished ? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.') : __('Pressemitteilung wurde gelöscht.'), variant: 'success');
$this->redirect(route('admin.press-releases.index'), navigate: true);
}
public function with(): array
{
$term = trim($this->companySearch);
$term = Portal::stripTrailingAbbreviation($this->companySearch);
$companies = Company::withoutGlobalScopes()
->when(filled($term), function ($q) use ($term): void {
@ -411,9 +544,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$statusEnum = PressReleaseStatus::tryFrom($this->currentStatus);
$selectedCompany = $this->companyId
? Company::withoutGlobalScopes()->find((int) $this->companyId)
: null;
$selectedCompany = $this->companyId ? Company::withoutGlobalScopes()->find((int) $this->companyId) : null;
return [
'companies' => $companies,
@ -423,9 +554,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
'statusEnum' => $statusEnum,
'targetStatusEnum' => PressReleaseStatus::tryFrom($this->targetStatus),
'selectedCompany' => $selectedCompany,
'selectedCompanyContacts' => $selectedCompany
? $this->companyContacts((int) $selectedCompany->id)
: Contact::query()->whereRaw('0 = 1')->get(),
'selectedCompanyContacts' => $selectedCompany ? $this->companyContacts((int) $selectedCompany->id) : Contact::query()->whereRaw('0 = 1')->get(),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'statusColor' => match ($this->currentStatus) {
'published' => 'green',
@ -485,9 +614,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
'key' => 'tags',
'status' => $tagsCount >= 1 ? 'ok' : 'warn',
'label' => __('Themen-Tags vergeben'),
'sub' => $tagsCount >= 1
? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount])
: __('empfohlen für SEO & Auffindbarkeit'),
'sub' => $tagsCount >= 1 ? trans_choice('{1}:n Tag|[2,*]:n Tags', $tagsCount, ['n' => $tagsCount]) : __('empfohlen für SEO & Auffindbarkeit'),
],
];
}
@ -536,10 +663,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
return null;
}
return Contact::withoutGlobalScopes()
->where('company_id', $companyId)
->whereKey($contactId)
->first();
return Contact::withoutGlobalScopes()->where('company_id', $companyId)->whereKey($contactId)->first();
}
/**
@ -547,43 +671,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
*/
private function tagSuggestionsFor(?Company $company): array
{
$defaults = [
__('Mittelstand'),
__('Unternehmen'),
__('Eröffnung'),
__('Innovation'),
__('Nachhaltigkeit'),
];
$defaults = [__('Mittelstand'), __('Unternehmen'), __('Eröffnung'), __('Innovation'), __('Nachhaltigkeit')];
if (!$company) {
return $defaults;
}
return array_values(array_unique(array_filter([
$company->portal?->label(),
$company->country_code === 'DE' ? __('Deutschland') : null,
...$defaults,
])));
return array_values(array_unique(array_filter([$company->portal?->label(), $company->country_code === 'DE' ? __('Deutschland') : null, ...$defaults])));
}
private function categoryOptions(): Collection
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get());
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn() => Category::query()->with('translations')->where('is_active', true)->orderBy('id')->get());
}
private function supportsFullTextSearch(string $term): bool
{
return mb_strlen($term) >= 3
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
return mb_strlen($term) >= 3 && 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: '' }">
@php
$statusClass = match ($currentStatus) {
'published' => 'ok',
@ -613,42 +721,92 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="eye" href="{{ route('admin.press-releases.show', $id) }}" wire:navigate>
<flux:modal.trigger name="admin-ki-check">
<flux:button variant="filled" icon="sparkles">
{{ __('Prüfung') }}
</flux:button>
</flux:modal.trigger>
<flux:button variant="filled" icon="eye" href="{{ route('admin.press-releases.show', $id) }}"
wire:navigate>
{{ __('Vorschau / Detail') }}
</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 Liste') }}
</flux:button>
</div>
</header>
{{-- ============== KI-PRÜFUNG (On-Demand) ============== --}}
<flux:modal name="admin-ki-check" class="w-full max-w-lg">
<div class="space-y-5">
<div>
<flux:text class="eyebrow muted">{{ __('KI-Prüfung') }}</flux:text>
<flux:heading size="lg">{{ __('Prüfung im Hintergrund starten') }}</flux:heading>
<flux:text class="mt-1">
{{ __('Startet eine erneute KI-Prüfung. Das Ergebnis aktualisiert nur die Bewertung und das Audit-Log der Status bleibt unverändert.') }}
</flux:text>
</div>
<div class="space-y-3">
<label class="flex items-start gap-2 text-[13px] text-[color:var(--color-ink-2)]">
<flux:checkbox wire:model="kiRunClassification" />
<span>
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Klassifikation (Red Flag)') }}</span><br>
{{ __('Prüft auf unzulässige Inhalte (grün/gelb/rot).') }}
</span>
</label>
<label class="flex items-start gap-2 text-[13px] text-[color:var(--color-ink-2)]">
<flux:checkbox wire:model="kiRunContentScore" />
<span>
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Content-Score') }}</span><br>
{{ __('Bewertet die handwerkliche Qualität (0100) und leitet die Stufe ab.') }}
</span>
</label>
<flux:select wire:model="kiProvider" :label="__('Anbieter')">
<option value="default">{{ __('Konfigurierter Anbieter') }}</option>
<option value="openai">OpenAI</option>
<option value="deterministic">{{ __('Deterministisch (Blacklist)') }}</option>
</flux:select>
</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" icon="sparkles" wire:click="runKiCheck">
{{ __('Prüfung starten') }}
</flux:button>
</div>
</div>
</flux:modal>
{{-- ============== 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 =================== --}}
<div class="space-y-6 min-w-0">
{{-- 1) FIRMA-SELEKTOR --}}
<section class="panel">
<div class="p-4 flex flex-wrap items-center gap-4">
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }} <span class="req">*</span></span>
<div class="min-w-[260px]">
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma suchen…') }}"
>
<div class="p-4 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<span class="pr-form-label shrink-0" style="margin-bottom:0;">
{{ __('Für Firma') }} <span class="req">*</span>
</span>
<flux:select wire:model.live="companyId" variant="combobox" :filter="false" clearable
placeholder="{{ __('Firma suchen…') }}" class="w-full sm:flex-1">
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
<flux:select.input wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}" />
</x-slot>
@foreach ($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
{{ $company->name }} @if ($company->portal)
({{ $company->portal->abbreviation() }})
@endif
</flux:select.option>
@endforeach
<x-slot name="empty">
@ -662,20 +820,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</x-slot>
</flux:select>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }}
</span>
<span class="flex-1"></span>
@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>
{{ __('Firmenprofil') }}
</flux:button>
@endif
</div>
</div>
<flux:error name="companyId" />
</section>
{{-- 2) TITEL --}}
<section class="panel">
<div class="p-5 pb-4">
@ -686,7 +846,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<div class="flex items-center gap-3">
@php
$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));
@endphp
<span class="pr-meter {{ $titleClass }}">
@ -696,11 +857,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<span class="pr-bald-badge">{{ __('KI-Titel · bald') }}</span>
</div>
</div>
<flux:input
wire:model.live.debounce.300ms="title"
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}"
size="lg"
/>
<flux:input wire:model.live.debounce.300ms="title"
placeholder="{{ __('Aussagekräftiger Titel — wer, was, wo?') }}" size="lg" />
<p class="pr-form-help">
{{ __('4090 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
</p>
@ -714,7 +872,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('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') }}
</span>
</span>
@ -727,10 +886,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
{{ $subLen }} / 200
</span>
</div>
<flux:input
wire:model.live.debounce.300ms="subtitle"
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}"
/>
<flux:input wire:model.live.debounce.300ms="subtitle"
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}" />
<flux:error name="subtitle" />
</div>
</section>
@ -744,7 +901,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</span>
<div class="flex items-center gap-3">
@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' : '');
$textBar = min(100, max(0, ($textLen / 3500) * 100));
@endphp
@ -755,11 +914,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
</div>
</div>
<flux:editor
wire:model.live.debounce.500ms="text"
<flux:editor wire:model.live.debounce.500ms="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
placeholder="{{ __('Hier weiterschreiben…') }}"
/>
placeholder="{{ __('Hier weiterschreiben…') }}" />
<flux:error name="text" />
<div class="pr-ai-hint mt-4">
@ -790,14 +947,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<div class="flex items-center justify-between mb-3 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Ü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') }}
</span>
</span>
<flux:checkbox
wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')"
/>
<flux:checkbox wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')" />
</div>
@if ($selectedCompany?->boilerplate)
@ -805,7 +961,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<p class="m-0">{!! nl2br(e($selectedCompany->boilerplate)) !!}</p>
@if ($selectedCompany->website)
<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 }}
</p>
@endif
@ -818,11 +975,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
@if ($useBoilerplateOverride)
<div class="mt-3">
<flux:textarea
wire:model.live.debounce.500ms="boilerplateOverride"
rows="5"
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}"
/>
<flux:textarea wire:model.live.debounce.500ms="boilerplateOverride" rows="5"
placeholder="{{ __('Boilerplate-Text speziell für diese PM…') }}" />
<flux:error name="boilerplateOverride" />
</div>
@endif
@ -837,7 +991,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
{{-- /Schreibfläche --}}
{{-- =================== 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">
{{-- Status-Aktionen + Pre-Submit-Check --}}
<article class="panel" style="border-color:var(--color-hub);">
@ -846,7 +1000,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<span @class(['badge', $statusClass])>{{ $statusEnum?->label() ?? $currentStatus }}</span>
</div>
<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
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
$totalCount = count($this->presubmitChecks);
@ -914,11 +1069,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@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
</flux:select>
<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>
</div>
</article>
@ -948,14 +1105,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</div>
<div class="p-5">
<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">
@foreach ($portalOptions as $p)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endforeach
</flux:select>
<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>
</div>
</article>
@ -971,7 +1131,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
{{ __('Diese Firma hat noch keine Pressekontakte.') }}
</p>
@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>
{{ __('Kontakt im Firmenprofil anlegen') }}
</flux:button>
@ -983,8 +1143,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($selectedCompanyContacts as $contact)
@php
$contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''))
?: __('Kontakt #:n', ['n' => $contact->id]);
$contactName =
trim(($contact->first_name ?? '') . ' ' . ($contact->last_name ?? '')) ?:
__('Kontakt #:n', ['n' => $contact->id]);
$contactRole = $contact->responsibility ?: __('Kontakt');
@endphp
<option value="{{ $contact->id }}">
@ -997,14 +1158,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
@if (!$contactId)
<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>
</div>
@else
@php($activeContact = $selectedCompanyContacts->firstWhere('id', (int) $contactId))
@if ($activeContact && empty($activeContact->phone))
<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>
</div>
@endif
@ -1018,15 +1181,19 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<div class="panel-head">
<span class="section-eyebrow">{{ __('Themen-Tags') }}</span>
<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>
</div>
<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)
<span class="pr-tag-chip" wire:key="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>
@empty
@if (count($this->tags) === 0)
@ -1035,15 +1202,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</span>
@endif
@endforelse
<input
type="text"
x-model="tagInput"
<input type="text" x-model="tagInput"
@keydown.enter.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"
placeholder="{{ count($this->tags) === 0 ? '' : '+ Tag' }}"
@disabled(count($this->tags) >= 5)
/>
@disabled(count($this->tags) >= 5) />
</div>
@if (!empty($tagSuggestions))
@ -1052,11 +1216,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<div class="flex flex-wrap gap-1.5">
@foreach ($tagSuggestions as $suggestion)
@if (!in_array($suggestion, $this->tags, true))
<button
type="button"
class="pr-tag-suggest"
wire:click="addTag(@js($suggestion))"
>+ {{ $suggestion }}</button>
<button type="button" class="pr-tag-suggest"
wire:click="addTag(@js($suggestion))">+
{{ $suggestion }}</button>
@endif
@endforeach
</div>
@ -1078,7 +1240,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<input type="radio" wire:model.live="publishMode" value="now" class="sr-only" />
<span class="dot-out"></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') }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
@ -1100,39 +1263,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</label>
@if ($publishMode === 'scheduled')
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
<flux:input
wire:model.live="scheduledAt"
type="datetime-local"
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
<flux:label>{{ __('Datum') }}</flux:label>
<flux:date-picker
wire:model.live="scheduledDate"
type="input"
:placeholder="__('Datum wählen')"
with-today
/>
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
<flux:error name="scheduledAt" />
<flux:error name="scheduledDate" />
</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')"
<flux:field>
<flux:label>{{ __('Uhrzeit') }}</flux:label>
<flux:time-picker
wire:model.live="scheduledTime"
type="input"
:placeholder="__('Uhrzeit wählen')"
/>
<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="scheduledTime" />
</flux:field>
@endif
</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>
</article>
@ -1181,10 +1336,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<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);">
<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>
</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>· {{ __('Versionshistorie & Kommentare') }}</li>
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
@ -1230,7 +1387,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="deletePressRelease">{{ __('Löschung bestätigen') }}</flux:button>
<flux:button variant="danger" wire:click="deletePressRelease">{{ __('Löschung bestätigen') }}
</flux:button>
</div>
</div>
</flux:modal>

View file

@ -27,6 +27,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
#[Url(as: 'status', except: 'all')]
public string $statusFilter = 'all';
#[Url(as: 'classification', except: 'all')]
public string $classificationFilter = 'all';
public string $portalFilter = 'all';
public string $languageFilter = 'all';
@ -74,6 +77,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
$this->resetPage();
}
public function updatedClassificationFilter(): void
{
$this->resetPage();
}
public function updatedPortalFilter(): void
{
$this->resetPage();
@ -142,6 +150,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
{
$this->search = '';
$this->statusFilter = 'all';
$this->classificationFilter = 'all';
$this->portalFilter = 'all';
$this->languageFilter = '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->classificationFilter !== 'all', fn ($q) => $q->where('classification', $this->classificationFilter))
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
->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
</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">
<option value="all">{{ __('Alle Portale') }}</option>
@foreach ($portalOptions as $p)
@ -532,7 +549,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearUserFilter"
title="{{ __('Usersuche zurücksetzen') }}"
@ -572,7 +589,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearCompanyFilter"
title="{{ __('Firmensuche zurücksetzen') }}"
@ -618,7 +635,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearContactFilter"
title="{{ __('Kontaktsuche zurücksetzen') }}"
@ -630,6 +647,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@php
$hasAnyFilter = $search !== ''
|| $statusFilter !== 'all'
|| $classificationFilter !== 'all'
|| $portalFilter !== 'all'
|| $languageFilter !== 'all'
|| $categoryFilter !== 'all'
@ -670,6 +688,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</span>
@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')
@php $portalEnum = \App\Enums\Portal::tryFrom($portalFilter); @endphp
<span class="active-chip">
@ -834,6 +867,26 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<flux:table.cell>
<div class="flex items-center gap-1.5 flex-wrap">
<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)
<flux:modal.trigger name="confirm-index-publish-{{ $pr->id }}">
<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())
<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" />
<span>{{ __('geplant') }} · {{ $pr->scheduled_at->format('d.m. H:i') }}</span>
<span>{{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }}</span>
</div>
@endif
@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)]">
<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>
@endif
</flux:table.cell>
@ -921,10 +974,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<flux:table.cell>
<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
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
title="{{ __('Bearbeiten') }}" />
</div>

View file

@ -2,6 +2,7 @@
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Livewire\Attributes\Layout;
@ -81,20 +82,29 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
->orderBy('first_name')
->select(['contacts.id', 'contacts.company_id', 'contacts.first_name', 'contacts.last_name', 'contacts.responsibility', 'contacts.email', 'contacts.phone']),
'statusLogs.changedBy:id,name',
'kiAudits',
])
->findOrFail($this->id);
$latestClassification = $pr->kiAudits
->firstWhere('type', \App\Models\KiAudit::TYPE_CLASSIFICATION);
$latestRejection = null;
if ($pr->status->value === 'rejected') {
$latestRejection = $pr->statusLogs
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
$cover = app(PressReleaseCoverImage::class);
return [
'pr' => $pr,
'statusLogs' => $pr->statusLogs,
'contacts' => $pr->contacts,
'latestClassification' => $latestClassification,
'latestRejection' => $latestRejection,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->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="eyebrow muted">{{ __('Content · Pressemitteilung') }}</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">{{ $pr->portal->label() }}</span>
</div>
@ -152,15 +182,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
<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') }}
</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') }}
</flux:button>
</div>
</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 ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
<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 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>
@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)
<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" />
{{ __('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>
@endif
</div>
@ -243,7 +295,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<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" />
{{ __('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>
@endif
@if ($pr->hits > 0)
@ -254,7 +306,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif
</div>
<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>
</div>
</article>
@ -266,7 +318,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
@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') }}
</flux:button>
@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="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">
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
{{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@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="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">
{{ $pr->embargo_at->format('d.m.Y H:i') }}
{{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
</div>
</div>
@endif

View file

@ -76,7 +76,7 @@ new #[Layout('components.layouts.app'), Title('Performance Reports')] class exte
<article class="panel">
<div class="panel-head">
<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') }}
</flux:button>
</div>

View file

@ -90,7 +90,7 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
</div>
<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') }}
</flux:button>
</div>
@ -143,7 +143,7 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
<article class="panel">
<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') }}
</flux:button>
<flux:button type="submit" variant="primary">

View file

@ -116,7 +116,7 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
</div>
<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') }}
</flux:button>
</div>
@ -178,7 +178,7 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
<article class="panel">
<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') }}
</flux:button>
<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>
@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
</flux:table.cell>
</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')
<div class="flex items-center gap-2">
<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') }}
</flux:button>
</div>
@ -464,14 +464,14 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
<flux:table.cell>
<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 }})" />
<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 />
@if($canLoginAsUser)
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="user"
square
type="button"
@ -643,7 +643,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
{{ __('Bearbeiten') }}
</flux:button>
<flux:modal.close>
<flux:button variant="ghost">{{ __('Schließen') }}</flux:button>
<flux:button variant="filled">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
</div>
</div>
@ -663,7 +663,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
@if($selectedUser->published_press_releases_count > 0)
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="arrow-top-right-on-square"
class="mt-2"
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>
@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') }}
</flux:button>
@endif

View file

@ -246,7 +246,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
</div>
<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') }}
</flux:button>
</div>
@ -365,7 +365,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
<option value="owner">{{ __('Owner') }}</option>
</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 }})">
{{ __('Entfernen') }}
</flux:button>
@ -439,7 +439,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
<article class="panel">
<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') }}
</flux:button>
<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 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') }}
</flux:button>
</div>
@ -1035,7 +1035,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearCompanyLookup"
title="{{ __('Firmensuche zurücksetzen') }}"
@ -1056,7 +1056,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
<option value="owner">{{ __('Owner') }}</option>
</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') }}
</flux:button>
</div>
@ -1113,7 +1113,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
<flux:button
type="button"
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
wire:click="clearContactLookup"
title="{{ __('Kontaktsuche zurücksetzen') }}"
@ -1128,7 +1128,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
</flux:text>
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="x-mark"
type="button"
wire:click="removeLinkedContact({{ $contactForm['id'] }})"
@ -1230,7 +1230,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
<article class="panel">
<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') }}
</flux:button>
<flux:button type="submit" variant="primary" icon="check">

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