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

@ -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,9 +64,33 @@ 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) {
if (!$this->companyId) {
$this->contactId = null;
return;
@ -71,7 +98,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
$contactStillValid = $this->companyContact((int) $this->contactId, (int) $this->companyId);
if (! $contactStillValid) {
if (!$contactStillValid) {
$this->contactId = $this->defaultContactIdFor((int) $this->companyId);
}
@ -109,10 +136,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public function removeTag(string $tag): void
{
$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);
@ -128,7 +152,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
protected function formRules(): array
{
$rules = [
'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))],
'portal' => ['required', Rule::in(array_map(fn(Portal $p) => $p->value, Portal::cases()))],
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
@ -143,26 +167,103 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
];
if ($this->publishMode === 'scheduled') {
$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'];
}
$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
{
if (! $this->getErrorBag()->has($property)) {
if (!$this->getErrorBag()->has($property)) {
return;
}
@ -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,27 +351,22 @@ 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'))
->when(blank($term) && $this->companyId, fn($q) => $q->whereIn('id', [(int) $this->companyId]))
->when(blank($term) && !$this->companyId, fn($q) => $q->whereRaw('0 = 1'))
->orderBy('name')
->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),
'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'),
],
];
}
@ -357,7 +434,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
}
return collect(explode(',', $this->keywords))
->map(fn (string $tag): string => trim($tag))
->map(fn(string $tag): string => trim($tag))
->filter()
->unique()
->values()
@ -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) {
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,16 +557,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</x-slot>
</flux:select>
</div>
<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"
href="{{ route('admin.companies.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Firmenprofil') }}
</flux:button>
@endif
<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>
@if ($selectedCompany)
<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);
@ -702,7 +754,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</span>
<span class="lbl">
{{ $check['label'] }}
@if (! empty($check['sub']))
@if (!empty($check['sub']))
<span class="sub">{{ $check['sub'] }}</span>
@endif
</span>
@ -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 }}">
@ -836,16 +882,18 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:error name="contactId" />
</flux:field>
@if (! $contactId)
@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,28 +928,23 @@ 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))
@if (!empty($tagSuggestions))
<div>
<div class="eyebrow muted mb-1.5" style="font-size:9.5px;">{{ __('Vorschläge') }}</div>
<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>
@if (!in_array($suggestion, $this->tags, true))
<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')
<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:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
<flux:error name="scheduledAt" />
</flux:field>
@endif
<div class="border-t pt-3" style="border-color: var(--color-line);">
<flux:switch
wire:model.live="useEmbargo"
:label="__('Sperrfrist (Embargo) setzen')"
/>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
{{ __('PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
</p>
@if ($useEmbargo)
<flux:field class="mt-3">
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
<flux:input
wire:model.live="embargoAt"
type="datetime-local"
:min="now()->format('Y-m-d\\TH:i')"
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Datum') }}</flux:label>
<flux:date-picker
wire:model.live="scheduledDate"
type="input"
:placeholder="__('Datum wählen')"
with-today
/>
<flux:error name="embargoAt" />
<flux:error name="scheduledDate" />
</flux:field>
@endif
</div>
<flux:field>
<flux:label>{{ __('Uhrzeit') }}</flux:label>
<flux:time-picker
wire:model.live="scheduledTime"
type="input"
:placeholder="__('Uhrzeit wählen')"
/>
<flux:error name="scheduledTime" />
</flux:field>
</div>
<flux:error name="scheduledAt" />
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
@endif
</div>
</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>

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
#[Url(as: 'status', except: 'all')]
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">

View file

@ -92,7 +92,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
</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>
<flux:button variant="primary" icon="pencil" href="{{ route('admin.users.edit', $user->id) }}" wire:navigate>
@ -221,7 +221,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
<div class="flex shrink-0 items-center gap-2">
<span class="badge hub">{{ $contact->portal?->label() ?? '—' }}</span>
@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>
{{ __('Bearbeiten') }}

View file

@ -118,7 +118,7 @@ new #[Layout('components.layouts.app'), Title('Benutzertabelle')] class extends
<flux:table.cell>
<flux:dropdown>
<flux:button variant="ghost" size="sm" icon="ellipsis-horizontal" inset="top bottom"></flux:button>
<flux:button variant="filled" size="sm" icon="ellipsis-horizontal" inset="top bottom"></flux:button>
<flux:menu>
<flux:menu.item icon="plus">New post</flux:menu.item>
<flux:menu.separator />

View file

@ -80,6 +80,12 @@ new class extends Component
Flux::toast(text: __('Anhang hochgeladen.'), variant: 'success');
}
public function removeNewFile(): void
{
$this->reset('newFile');
$this->resetErrorBag('newFile');
}
public function startEdit(int $attachmentId): void
{
$attachment = $this->getAttachment($attachmentId);
@ -252,16 +258,31 @@ new class extends Component
@if ($canEdit)
<form wire:submit="upload"
class="rounded-[5px] border border-dashed border-[color:var(--color-hub-soft-2)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
<div class="grid gap-3 sm:grid-cols-[1fr_auto] items-start">
<flux:field>
<flux:label>{{ __('Datei') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input
type="file"
wire:model="newFile"
accept=".{{ implode(',.', $allowedExtensions) }}"
/>
<flux:error name="newFile" />
</flux:field>
<flux:file-upload
wire:model="newFile"
accept=".{{ implode(',.', $allowedExtensions) }}"
>
<flux:file-upload.dropzone
:heading="__('Datei hierher ziehen oder klicken')"
:text="strtoupper(implode(' · ', $allowedExtensions)).' · max. '.$maxMb.' MB'"
with-progress
inline
/>
</flux:file-upload>
<flux:error name="newFile" />
@if ($newFile)
<flux:file-item
:heading="$newFile->getClientOriginalName()"
:size="$newFile->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="removeNewFile" :aria-label="__('Datei entfernen')" />
</x-slot>
</flux:file-item>
@endif
<div class="flex justify-end">
<flux:button type="submit" variant="primary" icon="arrow-up-tray" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="upload,newFile">{{ __('Hochladen') }}</span>
<span wire:loading wire:target="upload,newFile">{{ __('Lädt…') }}</span>
@ -308,7 +329,7 @@ new class extends Component
<flux:error name="editDescription" />
</flux:field>
<div class="flex justify-end gap-1 pt-1">
<flux:button size="xs" variant="ghost" wire:click="cancelEdit">{{ __('Abbrechen') }}</flux:button>
<flux:button size="xs" variant="filled" wire:click="cancelEdit">{{ __('Abbrechen') }}</flux:button>
<flux:button size="xs" variant="primary" icon="check" wire:click="updateAttachment">{{ __('Speichern') }}</flux:button>
</div>
</div>
@ -359,17 +380,17 @@ new class extends Component
<div class="flex flex-wrap items-center gap-1 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)]">
@if ($attachment->url())
<flux:button size="xs" variant="ghost" icon="arrow-down-tray"
<flux:button size="xs" variant="filled" icon="arrow-down-tray"
href="{{ $attachment->url() }}" target="_blank" rel="noopener">
{{ __('Download') }}
</flux:button>
@endif
@if ($canEdit)
<flux:button size="xs" variant="ghost" icon="pencil-square" wire:click="startEdit({{ $attachment->id }})" :title="__('Bearbeiten')" />
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $attachment->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $attachment->id }})" :title="__('Runter')" />
<flux:button size="xs" variant="filled" icon="pencil-square" wire:click="startEdit({{ $attachment->id }})" :title="__('Bearbeiten')" />
<flux:button size="xs" variant="filled" icon="arrow-up" wire:click="moveUp({{ $attachment->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="filled" icon="arrow-down" wire:click="moveDown({{ $attachment->id }})" :title="__('Runter')" />
<span class="flex-1"></span>
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $attachment->id }})"
<flux:button size="xs" variant="filled" icon="trash" wire:click="remove({{ $attachment->id }})"
wire:confirm="{{ __('Anhang wirklich entfernen?') }}" :title="__('Entfernen')" />
@endif
</div>

View file

@ -1,11 +1,12 @@
<?php
use App\Enums\ImageLicenseType;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
@ -16,8 +17,7 @@ use Livewire\WithFileUploads;
* `update` ability on `PressReleasePolicy`, so the same component is safe
* to use for admins (who can always edit) and customers (only their own).
*/
new class extends Component
{
new class extends Component {
use WithFileUploads;
#[Locked]
@ -29,35 +29,94 @@ new class extends Component
public string $newCopyright = '';
public bool $newIsPreview = false;
public string $newAuthor = '';
public string $newLicenseType = '';
public string $newLicenseDetail = '';
public string $newLicenseUrl = '';
public string $newSourceUrl = '';
public string $newPeopleRightsStatus = '';
public string $newPropertyRightsStatus = '';
public string $newRightsNotes = '';
public bool $newRightsConfirmed = false;
public bool $isUploadFormOpen = false;
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function upload(ImageService $imageService): void
public function openUploadForm(): void
{
$this->isUploadFormOpen = true;
}
public function closeUploadForm(): void
{
$this->resetUploadForm();
$this->resetErrorBag();
$this->isUploadFormOpen = false;
}
public function saveImage(ImageService $imageService): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
$this->addError('newImage', __('Bilder können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
if (!$this->canChangeImages($pressRelease)) {
$this->addError('newImage', __('Das Titelbild kann nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
return;
}
$this->validate([
'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:'.(int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)],
'newTitle' => ['nullable', 'string', 'max:120'],
'newCopyright' => ['nullable', 'string', 'max:255'],
]);
if ($this->titleImageFor($pressRelease) !== null) {
$this->addError('newImage', __('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
return;
}
$licenseType = ImageLicenseType::tryFrom($this->newLicenseType);
$requiresLicenseUrl = $licenseType?->requiresLicenseUrl() ?? false;
$requiresLicenseDetail = $licenseType?->requiresLicenseDetail() ?? false;
$this->validate(
[
'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:' . (int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)],
'newTitle' => ['nullable', 'string', 'max:120'],
'newCopyright' => ['nullable', 'string', 'max:255'],
'newAuthor' => ['required', 'string', 'max:255'],
'newLicenseType' => ['required', Rule::enum(ImageLicenseType::class)],
'newLicenseDetail' => [$requiresLicenseDetail ? 'required' : 'nullable', 'string', 'max:120'],
'newLicenseUrl' => [$requiresLicenseUrl ? 'required' : 'nullable', 'url', 'max:2048'],
'newSourceUrl' => ['nullable', 'url', 'max:2048'],
'newPeopleRightsStatus' => ['required', Rule::in(array_keys($this->peopleRightsOptions()))],
'newPropertyRightsStatus' => ['required', Rule::in(array_keys($this->propertyRightsOptions()))],
'newRightsNotes' => ['nullable', 'string', 'max:1000'],
'newRightsConfirmed' => ['accepted'],
],
[
'newAuthor.required' => __('Bitte Urheber, Fotograf oder Rechteinhaber angeben.'),
'newLicenseType.required' => __('Bitte einen Lizenztyp wählen.'),
'newLicenseDetail.required' => __('Bitte die Lizenz genauer angeben.'),
'newLicenseUrl.required' => __('Für diesen Lizenztyp ist eine Nachweis-URL erforderlich.'),
'newPeopleRightsStatus.required' => __('Bitte angeben, ob erkennbare Personen abgebildet sind.'),
'newPropertyRightsStatus.required' => __('Bitte angeben, ob Marken, Kunstwerke oder private Orte sichtbar sind.'),
'newRightsConfirmed.accepted' => __('Bitte bestätigen, dass die Bildrechte geklärt sind.'),
],
);
$stored = $imageService->storePressReleaseImage($this->newImage, $pressRelease->id);
if ($this->newIsPreview) {
$pressRelease->images()->update(['is_preview' => false]);
}
$pressRelease->images()->update(['is_preview' => false]);
$pressRelease->images()->create([
'disk' => 'public',
@ -65,43 +124,48 @@ new class extends Component
'variants' => $stored['variants'],
'title' => $this->newTitle ?: null,
'copyright' => $this->newCopyright ?: null,
'is_preview' => $this->newIsPreview,
'author' => $this->newAuthor,
'license_type' => $this->newLicenseType,
'license_detail' => $this->newLicenseDetail ?: null,
'license_url' => $this->newLicenseUrl ?: null,
'source_url' => $this->newSourceUrl ?: null,
'persons_consent' => $this->newPeopleRightsStatus === 'consent',
'people_rights_status' => $this->newPeopleRightsStatus,
'property_rights_status' => $this->newPropertyRightsStatus,
'rights_notes' => $this->newRightsNotes ?: null,
'rights_confirmed_at' => now(),
'is_preview' => true,
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
'width' => $stored['width'],
'height' => $stored['height'],
'mime' => $stored['mime'],
]);
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newIsPreview']);
$this->resetUploadForm();
$this->isUploadFormOpen = false;
Flux::toast(text: __('Bild hochgeladen.'), variant: 'success');
$this->dispatch('title-image-changed');
Flux::toast(text: __('Titelbild hochgeladen.'), variant: 'success');
}
public function setPreview(int $imageId): void
public function removeNewImage(): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
$this->reset('newImage');
$this->resetErrorBag('newImage');
}
$image = $pressRelease->images()->whereKey($imageId)->first();
if (! $image) {
return;
public function newImagePreviewUrl(): ?string
{
if (!is_object($this->newImage) || !method_exists($this->newImage, 'temporaryUrl')) {
return null;
}
$pressRelease->images()->where('id', '!=', $image->id)->update(['is_preview' => false]);
$image->update(['is_preview' => true]);
Flux::toast(text: __('Vorschaubild gesetzt.'), variant: 'success');
}
public function moveUp(int $imageId): void
{
$this->swapSortOrder($imageId, -1);
}
public function moveDown(int $imageId): void
{
$this->swapSortOrder($imageId, 1);
try {
return $this->newImage->temporaryUrl();
} catch (\Throwable) {
return null;
}
}
public function remove(int $imageId, ImageService $imageService): void
@ -109,20 +173,22 @@ new class extends Component
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
if (!$this->canChangeImages($pressRelease)) {
return;
}
$image = $pressRelease->images()->whereKey($imageId)->first();
if (! $image) {
if (!$image) {
return;
}
$imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
Flux::toast(text: __('Bild entfernt.'), variant: 'success');
$this->dispatch('title-image-changed');
Flux::toast(text: __('Titelbild entfernt.'), variant: 'success');
}
public function with(): array
@ -130,149 +196,273 @@ new class extends Component
$pressRelease = $this->getPressRelease();
return [
'images' => $pressRelease->images()
->orderBy('sort_order')
->orderBy('id')
->get(),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
&& $this->canChangeImages($pressRelease),
'titleImage' => $this->titleImageFor($pressRelease),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease),
'licenseTypeOptions' => ImageLicenseType::options(),
'ccLicenseOptions' => $this->ccLicenseOptions(),
'peopleRightsOptions' => $this->peopleRightsOptions(),
'propertyRightsOptions' => $this->propertyRightsOptions(),
'licenseUrlRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseUrl() ?? false,
'licenseDetailRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseDetail() ?? false,
'showsCcWarning' => $this->newLicenseType === ImageLicenseType::CreativeCommons->value,
'showsRightsWarning' => $this->shouldShowRightsWarning(),
];
}
private function swapSortOrder(int $imageId, int $direction): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
return;
}
$images = $pressRelease->images()->orderBy('sort_order')->orderBy('id')->get();
$currentIndex = $images->search(fn (PressReleaseImage $image) => $image->id === $imageId);
if ($currentIndex === false) {
return;
}
$targetIndex = $currentIndex + $direction;
if ($targetIndex < 0 || $targetIndex >= $images->count()) {
return;
}
$current = $images[$currentIndex];
$target = $images[$targetIndex];
$currentSort = $current->sort_order;
$current->update(['sort_order' => $target->sort_order]);
$target->update(['sort_order' => $currentSort]);
}
private function getPressRelease(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->findOrFail($this->pressReleaseId);
return PressRelease::withoutGlobalScopes()->findOrFail($this->pressReleaseId);
}
private function canChangeImages(PressRelease $pressRelease): bool
{
if (auth()->user()?->canAccessAdmin()) {
return ! in_array(
$pressRelease->status,
[PressReleaseStatus::Archived],
true,
);
return !in_array($pressRelease->status, [PressReleaseStatus::Archived], true);
}
return in_array(
$pressRelease->status,
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
true,
);
return in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true);
}
private function titleImageFor(PressRelease $pressRelease): ?PressReleaseImage
{
return $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
}
private function resetUploadForm(): void
{
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newAuthor', 'newLicenseType', 'newLicenseDetail', 'newLicenseUrl', 'newSourceUrl', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsNotes', 'newRightsConfirmed']);
}
/**
* @return array<string, string>
*/
private function ccLicenseOptions(): array
{
return [
'cc0' => 'CC0',
'cc_by' => 'CC BY',
'cc_by_sa' => 'CC BY-SA',
'cc_by_nd' => 'CC BY-ND',
'cc_by_nc' => 'CC BY-NC',
'cc_by_nc_sa' => 'CC BY-NC-SA',
'cc_by_nc_nd' => 'CC BY-NC-ND',
];
}
/**
* @return array<string, string>
*/
private function peopleRightsOptions(): array
{
return [
'none' => __('Nein, keine erkennbaren Personen'),
'consent' => __('Ja, Einwilligung liegt vor'),
'public_event' => __('Ja, öffentliche Veranstaltung / redaktioneller Kontext'),
];
}
/**
* @return array<string, string>
*/
private function propertyRightsOptions(): array
{
return [
'none' => __('Nein'),
'cleared' => __('Ja, Rechte / Nutzung sind geklärt'),
];
}
private function shouldShowRightsWarning(): bool
{
$restrictedCcLicense = str_contains($this->newLicenseDetail, '_nc') || str_contains($this->newLicenseDetail, '_nd');
return $this->newLicenseType === ImageLicenseType::Other->value || $restrictedCcLicense;
}
}; ?>
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="md">{{ __('Bilder') }}</flux:heading>
<flux:badge color="zinc" size="sm">{{ count($images) }}</flux:badge>
<flux:heading size="md">{{ __('Titelbild') }}</flux:heading>
<flux:badge color="{{ $titleImage ? 'green' : 'zinc' }}" size="sm">
{{ $titleImage ? __('gesetzt') : __('Platzhalter aktiv') }}
</flux:badge>
</div>
@if($canEdit)
<form wire:submit="upload" class="mt-4 space-y-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="xs">{{ __('Neues Bild hinzufügen') }}</flux:heading>
<flux:input
type="file"
wire:model="newImage"
accept="image/jpeg,image/png,image/webp"
:description="__('JPG/PNG/WebP, max. 8 MB. Varianten thumb/medium/large werden automatisch erzeugt.')"
/>
<flux:error name="newImage" />
@if ($titleImage)
<div class="mt-4 overflow-hidden rounded-md border border-zinc-200 dark:border-zinc-700">
<div class="relative aspect-[16/9] bg-zinc-50 dark:bg-zinc-800">
@php
$titleImageUrl =
$titleImage->variantUrl('cover') ??
($titleImage->variantUrl('large') ?? ($titleImage->variantUrl('medium') ?? $titleImage->url()));
@endphp
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newTitle" :label="__('Titel (optional)')" />
<flux:input wire:model="newCopyright" :label="__('Copyright / Quelle (optional)')" />
@if ($titleImageUrl)
<img src="{{ $titleImageUrl }}" alt="{{ $titleImage->title ?? __('Titelbild') }}"
class="absolute inset-0 size-full object-cover" loading="lazy" />
@endif
</div>
<flux:checkbox wire:model="newIsPreview" :label="__('Als Vorschaubild verwenden')" />
<div class="space-y-3 p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1">
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ $titleImage->title ?: __('Eigenes Titelbild') }}
</div>
@if ($titleImage->author)
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->author }}">©
{{ $titleImage->author }}</p>
@endif
@if ($titleImage->copyright)
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->copyright }}">
{{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }}
</p>
@endif
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
@if ($titleImage->license_type)
<flux:badge color="zinc" size="xs">{{ $titleImage->license_type->label() }}
</flux:badge>
@endif
@if ($titleImage->width && $titleImage->height)
<span>{{ $titleImage->width }}×{{ $titleImage->height }}</span>
@endif
</div>
</div>
<div class="flex justify-end">
@if ($canEdit)
<flux:button size="sm" variant="filled" icon="trash"
wire:click="remove({{ $titleImage->id }})"
wire:confirm="{{ __('Titelbild wirklich entfernen? Danach wird wieder der Platzhalter verwendet.') }}">
{{ __('Titelbild löschen') }}
</flux:button>
@endif
</div>
</div>
</div>
@elseif($canEdit)
@if (!$isUploadFormOpen)
<div
class="mt-4 flex flex-col gap-4 rounded-md border border-dashed border-zinc-300 p-5 dark:border-zinc-700 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<flux:heading size="xs">{{ __('Hier fehlt ein Titelbild') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">
{{ __('Der Platzhalter bleibt aktiv, bis ein eigenes Titelbild hochgeladen wurde.') }}
</flux:text>
</div>
<flux:button type="button" variant="primary" icon="arrow-up-tray" wire:click="openUploadForm">
{{ __('Eigenes Titelbild hochladen') }}
</flux:button>
</div>
@else
<form wire:submit="saveImage" class="mt-4 space-y-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="xs">{{ __('Titelbild hochladen') }}</flux:heading>
<flux:file-upload wire:model="newImage" accept="image/jpeg,image/png,image/webp"
:description="__('JPG/PNG/WebP, max. 16 MB. Wird als Titelbild gespeichert und ersetzt den Platzhalter.')">
<flux:file-upload.dropzone :heading="__('Bild hierher ziehen oder klicken')"
:text="__('JPG, PNG oder WebP · max. 16 MB')" with-progress />
</flux:file-upload>
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Bitte laden Sie nur Bilder hoch, für die Sie die erforderlichen Nutzungsrechte besitzen. Bilder aus Google, Social Media, Messenger-Gruppen oder fremden Websites dürfen nicht ohne ausdrückliche Erlaubnis verwendet werden.') }}
</div>
@if ($newImage)
<flux:file-item :heading="$newImage->getClientOriginalName()" :image="$this->newImagePreviewUrl()"
:size="$newImage->getSize()">
<x-slot name="actions">
<flux:file-item.remove wire:click="removeNewImage" :aria-label="__('Bild entfernen')" />
</x-slot>
</flux:file-item>
@endif
<flux:input wire:model="newTitle" :label="__('Titel / Alt-Text (optional)')" />
<flux:input wire:model="newCopyright" :label="__('Öffentlicher Bildnachweis')"
:description="__('Wird öffentlich angezeigt, z. B. Foto: Max Mustermann / Beispiel GmbH.')" />
<flux:separator variant="subtle" :text="__('Bildrechte')" />
<flux:input wire:model="newAuthor" :label="__('Urheber / Fotograf / Rechteinhaber (Pflichtfeld)')"
required />
<flux:select wire:model.live="newLicenseType" :label="__('Lizenztyp')" required>
<flux:select.option value="" disabled>{{ __('Bitte wählen…') }}</flux:select.option>
@foreach ($licenseTypeOptions as $value => $label)
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
@if ($newLicenseType === \App\Enums\ImageLicenseType::CreativeCommons->value)
<flux:select wire:model.live="newLicenseDetail" :label="__('Creative-Commons-Lizenz')" required>
<flux:select.option value="" disabled>{{ __('Bitte wählen…') }}</flux:select.option>
@foreach ($ccLicenseOptions as $value => $label)
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Creative-Commons-Lizenzen können Einschränkungen enthalten. Bitte prüfen Sie, ob kommerzielle Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt sind.') }}
</div>
@elseif($licenseDetailRequired)
<flux:input wire:model="newLicenseDetail" :label="__('Lizenzdetails / Begründung')"
:description="__('Bitte kurz erklären, warum die Nutzung erlaubt ist.')" required />
@endif
<flux:input wire:model="newLicenseUrl" type="url"
:label="$licenseUrlRequired ? __('Quelle oder Lizenznachweis-URL') : __(
'Quelle oder Lizenznachweis-URL (optional)')"
:required="$licenseUrlRequired" placeholder="https://…" />
<flux:input wire:model="newSourceUrl" type="url" :label="__('Weitere Quelle / Fundstelle (optional)')"
placeholder="https://…" />
<flux:radio.group wire:model.live="newPeopleRightsStatus"
:label="__('Sind erkennbare Personen abgebildet?')" required>
@foreach ($peopleRightsOptions as $value => $label)
<flux:radio :value="$value" :label="$label" />
@endforeach
</flux:radio.group>
@if (in_array($newPeopleRightsStatus, ['consent', 'public_event'], true))
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Bei erkennbaren Personen können zusätzlich Persönlichkeits- oder Datenschutzrechte betroffen sein. Bitte stellen Sie sicher, dass die Veröffentlichung zulässig ist.') }}
</div>
@endif
<flux:radio.group wire:model.live="newPropertyRightsStatus"
:label="__('Sind Marken, Kunstwerke, geschützte Werke oder private Orte sichtbar?')" required>
@foreach ($propertyRightsOptions as $value => $label)
<flux:radio :value="$value" :label="$label" />
@endforeach
</flux:radio.group>
@if ($showsRightsWarning)
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Diese Auswahl kann Einschränkungen enthalten. Bitte laden Sie das Bild nur hoch, wenn die Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt ist.') }}
</div>
@endif
<flux:textarea wire:model="newRightsNotes"
:label="__('Interne Hinweise zu Rechten / Freigaben (optional)')" rows="3" />
<flux:switch wire:model="newRightsConfirmed" align="right" :label="__('Rechte bestätigt')"
:description="__('Ich bestätige, dass ich über die erforderlichen Rechte zur Veröffentlichung dieses Bildes verfüge und die Verantwortung für die Richtigkeit meiner Angaben übernehme. Dies umfasst Urheberrechte, Nutzungsrechte, Persönlichkeitsrechte abgebildeter Personen sowie gegebenenfalls Marken-, Eigentums- oder sonstige Rechte Dritter. Mir ist bewusst, dass ich für fehlerhafte oder unvollständige Angaben verantwortlich bin.')" />
<div class="flex justify-end gap-2">
<flux:button type="button" variant="filled" wire:click="closeUploadForm">{{ __('Abbrechen') }}</flux:button>
<flux:button type="submit" variant="primary" icon="arrow-up-tray">{{ __('Hochladen') }}</flux:button>
</div>
</form>
@endif
@if($images->isEmpty())
@endif
@else
<div class="mt-4 rounded-md border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<flux:icon.photo class="mx-auto size-10 text-zinc-400" />
<flux:text class="mt-2 text-sm text-zinc-500">{{ __('Noch keine Bilder hinterlegt.') }}</flux:text>
</div>
@else
<div class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
@foreach($images as $image)
<div class="group rounded-md border border-zinc-200 p-2 dark:border-zinc-700" wire:key="pri-{{ $image->id }}">
<div class="relative aspect-square overflow-hidden rounded bg-zinc-50 dark:bg-zinc-800">
@if($image->variantUrl('thumb') ?? $image->url())
<img src="{{ $image->variantUrl('thumb') ?? $image->url() }}" alt="{{ $image->title ?? '' }}" class="absolute inset-0 size-full object-cover" loading="lazy" />
@endif
@if($image->is_preview)
<flux:badge color="green" size="sm" icon="star" class="absolute left-2 top-2">
{{ __('Vorschau') }}
</flux:badge>
@endif
</div>
<div class="mt-2 space-y-1">
@if($image->title)
<p class="truncate text-sm font-medium" title="{{ $image->title }}">{{ $image->title }}</p>
@endif
@if($image->copyright)
<p class="truncate text-xs text-zinc-500">{{ $image->copyright }}</p>
@endif
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
@if($image->width && $image->height)
<span>{{ $image->width }}×{{ $image->height }}</span>
@endif
@if(is_array($image->variants))
<flux:badge color="zinc" size="xs">{{ count($image->variants) }}× variant</flux:badge>
@endif
</div>
@if($canEdit)
<div class="flex flex-wrap gap-1 pt-1">
@if(! $image->is_preview)
<flux:button size="xs" variant="ghost" icon="star" wire:click="setPreview({{ $image->id }})" :title="__('Als Vorschau setzen')" />
@endif
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $image->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $image->id }})" :title="__('Runter')" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $image->id }})"
wire:confirm="{{ __('Bild wirklich entfernen?') }}" :title="__('Entfernen')" />
</div>
@endif
</div>
</div>
@endforeach
<flux:text class="mt-2 text-sm text-zinc-500">
{{ __('Noch kein eigenes Titelbild hinterlegt. Der Platzhalter bleibt aktiv.') }}</flux:text>
</div>
@endif
</flux:card>

View file

@ -0,0 +1,82 @@
<?php
use App\Enums\PressReleasePlaceholder;
use Flux\Flux;
use Livewire\Volt\Component;
/**
* Auswahl-Modal für die SVG-Titelbild-Platzhalter.
*
* Wird in den PM-Forms eingebunden. Beim Bestätigen wird das Event
* `placeholder-selected` mit der gewählten Variante dispatched; das
* Eltern-Formular übernimmt den Wert in sein `placeholder_variant`-Feld.
*/
new class extends Component
{
public string $selected = '';
public function mount(?string $current = null): void
{
$this->selected = PressReleasePlaceholder::fromValueOrDefault($current)->value;
}
public function choose(string $variant): void
{
$this->selected = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
}
public function confirm(): void
{
$this->dispatch('placeholder-selected', variant: $this->selected);
Flux::modal('placeholder-picker')->close();
}
public function with(): array
{
return [
'variants' => PressReleasePlaceholder::cases(),
];
}
}; ?>
<flux:modal name="placeholder-picker" class="w-full max-w-4xl">
<div class="space-y-5">
<div>
<flux:heading size="lg">{{ __('Titelbild-Platzhalter wählen') }}</flux:heading>
<flux:subheading>
{{ __('Wird verwendet, solange kein eigenes Titelbild hochgeladen ist.') }}
</flux:subheading>
</div>
<div class="grid max-h-[62vh] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-4">
@foreach ($variants as $variant)
<button type="button" wire:click="choose('{{ $variant->value }}')"
@class([
'group relative aspect-[16/9] overflow-hidden rounded-[6px] border-2 transition',
'border-[color:var(--color-hub)] ring-2 ring-[color:var(--color-hub)]/30' => $selected === $variant->value,
'border-transparent hover:border-[color:var(--color-bg-rule)]' => $selected !== $variant->value,
])
aria-pressed="{{ $selected === $variant->value ? 'true' : 'false' }}"
title="{{ $variant->label() }}">
<img src="{{ asset($variant->path()) }}" alt="{{ $variant->label() }}"
class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
@if ($selected === $variant->value)
<span class="absolute right-1.5 top-1.5 flex size-5 items-center justify-center rounded-full bg-[color:var(--color-hub)] text-white">
<flux:icon.check variant="micro" class="size-3.5" />
</span>
@endif
</button>
@endforeach
</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="confirm">
{{ __('Übernehmen') }}
</flux:button>
</div>
</div>
</flux:modal>

View file

@ -116,7 +116,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="ghost" icon="document-text" href="{{ route('me.invoices.index') }}" wire:navigate>
<flux:button size="sm" variant="filled" icon="document-text" href="{{ route('me.invoices.index') }}" wire:navigate>
{{ __('Rechnungen') }}
</flux:button>
<flux:button size="sm" variant="primary" icon="plus" disabled>
@ -229,7 +229,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@endif
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="ghost" disabled>
<flux:button size="sm" variant="filled" disabled>
{{ __('Kaufen') }}
</flux:button>
</flux:table.cell>

View file

@ -78,7 +78,7 @@ new class extends Component
@if ($selectedCompany)
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="building-office"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}"
wire:navigate
@ -86,7 +86,7 @@ new class extends Component
{{ __('Firma öffnen') }}
</flux:button>
@else
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen') }}
</flux:button>
@endif

View file

@ -515,7 +515,7 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte die Firmenverwaltung oder wenden Sie sich an den Support.') }}
</div>
<div class="mt-3">
<flux:button size="sm" variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
<flux:button size="sm" variant="filled" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen öffnen') }}
</flux:button>
</div>

View file

@ -76,7 +76,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="ghost" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
<flux:button size="sm" variant="filled" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
{{ __('Rechnungsadresse im Profil pflegen') }}
</flux:button>
</div>
@ -192,7 +192,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
<flux:table.cell>
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="arrow-top-right-on-square"
:href="route('me.invoices.pdf', $invoice)"
target="_blank"
@ -215,7 +215,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Sobald Rechnungen aus dem Archiv oder aus neuen Buchungen vorhanden sind, erscheinen sie hier.') }}
</p>
<flux:button class="mt-4" size="sm" variant="ghost" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
<flux:button class="mt-4" size="sm" variant="filled" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
{{ __('Rechnungsadresse prüfen') }}
</flux:button>
</div>

View file

@ -136,7 +136,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-kits.index') }}" wire:navigate>
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Zurück zur Liste') }}
</flux:button>
</div>
@ -207,7 +207,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
</article>
<div class="flex items-center justify-end gap-2">
<flux:button variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
<flux:button variant="filled" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary" icon="check">

View file

@ -452,7 +452,7 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="document-arrow-down" disabled>
<flux:button variant="filled" icon="document-arrow-down" disabled>
{{ __('Export') }}
<span class="badge muted ml-2" style="font-size:9px;padding:0 5px;letter-spacing:0.06em;">
{{ __('bald') }}
@ -829,7 +829,7 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
<div class="firm-list-actions flex items-center justify-end gap-1">
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="eye"
href="{{ route('me.press-kits.show', $company->id) }}"
wire:navigate
@ -838,7 +838,7 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
/>
<flux:button
size="sm"
variant="ghost"
variant="filled"
icon="document-plus"
href="{{ route('me.press-releases.create') }}"
wire:navigate

View file

@ -326,11 +326,11 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</div>
<div class="flex flex-wrap items-center gap-2 flex-shrink-0">
<flux:button icon="arrow-left" variant="ghost" href="{{ route('me.press-kits.index') }}" wire:navigate>
<flux:button icon="arrow-left" variant="filled" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
@if ($canManageCompany)
<flux:button icon="pencil" variant="ghost" wire:click="startEditCompany">
<flux:button icon="pencil" variant="filled" wire:click="startEditCompany">
{{ __('Stammdaten bearbeiten') }}
</flux:button>
@endif
@ -375,7 +375,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<div class="panel-head">
<span class="section-eyebrow">{{ __('Stammdaten') }}</span>
@if ($canManageCompany)
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="startEditCompany">
<flux:button size="sm" variant="filled" icon="pencil" wire:click="startEditCompany">
{{ __('Bearbeiten') }}
</flux:button>
@endif
@ -438,21 +438,41 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<div class="flex items-center gap-3">
<img src="{{ $logoUrl }}" alt="{{ $company->name }}" width="64" height="64"
class="h-16 max-h-16 w-16 max-w-16 rounded-[6px] border border-[color:var(--color-bg-rule)] object-contain bg-white" />
<flux:button type="button" size="sm" variant="ghost" wire:click="$set('removeCompanyLogo', true)">
<flux:button type="button" size="sm" variant="filled" wire:click="$set('removeCompanyLogo', true)">
{{ __('Logo entfernen') }}
</flux:button>
</div>
@endif
<flux:field>
<flux:input type="file" wire:model="companyLogo" :label="__('Neues Logo hochladen')"
<flux:file-upload wire:model="companyLogo"
:label="__('Neues Logo hochladen')"
accept="image/jpeg,image/png,image/webp,image/gif"
:description="__('JPG/PNG/WebP/GIF, max. 4 MB. Varianten werden automatisch generiert.')" />
:description="__('JPG/PNG/WebP/GIF, max. 4 MB. Varianten werden automatisch generiert.')">
<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:error name="companyLogo" />
@if ($companyLogo)
<flux:file-item class="mt-2"
:heading="$companyLogo->getClientOriginalName()"
:image="$companyLogo->temporaryUrl()"
:size="$companyLogo->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="$set('companyLogo', null)" :aria-label="__('Logo entfernen')" />
</x-slot>
</flux:file-item>
@endif
</flux:field>
</div>
<div class="mt-4 pt-4 border-t border-[color:var(--color-bg-rule)] flex justify-end gap-2">
<flux:button type="button" variant="ghost" wire:click="cancelCompanyForm">
<flux:button type="button" variant="filled" wire:click="cancelCompanyForm">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="button" variant="primary" wire:click="saveCompany">
@ -548,7 +568,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</div>
<div class="mt-4 pt-4 border-t border-[color:var(--color-bg-rule)] flex justify-end gap-2">
<flux:button type="button" variant="ghost" wire:click="cancelContactForm">
<flux:button type="button" variant="filled" wire:click="cancelContactForm">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="button" variant="primary" wire:click="saveContact">
@ -585,10 +605,10 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
@if ($canManageContacts)
<div class="flex gap-1 flex-shrink-0">
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="editContact({{ $contact->id }})">
<flux:button size="sm" variant="filled" icon="pencil" wire:click="editContact({{ $contact->id }})">
{{ __('Bearbeiten') }}
</flux:button>
<flux:button size="sm" variant="ghost" icon="trash"
<flux:button size="sm" variant="filled" icon="trash"
wire:click="deleteContact({{ $contact->id }})"
wire:confirm="{{ __('Diesen Pressekontakt löschen?') }}">
{{ __('Löschen') }}
@ -618,7 +638,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<article class="panel overflow-hidden" id="pressemitteilungen">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Pressemitteilungen dieser Firma') }}</span>
<flux:button size="sm" variant="ghost" href="{{ route('me.press-releases.index') }}" wire:navigate>
<flux:button size="sm" variant="filled" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Alle anzeigen') }}
</flux:button>
</div>
@ -653,7 +673,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
</span>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="ghost" icon="eye"
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('me.press-releases.show', $pressRelease->id) }}" wire:navigate>
{{ __('Öffnen') }}
</flux:button>
@ -701,7 +721,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<p class="mt-1 text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Rechnungen finden Sie aktuell gesammelt im Finanzbereich. Firmenscharfe Zahlungsarten folgen mit dem Preismodell.') }}
</p>
<flux:button class="mt-3" size="sm" variant="ghost" href="{{ route('me.invoices.index') }}" wire:navigate>
<flux:button class="mt-3" size="sm" variant="filled" href="{{ route('me.invoices.index') }}" wire:navigate>
{{ __('Rechnungen öffnen') }}
</flux:button>
</div>

View file

@ -1,6 +1,7 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleasePlaceholder;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
@ -14,6 +15,7 @@ use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
@ -25,6 +27,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public int|string|null $companyId = null;
public string $companySearch = '';
public int|string|null $categoryId = null;
public int|string|null $contactId = null;
@ -47,21 +51,35 @@ 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;
public string $placeholderVariant = '';
public function mount(): void
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$firstCompany = $context->selectedCompany($user) ?? $context->companiesFor($user)->first();
$firstCompany = $context->selectedCompany($user) ?? $context->latestCompaniesFor($user, 1)->first();
if ($firstCompany) {
$this->companyId = $firstCompany->id;
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
$this->contactId = $this->defaultContactIdFor((int) $firstCompany->id);
}
$this->placeholderVariant = PressReleasePlaceholder::default()->value;
}
#[On('placeholder-selected')]
public function setPlaceholderVariant(string $variant): void
{
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
}
public function updatedCompanyId(): void
@ -79,11 +97,44 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
unset($this->tags, $this->presubmitChecks);
}
public function updatedCompanySearch(): void
{
$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();
}
/**
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt und
* der User muss nicht erst auf „Entwurf speichern" klicken.
*
* 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
{
@ -145,20 +196,93 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
// die PM verlässlich rechtzeitig fängt.
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'];
}
$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 — Error-Bag wird automatisch befüllt.
}
}
public function addTag(string $tag): void
{
$tag = trim($tag);
@ -195,8 +319,77 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
unset($this->tags, $this->presubmitChecks);
}
/**
* Lazy Auto-Draft: legt sofort einen Entwurf an, damit Titelbild und
* Einstellungen schon vor dem finalen Speichern gepflegt werden
* können. Erfordert nur Firma und Kategorie (category_id ist NOT NULL),
* alle übrigen Felder werden soweit erfasst übernommen. Anschließend
* wird in den vollwertigen Editor (Edit-Seite) weitergeleitet, wo der
* Bild-Manager direkt zur Verfügung steht.
*/
public function ensureDraft(): void
{
try {
$this->validate([
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
], [
'companyId.required' => __('Bitte zuerst eine Firma wählen, bevor du ein Titelbild hochlädst.'),
'categoryId.required' => __('Bitte zuerst eine Kategorie wählen, bevor du ein Titelbild hochlädst.'),
]);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->notifyValidationError($e);
throw $e;
}
$user = auth()->user();
$company = $this->selectedCompany();
if (! $company) {
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
$this->notifyValidationError();
return;
}
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$slug = (new PressRelease)->generateUniqueSlug($this->title ?: __('Entwurf'), [
'portal' => $this->portal,
'language' => $this->language,
]);
$pr = PressRelease::query()->create([
'uuid' => (string) Str::uuid(),
'user_id' => $user->id,
'status' => PressReleaseStatus::Draft->value,
...$this->pressReleaseAttributes($slug),
]);
if ($this->contactId) {
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
if ($contact) {
$pr->contacts()->sync([$contact->id]);
}
}
Flux::toast(
heading: __('Entwurf gesichert'),
text: __('Du kannst jetzt ein Titelbild hochladen und alle Einstellungen vornehmen. Der Entwurf liegt unter „Meine PMs".'),
variant: 'success',
);
$this->redirect(route('me.press-releases.edit', $pr->id), navigate: true);
}
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) {
@ -237,31 +430,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
'language' => $this->language,
]);
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
$pr = PressRelease::query()->create([
'uuid' => (string) Str::uuid(),
'portal' => $this->portal,
'language' => $this->language,
'user_id' => $user->id,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'subtitle' => trim($this->subtitle) ?: null,
'slug' => $slug,
'text' => $cleanText,
'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,
'status' => $status->value,
...$this->pressReleaseAttributes($slug),
]);
if ($contact) {
@ -281,12 +454,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
}
/**
* Gemeinsame Spaltenwerte für Create- und Auto-Draft-Anlage.
*
* @return array<string, mixed>
*/
private function pressReleaseAttributes(string $slug): array
{
return [
'portal' => $this->portal,
'language' => $this->language,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => trim($this->title) !== '' ? $this->title : __('Unbenannter Entwurf'),
'subtitle' => trim($this->subtitle) ?: null,
'slug' => $slug,
'text' => app(PressReleaseHtmlSanitizer::class)->clean($this->text),
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
? trim($this->boilerplateOverride)
: null,
'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'scheduled_at' => $this->publishMode === 'scheduled'
? $this->scheduledAtUtc()
: null,
'embargo_at' => null,
];
}
public function with(): array
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$myCompanies = $context->companiesFor($user);
$selectedCompany = $this->selectedCompany();
$companyOptions = $context->searchCompaniesFor(
$user,
$this->companySearch,
$this->companyId ? (int) $this->companyId : null,
10,
);
$categories = Category::query()
->with('translations')
@ -295,7 +502,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
->get();
return [
'myCompanies' => $myCompanies,
'companyOptions' => $companyOptions,
'categories' => $categories,
'selectedCompany' => $selectedCompany,
'selectedCompanyContacts' => $selectedCompany
@ -303,6 +510,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
: Contact::query()->whereRaw('0 = 1')->get(),
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'quotaTotal' => (int) $user->press_release_quota,
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
];
}
@ -442,7 +651,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
}
}; ?>
<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">
@ -460,38 +669,64 @@ 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('me.press-releases.index') }}" wire:navigate>
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zur Liste') }}
</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>
<flux:select wire:model.live="companyId" class="!w-auto min-w-[260px]">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Boilerplate und Pressekontakt werden vorbefüllt.') }}
</span>
<span class="flex-1"></span>
@if ($selectedCompany)
<flux:button size="sm" variant="ghost" icon="building-office"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Firmenprofil') }}
</flux:button>
@endif
<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>
<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…') }}"
/>
</x-slot>
@foreach ($companyOptions as $company)
<flux:select.option :value="$company->id" wire:key="company-option-{{ $company->id }}">
{{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if (blank(trim($companySearch)))
{{ __('Keine Firma verfügbar.') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</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 vorbefüllt.') }}
</span>
@if ($selectedCompany)
<flux:button size="sm" variant="filled" icon="building-office"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Firmenprofil') }}
</flux:button>
@endif
</div>
</div>
<flux:error name="companyId" />
</section>
@ -594,27 +829,62 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</section>
{{-- 5) MEDIEN (nach Speichern verfügbar) --}}
{{-- 5) TITELBILD --}}
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Medien / Bilder') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
{{ __('nach Speichern verfügbar') }}
</span>
{{ __('Titelbild') }}
</span>
<span class="pr-bald-badge">{{ __('KI-Bildgenerierung · bald') }}</span>
</div>
{{-- Titelbild-Platzhalter (bis ein eigenes Bild hochgeladen ist) --}}
<div class="mb-4">
<div class="mb-2 flex items-center justify-between gap-3">
<span class="text-[12px] font-semibold text-[color:var(--color-ink-2)]">{{ __('Titelbild-Platzhalter') }}</span>
<flux:modal.trigger name="placeholder-picker">
<flux:button size="xs" variant="filled" icon="swatch">
{{ __('Platzhalter wählen') }}
</flux:button>
</flux:modal.trigger>
</div>
{{-- Anzeige analog Detailansicht: max. 1280×580, zentriert begrenzt --}}
<x-portal.press-release-placeholder
:variant="$placeholderVariant"
:title="$title ?: null"
class="mx-auto aspect-[1280/580] w-full max-w-[1280px] rounded-[6px] border border-[color:var(--color-bg-rule)]" />
<p class="mt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }}
</p>
</div>
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-5 text-center">
<flux:icon name="photo" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Sobald die Pressemitteilung als Entwurf gespeichert ist, kannst du Bilder hinzufügen, ein Titelbild festlegen und Bildunterschriften/Alt-Texte pflegen.') }}
{{ __('Lade ein eigenes Titelbild hoch. Dafür wird automatisch ein Entwurf gesichert — danach kannst du alles weiter bearbeiten.') }}
</p>
<flux:button
type="button"
variant="primary"
icon="arrow-up-tray"
class="mt-3"
wire:click="ensureDraft"
wire:loading.attr="disabled"
wire:target="ensureDraft"
>
{{ __('Titelbild hochladen & Entwurf sichern') }}
</flux:button>
<p class="mt-2 text-[11px] text-[color:var(--color-ink-4)] m-0">
{{ __('Erfordert nur Firma + Kategorie. Du landest danach im Editor mit Bild-Upload.') }}
</p>
</div>
</div>
</section>
{{-- Titelbild-Platzhalter-Auswahl --}}
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
{{-- 6) ANHÄNGE TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
@ -689,7 +959,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">
{{-- Status & Absenden --}}
<article class="panel" style="border-color:var(--color-hub);">
@ -733,16 +1003,17 @@ 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"
>
{{ __('Zur Prüfung senden') }}
</flux:button>
<flux:modal.trigger name="confirm-submit-review">
<flux:button
type="button"
variant="primary"
icon="paper-airplane"
class="w-full"
wire:loading.attr="disabled"
>
{{ __('Zur Prüfung senden') }}
</flux:button>
</flux:modal.trigger>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}
</p>
@ -750,7 +1021,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
<flux:button
type="button"
variant="ghost"
variant="filled"
icon="bookmark"
class="w-full"
wire:click="save('draft')"
@ -822,7 +1093,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ __('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('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Kontakt im Firmenprofil anlegen') }}
</flux:button>
@ -952,39 +1223,31 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</label>
@if ($publishMode === 'scheduled')
<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:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
<flux:error name="scheduledAt" />
</flux:field>
@endif
<div class="border-t pt-3" style="border-color: var(--color-line);">
<flux:switch
wire:model.live="useEmbargo"
:label="__('Sperrfrist (Embargo) setzen')"
/>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
{{ __('Die 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')"
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Datum') }}</flux:label>
<flux:date-picker
wire:model.live="scheduledDate"
type="input"
:placeholder="__('Datum wählen')"
with-today
/>
<flux:error name="embargoAt" />
<flux:error name="scheduledDate" />
</flux:field>
@endif
</div>
<flux:field>
<flux:label>{{ __('Uhrzeit') }}</flux:label>
<flux:time-picker
wire:model.live="scheduledTime"
type="input"
:placeholder="__('Uhrzeit wählen')"
/>
<flux:error name="scheduledTime" />
</flux:field>
</div>
<flux:error name="scheduledAt" />
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
@endif
</div>
</article>
@ -1027,4 +1290,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</aside>
</div>
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}}
<x-press-release-submit-modal
name="confirm-submit-review"
action="save('review')"
:confirm-label="__('Zur Prüfung senden')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining" />
</div>

View file

@ -1,6 +1,7 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleasePlaceholder;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
@ -8,6 +9,7 @@ use App\Models\Contact;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
@ -16,6 +18,7 @@ use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
@ -30,6 +33,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
public int|string|null $companyId = null;
public string $companySearch = '';
public int|string|null $categoryId = null;
public int|string|null $contactId = null;
@ -52,12 +57,24 @@ 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;
public string $currentStatus = '';
public ?int $contentScore = null;
public ?string $contentTier = null;
public string $placeholderVariant = '';
private ?PressRelease $cachedPressRelease = null;
public function mount(int $id): void
{
$this->id = $id;
@ -72,6 +89,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
);
$this->currentStatus = $pr->status->value;
$this->contentScore = $pr->content_score;
$this->contentTier = $pr->content_tier?->value;
$this->portal = $pr->portal->value;
$this->language = $pr->language;
$this->companyId = $pr->company_id;
@ -87,14 +106,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
?? $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');
$this->scheduledAt = $scheduledAt->format('Y-m-d\TH:i');
$this->scheduledDate = $scheduledAt->format('Y-m-d');
$this->scheduledTime = $scheduledAt->format('H:i');
}
if ($pr->embargo_at) {
$this->useEmbargo = true;
$this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i');
}
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value;
}
#[On('placeholder-selected')]
public function setPlaceholderVariant(string $variant): void
{
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
}
#[On('title-image-changed')]
public function refreshTitleImage(): void
{
unset($this->tags, $this->presubmitChecks);
}
public function updatedCompanyId(): void
@ -118,6 +150,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
unset($this->tags, $this->presubmitChecks);
}
public function updatedCompanySearch(): void
{
$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 addTag(string $tag): void
{
$tag = trim($tag);
@ -159,6 +220,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt und
* der User muss nicht erst auf „Speichern" klicken.
*
* 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
{
@ -214,22 +279,99 @@ 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'];
}
$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 — Error-Bag wird automatisch befüllt.
}
}
public function save(bool $submitAfterSave = false): void
{
$this->syncScheduledAt();
$this->useEmbargo = false;
$this->embargoAt = null;
try {
$this->validate($this->formRules());
} catch (\Illuminate\Validation\ValidationException $e) {
@ -278,16 +420,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
? trim($this->boilerplateOverride)
: null,
'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value,
'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)
'scheduled_at' => $this->publishMode === 'scheduled'
? $this->scheduledAtUtc()
: null,
'embargo_at' => null,
]);
$contentChanged = $pr->wasChanged(['title', 'text']);
$pr->contacts()->sync($contact ? [$contact->id] : []);
if ($submitAfterSave) {
@ -314,6 +457,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
variant: 'success',
);
} else {
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu
// prüfen (Re-Check ohne Routing) und neu bewerten. Beim Einreichen
// übernimmt das submitForReview.
if ($contentChanged) {
$service = app(PressReleaseService::class);
$fresh = $pr->fresh();
$service->reclassifyIfClassified($fresh);
$service->rescoreIfScored($fresh);
}
Flux::toast(
heading: __('Gespeichert'),
text: __('Deine Änderungen sind gesichert.'),
@ -333,17 +486,24 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$myCompanies = $context->companiesFor($user);
$selectedCompany = $this->selectedCompany();
$companyOptions = $context->searchCompaniesFor(
$user,
$this->companySearch,
$this->companyId ? (int) $this->companyId : null,
10,
);
$categories = Category::query()
->with('translations')
->where('is_active', true)
->orderBy('id')
->get();
$pressRelease = $this->getMyPR()->load('images');
$cover = app(PressReleaseCoverImage::class);
return [
'myCompanies' => $myCompanies,
'companyOptions' => $companyOptions,
'categories' => $categories,
'selectedCompany' => $selectedCompany,
'selectedCompanyContacts' => $selectedCompany
@ -351,6 +511,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
: Contact::query()->whereRaw('0 = 1')->get(),
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'coverUrl' => $cover->coverUrl($pressRelease, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease),
'quotaTotal' => (int) $user->press_release_quota,
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
];
}
@ -453,7 +617,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
private function getMyPR(): PressRelease
{
return PressRelease::withoutGlobalScopes()
// Pro Livewire-Request memoisiert: mount(), with() und save() greifen
// sonst jeweils mit einer eigenen Query auf dieselbe PM zu.
return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes()
->where('user_id', auth()->id())
->findOrFail($this->id);
}
@ -495,7 +661,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
}
}; ?>
<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">
@ -518,17 +684,17 @@ 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('me.press-releases.show', $id) }}" wire:navigate>
<flux:button variant="filled" icon="eye" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
{{ __('Vorschau / Detail') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zur Liste') }}
</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">
@ -537,18 +703,42 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<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>
<flux:select wire:model.live="companyId" class="!w-auto min-w-[260px]">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
<div class="min-w-[260px]">
<flux:select
wire:model.live="companyId"
variant="combobox"
:filter="false"
clearable
placeholder="{{ __('Firma suchen…') }}"
>
<x-slot name="input">
<flux:select.input
wire:model.live.debounce.300ms="companySearch"
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach ($companyOptions as $company)
<flux:select.option :value="$company->id" wire:key="company-option-{{ $company->id }}">
{{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if (blank(trim($companySearch)))
{{ __('Keine Firma verfügbar.') }}
@else
{{ __('Keine Firma gefunden.') }}
@endif
</flux:select.option.empty>
</x-slot>
</flux:select>
</div>
<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('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Firmenprofil') }}
</flux:button>
@ -655,7 +845,32 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</div>
</section>
{{-- 5) MEDIEN Image-Manager direkt eingebunden --}}
@if ($coverIsPlaceholder)
{{-- 5) TITELBILD-PLATZHALTER --}}
<section class="panel">
<div class="p-5">
<div class="mb-3 flex items-center justify-between gap-3">
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Titelbild-Platzhalter') }}</span>
<flux:modal.trigger name="placeholder-picker">
<flux:button size="xs" variant="filled" icon="swatch">
{{ __('Platzhalter wählen') }}
</flux:button>
</flux:modal.trigger>
</div>
<x-portal.press-release-placeholder
:variant="$placeholderVariant"
:title="$title ?: null"
class="mx-auto aspect-[1280/580] w-full max-w-[1280px] rounded-[6px] border border-[color:var(--color-bg-rule)]" />
<p class="mt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }}
</p>
</div>
</section>
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
@endif
{{-- 6) MEDIEN Image-Manager direkt eingebunden --}}
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
{{-- 6) ANHÄNGE TEMPORÄR DEAKTIVIERT
@ -718,7 +933,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 & Absenden --}}
<article class="panel" style="border-color:var(--color-hub);">
@ -766,17 +981,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
@endforeach
</div>
<flux:button
type="button"
variant="primary"
icon="paper-airplane"
class="w-full"
wire:click="saveAndSubmit"
wire:confirm="{{ __('Änderungen speichern und zur Prüfung einreichen?') }}"
wire:loading.attr="disabled"
>
{{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }}
</flux:button>
<flux:modal.trigger name="confirm-submit-review">
<flux:button
type="button"
variant="primary"
icon="paper-airplane"
class="w-full"
wire:loading.attr="disabled"
>
{{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }}
</flux:button>
</flux:modal.trigger>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}
</p>
@ -784,7 +999,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
<flux:button
type="button"
variant="ghost"
variant="filled"
icon="bookmark"
class="w-full"
wire:click="save"
@ -795,6 +1010,42 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</div>
</article>
{{-- Content-Score (Qualitäts-Feedback während des Schreibens) --}}
@if (! is_null($contentScore))
@php
$tierEnum = $contentTier ? \App\Enums\PressReleaseContentTier::from($contentTier) : null;
$tiers = config('scoring.content_score.tiers');
$nextThreshold = $contentScore < (int) $tiers['gepruft']
? (int) $tiers['gepruft']
: ($contentScore < (int) $tiers['hochwertig'] ? (int) $tiers['hochwertig'] : null);
@endphp
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Qualität') }}</span>
@if ($tierEnum)
<span class="badge {{ $tierEnum === \App\Enums\PressReleaseContentTier::Hochwertig ? 'ok' : ($tierEnum === \App\Enums\PressReleaseContentTier::Geprueft ? 'hub' : 'muted') }}">{{ $tierEnum->label() }}</span>
@endif
</div>
<div class="p-4">
<div class="text-[26px] font-bold leading-none text-[color:var(--color-ink)]">
{{ $contentScore }}<span class="text-[14px] font-medium text-[color:var(--color-ink-3)]">/100</span>
</div>
@if ($nextThreshold)
<p class="text-[12px] text-[color:var(--color-ink-2)] mt-2 m-0">
{{ __('Noch :points Punkte bis zur nächsten Stufe.', ['points' => $nextThreshold - $contentScore]) }}
</p>
@else
<p class="text-[12px] text-[color:var(--color-ink-2)] mt-2 m-0">
{{ __('Höchste Stufe erreicht.') }}
</p>
@endif
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
{{ __('Der Score wird nach dem Speichern automatisch neu berechnet.') }}
</p>
</div>
</article>
@endif
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
<article class="panel">
<div class="panel-head">
@ -856,7 +1107,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('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Kontakt im Firmenprofil anlegen') }}
</flux:button>
@ -986,39 +1237,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</label>
@if ($publishMode === 'scheduled')
<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:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
<flux:error name="scheduledAt" />
</flux:field>
@endif
<div class="border-t pt-3" style="border-color: var(--color-line);">
<flux:switch
wire:model.live="useEmbargo"
:label="__('Sperrfrist (Embargo) setzen')"
/>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
{{ __('Die 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')"
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Datum') }}</flux:label>
<flux:date-picker
wire:model.live="scheduledDate"
type="input"
:placeholder="__('Datum wählen')"
with-today
/>
<flux:error name="embargoAt" />
<flux:error name="scheduledDate" />
</flux:field>
@endif
</div>
<flux:field>
<flux:label>{{ __('Uhrzeit') }}</flux:label>
<flux:time-picker
wire:model.live="scheduledTime"
type="input"
:placeholder="__('Uhrzeit wählen')"
/>
<flux:error name="scheduledTime" />
</flux:field>
</div>
<flux:error name="scheduledAt" />
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
@endif
</div>
</article>
@ -1061,4 +1304,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</aside>
</div>
{{-- Einreichungs-Modal (öffnet über „Speichern & zur Prüfung") --}}
<x-press-release-submit-modal
name="confirm-submit-review"
action="saveAndSubmit"
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & einreichen')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining" />
</div>

View file

@ -505,23 +505,23 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
@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>
<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('me.press-releases.show', $pr->id) }}" wire:navigate />
@if (in_array($status, ['draft', 'rejected']))
<flux:button size="sm" variant="ghost" icon="pencil"
<flux:button size="sm" variant="filled" icon="pencil"
href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
@endif
</div>

View file

@ -4,6 +4,7 @@ use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Livewire\Attributes\Layout;
@ -35,6 +36,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
try {
app(PressReleaseService::class)->submitForReview($pr);
} catch (BlacklistViolationException $e) {
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Automatisch abgelehnt'),
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
@ -45,6 +48,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
return;
}
Flux::modal('confirm-submit-review')->close();
Flux::toast(
heading: __('Eingereicht'),
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
@ -78,9 +83,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
}
$cover = app(PressReleaseCoverImage::class);
$user = auth()->user();
return [
'pr' => $pr,
'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'quotaTotal' => (int) $user->press_release_quota,
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'canEdit' => auth()->user()->can('update', $pr)
&& in_array($pr->status->value, ['draft', 'rejected']),
'latestRejection' => $latestRejection,
@ -133,6 +145,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Pressemitteilung') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
@if ($pr->content_tier?->isPubliclyBadged())
<span class="badge {{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? 'ok' : 'hub' }}">
{{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? '★ ' : '✓ ' }}{{ $pr->content_tier->label() }}
</span>
@endif
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
@ -154,19 +171,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="flex items-center gap-2 flex-shrink-0">
@if ($canEdit)
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button variant="ghost" icon="link" wire:click="generateShareLink">
<flux:button variant="filled" icon="link" wire:click="generateShareLink">
{{ __('Vorschau-Link') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.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 — laden Sie im Editor ein eigenes Bild hoch.') }}</span>
</div>
@endif
</article>
{{-- ============== SHARE-LINK ERFOLG ============== --}}
@if ($shareUrl)
<article class="panel" style="border-color:var(--color-ok);">
@ -224,14 +257,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</p>
<div class="flex items-center gap-2 flex-shrink-0">
@if ($canEdit)
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button type="button" variant="primary" wire:click="submitForReview"
wire:confirm="{{ __('Pressemitteilung zur Prüfung einreichen?') }}">
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
</flux:button>
<flux:modal.trigger name="confirm-submit-review">
<flux:button type="button" variant="primary">
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
</flux:button>
</flux:modal.trigger>
</div>
</div>
</article>
@ -261,7 +295,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('me.press-kits.show', $pr->company->id) }}" wire:navigate>
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate>
{{ __('Firma') }}
</flux:button>
@endif
@ -341,7 +375,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">{{ __('Geplante Veröffentlichung') }}</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
@ -349,7 +383,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
@ -434,6 +468,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
</article>
{{-- ============== VERÖFFENTLICHUNGS-MODAL ============== --}}
@if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<x-press-release-submit-modal
name="confirm-submit-review"
action="submitForReview"
:confirm-label="__('Veröffentlichung anfordern')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining" />
@endif
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
@if ($pr->boilerplate_override)
<article class="panel">

View file

@ -199,7 +199,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('me.press-kits.index') }}" wire:navigate>
{{ __('Firmen verwalten') }}
</flux:button>
</div>
@ -315,7 +315,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
<option value="en">English</option>
</flux:select>
<div class="pt-3 border-t border-[color:var(--color-bg-rule)]">
<flux:button size="sm" variant="ghost" icon="shield-check" href="{{ route('me.security') }}" wire:navigate>
<flux:button size="sm" variant="filled" icon="shield-check" href="{{ route('me.security') }}" wire:navigate>
{{ __('Konto-Sicherheit öffnen') }}
</flux:button>
</div>

View file

@ -291,7 +291,7 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
@endif
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] flex flex-wrap gap-2">
<flux:button wire:click="regenerateRecoveryCodes" variant="ghost">
<flux:button wire:click="regenerateRecoveryCodes" variant="filled">
{{ __('Neue Wiederherstellungs-Codes erzeugen') }}
</flux:button>
<flux:button wire:click="disableTwoFactorAuthentication" variant="danger">

View file

@ -102,7 +102,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button href="{{ route('docs.api.v1') }}" variant="ghost" icon="book-open">
<flux:button href="{{ route('docs.api.v1') }}" variant="filled" icon="book-open">
{{ __('API-Dokumentation') }}
</flux:button>
</div>