create PM v0.5
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
Kevin Adametz 2026-05-20 19:14:39 +02:00
parent 9b47296cea
commit d2ba22c0cf
25 changed files with 2155 additions and 72 deletions

View file

@ -697,3 +697,264 @@
padding-inline-end: 18px;
}
}
/* ============================================================
* Phase 7 Press-Release-Form-Bausteine
* ============================================================
* Vorlage: dev/frontend/tailwind_v3/User Neue Mitteilung presseportale.html
* Bewusst eng auf den Form-Kontext gemünzt. Wird im Hub-Form für
* Counter-Pillen, KI-bald-Hints, Pre-Submit-Checks, Boilerplate-Box,
* Tag-Chips und Portal-/Veröffentlichungs-Optionen verwendet.
*/
@layer components {
.pr-form-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-ink-3);
margin-bottom: 8px;
}
.pr-form-label .req {
color: var(--color-err);
font-weight: 700;
letter-spacing: 0;
}
.pr-form-help {
font-size: 11.5px;
color: var(--color-ink-4);
margin-top: 6px;
line-height: 1.5;
}
.pr-form-help.warn {
color: var(--color-warn);
}
/* Counter-Pille mit Bar (Titel-, Subline-, Body-Länge) */
.pr-meter {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 10.5px;
color: var(--color-ink-3);
font-variant-numeric: tabular-nums;
}
.pr-meter .bar {
position: relative;
width: 60px;
height: 4px;
background: var(--color-bg-rule-2);
border-radius: 99px;
overflow: hidden;
}
.pr-meter .bar i {
position: absolute;
inset: 0 auto 0 0;
background: var(--color-hub);
border-radius: 99px;
}
.pr-meter.good .bar i {
background: var(--color-ok);
}
.pr-meter.warn .bar i {
background: var(--color-warn);
}
.pr-meter.err .bar i {
background: var(--color-err);
}
/* KI-bald-Badge — gestrichelter Akzent-Ring */
.pr-bald-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 99px;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
background: var(--color-accent-soft);
color: var(--color-accent-deep);
border: 1px dashed color-mix(in srgb, var(--color-accent) 60%, transparent);
}
.pr-bald-badge::before {
content: "";
width: 4px;
height: 4px;
border-radius: 99px;
background: var(--color-accent);
}
/* KI-Inline-Hint (z.B. KI-Lektorat unter dem Editor) */
.pr-ai-hint {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 11px;
background: color-mix(in srgb, var(--color-accent-soft) 60%, var(--color-bg));
border: 1px dashed color-mix(in srgb, var(--color-accent) 50%, transparent);
border-radius: 4px;
font-size: 11.5px;
color: var(--color-accent-deep);
line-height: 1.45;
}
.pr-ai-hint .ico {
width: 22px;
height: 22px;
border-radius: 4px;
background: var(--color-accent-soft);
color: var(--color-accent-deep);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Pre-Submit-Checkliste */
.pr-check-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 7px 0;
font-size: 12.5px;
}
.pr-check-row .ic {
width: 16px;
height: 16px;
border-radius: 99px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.pr-check-row.ok .ic {
background: var(--color-ok-soft);
color: var(--color-ok);
}
.pr-check-row.warn .ic {
background: var(--color-warn-soft);
color: var(--color-warn);
}
.pr-check-row.err .ic {
background: var(--color-err-soft);
color: var(--color-err);
}
.pr-check-row .lbl {
flex: 1;
color: var(--color-ink);
line-height: 1.4;
}
.pr-check-row .lbl .sub {
display: block;
font-size: 11px;
color: var(--color-ink-3);
margin-top: 1px;
}
/* Boilerplate-Box (Read-only Firmenprofil) */
.pr-boiler {
background: var(--color-bg-elev);
border: 1px dashed var(--color-hub-soft-2);
border-radius: 5px;
padding: 14px 16px;
font-family: var(--font-serif, Georgia, serif);
font-size: 13.5px;
color: var(--color-ink-2);
line-height: 1.6;
}
/* Themen-Tag-Chip + Vorschlags-Buttons */
.pr-tag-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 4px 3px 9px;
background: var(--color-hub-soft);
color: var(--color-hub);
border-radius: 3px;
font-size: 11.5px;
font-weight: 500;
}
.pr-tag-chip .x {
width: 16px;
height: 16px;
border-radius: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-hub);
transition: background 0.12s;
cursor: pointer;
}
.pr-tag-chip .x:hover {
background: var(--color-hub-soft-2);
}
.pr-tag-suggest {
padding: 3px 9px;
background: var(--color-bg-elev);
border: 1px dashed var(--color-hub-soft-2);
border-radius: 3px;
font-size: 11.5px;
color: var(--color-ink-2);
font-weight: 500;
transition: border-color 0.12s, background 0.12s, color 0.12s;
cursor: pointer;
}
.pr-tag-suggest:hover {
border-style: solid;
border-color: var(--color-hub);
background: var(--color-hub-soft);
color: var(--color-hub);
}
/* Veröffentlichungs-Optionen (RadioGroup-Cards) */
.pr-pub-opt {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 12px;
border: 1px solid var(--color-bg-rule);
border-radius: 4px;
background: var(--color-bg-elev);
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.pr-pub-opt:hover {
border-color: var(--color-hub);
}
.pr-pub-opt.is-checked {
border-color: var(--color-hub);
background: var(--color-hub-soft);
box-shadow: inset 0 0 0 1px var(--color-hub);
}
.pr-pub-opt.is-disabled {
opacity: 0.7;
cursor: not-allowed;
}
.pr-pub-opt .dot-out {
width: 14px;
height: 14px;
border-radius: 99px;
border: 2px solid var(--color-ink-4);
flex-shrink: 0;
margin-top: 2px;
position: relative;
}
.pr-pub-opt.is-checked .dot-out {
border-color: var(--color-hub);
background: var(--color-hub);
}
.pr-pub-opt.is-checked .dot-out::after {
content: "";
position: absolute;
inset: 3px;
border-radius: 99px;
background: #fff;
}
}

View file

@ -6,6 +6,7 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@ -69,6 +70,8 @@ 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,
@ -78,7 +81,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'slug' => $slug,
'text' => $this->text,
'text' => $cleanText,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'status' => $status->value,
@ -176,7 +179,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text der Pressemitteilung…') }}" />
<flux:editor
wire:model="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
placeholder="{{ __('Vollständiger Text der Pressemitteilung…') }}"
/>
<flux:error name="text" />
</flux:field>
</div>

View file

@ -7,6 +7,7 @@ use App\Models\Company;
use App\Models\PressRelease;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Services\PressRelease\PressReleaseService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
@ -95,6 +96,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$slug = $pr->slug;
}
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
$pr->update([
'portal' => $this->portal,
'language' => $this->language,
@ -102,7 +105,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'slug' => $slug,
'text' => $this->text,
'text' => $cleanText,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'no_export' => $this->noExport,
@ -327,7 +330,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="20" />
<flux:editor
wire:model="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
/>
<flux:error name="text" />
</flux:field>
</div>

View file

@ -196,7 +196,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! nl2br(e($pr->text)) !!}
{!! $pr->renderedText() !!}
</div>
</div>
</article>

View file

@ -4,10 +4,14 @@ use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Services\Customer\CustomerCompanyContext;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
@ -22,14 +26,24 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public int|string|null $categoryId = null;
public int|string|null $contactId = null;
public string $title = '';
public string $subtitle = '';
public string $text = '';
public string $keywords = '';
public string $backlinkUrl = '';
public string $boilerplateOverride = '';
public bool $useBoilerplateOverride = false;
public string $publishMode = 'now';
public function mount(): void
{
$user = auth()->user();
@ -39,19 +53,74 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
if ($firstCompany) {
$this->companyId = $firstCompany->id;
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
$this->contactId = $this->defaultContactIdFor((int) $firstCompany->id);
}
}
public function updatedCompanyId(): void
{
$company = $this->selectedCompany();
if ($company?->portal) {
$this->portal = $company->portal->value;
}
$this->contactId = $company
? $this->defaultContactIdFor((int) $company->id)
: null;
unset($this->tags, $this->presubmitChecks);
}
public function addTag(string $tag): void
{
$tag = trim($tag);
if ($tag === '') {
return;
}
$existing = $this->tagsArray();
if (count($existing) >= 5) {
return;
}
if (in_array($tag, $existing, true)) {
return;
}
$existing[] = $tag;
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
public function removeTag(string $tag): void
{
$existing = array_values(array_filter(
$this->tagsArray(),
fn (string $existingTag): bool => $existingTag !== $tag,
));
$this->keywords = implode(', ', $existing);
unset($this->tags, $this->presubmitChecks);
}
public function save(string $submitStatus = 'draft'): void
{
$this->validate([
'language' => ['required', Rule::in(['de', 'en'])],
'companyId' => ['required', 'integer'],
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
'contactId' => ['required', 'integer'],
'title' => ['required', 'string', 'min:5', 'max:255'],
'subtitle' => ['nullable', 'string', 'max:255'],
'text' => ['required', 'string', 'min:50'],
'keywords' => ['nullable', 'string', 'max:255'],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
]);
$user = auth()->user();
@ -63,6 +132,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
return;
}
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
if (! $contact) {
$this->addError('contactId', __('Der gewählte Pressekontakt gehört nicht zu dieser Firma.'));
return;
}
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$status = $submitStatus === 'review' ? PressReleaseStatus::Review : PressReleaseStatus::Draft;
@ -72,6 +149,8 @@ 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,
@ -80,13 +159,19 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'subtitle' => trim($this->subtitle) ?: null,
'slug' => $slug,
'text' => $this->text,
'text' => $cleanText,
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
? trim($this->boilerplateOverride)
: null,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
'status' => $status->value,
]);
$pr->contacts()->sync([$contact->id]);
session()->flash('success', $status === PressReleaseStatus::Review
? __('Pressemitteilung zur Prüfung eingereicht.')
: __('Entwurf gespeichert.'));
@ -99,6 +184,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
$myCompanies = $context->companiesFor($user);
$selectedCompany = $this->selectedCompany();
$categories = Category::query()
->with('translations')
@ -109,102 +195,583 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
return [
'myCompanies' => $myCompanies,
'categories' => $categories,
'selectedPortalLabel' => $this->selectedCompany()?->portal?->label() ?? __('Wird aus der Firma übernommen'),
'selectedCompany' => $selectedCompany,
'selectedCompanyContacts' => $selectedCompany
? $this->companyContacts((int) $selectedCompany->id)
: Contact::query()->whereRaw('0 = 1')->get(),
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
];
}
public function updatedCompanyId(): void
#[Computed]
public function tags(): array
{
$company = $this->selectedCompany();
return $this->tagsArray();
}
if ($company?->portal) {
$this->portal = $company->portal->value;
#[Computed]
public function presubmitChecks(): array
{
$titleLen = mb_strlen(trim($this->title));
$textLen = app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text);
$tagsCount = count($this->tagsArray());
return [
[
'key' => 'title',
'status' => $titleLen >= 5 ? 'ok' : 'err',
'label' => __('Titel vorhanden'),
'sub' => $titleLen > 0
? trans_choice('{0}Noch leer|{1}:n Zeichen|[2,*]:n Zeichen', $titleLen, ['n' => $titleLen])
: __('Noch leer'),
],
[
'key' => 'text',
'status' => $textLen >= 600 ? 'ok' : ($textLen >= 50 ? 'warn' : 'err'),
'label' => __('Mindestlänge Fließtext erreicht'),
'sub' => __(':n / 600 Zeichen empfohlen', ['n' => number_format($textLen, 0, ',', '.')]),
],
[
'key' => 'company',
'status' => $this->companyId ? 'ok' : 'err',
'label' => __('Firma zugeordnet'),
'sub' => $this->selectedCompany()?->name ?? __('Keine Firma gewählt'),
],
[
'key' => 'category',
'status' => $this->categoryId ? 'ok' : 'err',
'label' => __('Kategorie gewählt'),
'sub' => $this->categoryId ? '' : __('Kategorie ist Pflicht'),
],
[
'key' => 'contact',
'status' => $this->contactId ? 'ok' : 'err',
'label' => __('Pressekontakt zugeordnet'),
'sub' => $this->contactId ? '' : __('Mindestens ein Kontakt erforderlich'),
],
[
'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'),
],
];
}
/**
* @return list<string>
*/
private function tagsArray(): array
{
if (trim($this->keywords) === '') {
return [];
}
return collect(explode(',', $this->keywords))
->map(fn (string $tag): string => trim($tag))
->filter()
->unique()
->values()
->all();
}
private function defaultContactIdFor(int $companyId): ?int
{
return $this->companyContacts($companyId)->first()?->id;
}
/**
* @return Collection<int, Contact>
*/
private function companyContacts(int $companyId): Collection
{
return Contact::withoutGlobalScopes()
->where('company_id', $companyId)
->orderBy('last_name')
->orderBy('first_name')
->get(['id', 'company_id', 'first_name', 'last_name', 'responsibility', 'phone', 'email']);
}
private function companyContact(int $contactId, int $companyId): ?Contact
{
return Contact::withoutGlobalScopes()
->where('company_id', $companyId)
->whereKey($contactId)
->first();
}
private function selectedCompany(): ?Company
{
if (! $this->companyId) {
return null;
}
return app(CustomerCompanyContext::class)
->findFor(auth()->user(), (int) $this->companyId);
}
/**
* @return list<string>
*/
private function tagSuggestionsFor(?Company $company): array
{
$defaults = [
__('Mittelstand'),
__('Unternehmen'),
__('Eröffnung'),
__('Innovation'),
__('Nachhaltigkeit'),
];
if (! $company) {
return $defaults;
}
$portalLabel = $company->portal?->label();
return array_values(array_unique(array_filter([
$portalLabel,
$company->country_code === 'DE' ? __('Deutschland') : null,
...$defaults,
])));
}
}; ?>
<div class="space-y-8">
<div class="space-y-8" x-data="{ tagInput: '' }">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Neue PM') }}</span>
<span class="badge muted dot">{{ __('Entwurf') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Neue Pressemitteilung') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}
{{ __('Schreibfläche links, Steuerung rechts. Pflichtfelder werden rechts in der Checkliste angezeigt.') }}
</p>
</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>
{{ __('Zurück') }}
{{ __('Zur Liste') }}
</flux:button>
</div>
</header>
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
<div class="space-y-6">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
{{-- ============== 2-COLUMN GRID ============== --}}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),360px]">
{{-- =================== 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>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input wire:model="title" placeholder="{{ __('Aussagekräftiger Titel…') }}" />
<flux:error name="title" />
</flux:field>
<flux:error name="companyId" />
</section>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text…') }}" />
<flux:error name="text" />
</flux:field>
{{-- 2) TITEL --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Titel / Headline') }} <span class="req">*</span>
</span>
<div class="flex items-center gap-3">
@php
$titleLen = mb_strlen($title);
$titleClass = $titleLen >= 40 && $titleLen <= 90 ? 'good' : ($titleLen > 0 ? 'warn' : '');
$titleBar = min(100, max(0, ($titleLen / 100) * 100));
@endphp
<span class="pr-meter {{ $titleClass }}">
<span class="bar"><i style="width: {{ $titleBar }}%;"></i></span>
{{ $titleLen }} / 100
</span>
<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"
/>
<p class="pr-form-help">
{{ __('4090 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
</p>
<flux:error name="title" />
</div>
</section>
<flux:field>
<flux:label>{{ __('Stichwörter') }}</flux:label>
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennt…') }}" />
</flux:field>
{{-- 3) SUBTITLE --}}
<section class="panel">
<div class="p-5 pb-4">
<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;">
{{ __('optional') }}
</span>
</span>
@php
$subLen = mb_strlen($subtitle);
$subBar = min(100, max(0, ($subLen / 200) * 100));
@endphp
<span class="pr-meter">
<span class="bar"><i style="width: {{ $subBar }}%;"></i></span>
{{ $subLen }} / 200
</span>
</div>
<flux:input
wire:model.live.debounce.300ms="subtitle"
placeholder="{{ __('Kurzer Zusatztext / Dachzeile…') }}"
/>
<flux:error name="subtitle" />
</div>
</section>
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
{{-- 4) FLIESSTEXT --}}
<section class="panel">
<div class="p-5 pb-4">
<div class="flex items-center justify-between mb-2 gap-4">
<span class="pr-form-label" style="margin-bottom:0;">
{{ __('Fließtext') }} <span class="req">*</span>
</span>
<div class="flex items-center gap-3">
@php
$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
<span class="pr-meter {{ $textClass }}">
<span class="bar"><i style="width: {{ $textBar }}%;"></i></span>
{{ number_format($textLen, 0, ',', '.') }} / 3.500 Z.
</span>
<span class="pr-bald-badge">{{ __('KI-Lektorat · bald') }}</span>
</div>
</div>
<flux:editor
wire:model.live.debounce.500ms="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
placeholder="{{ __('Hier weiterschreiben…') }}"
/>
<flux:error name="text" />
<div class="pr-ai-hint mt-4">
<span class="ico">
<flux:icon name="sparkles" variant="micro" />
</span>
<span>
<strong class="font-semibold">{{ __('KI-Lektorat') }}</strong>
{{ __('liest Korrektur, schlägt Kürzungen vor und prüft auf werbliche Sprache. Erscheint hier inline — bald verfügbar.') }}
</span>
</div>
</div>
</section>
{{-- 5) MEDIEN (nach Speichern verfügbar) --}}
<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>
</span>
<span class="pr-bald-badge">{{ __('KI-Bildgenerierung · bald') }}</span>
</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.') }}
</p>
</div>
</div>
</section>
{{-- 6) ANHÄNGE (nach Speichern verfügbar) --}}
<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;">
{{ __('Anhänge / Downloads') }}
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
{{ __('optional, nach Speichern verfügbar') }}
</span>
</span>
</div>
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('PDF, Pressemappen und andere Dokumente kannst du nach dem ersten Speichern hinzufügen.') }}
</div>
</div>
</section>
{{-- 7) BOILERPLATE --}}
<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;">
{{ __('Über das Unternehmen') }}
<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')"
/>
</div>
@if ($selectedCompany?->boilerplate)
<div class="pr-boiler">
<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>
{{ $selectedCompany->website }}
</p>
@endif
</div>
@else
<div class="pr-boiler text-[color:var(--color-ink-3)]">
{{ __('Für diese Firma ist noch kein Boilerplate-Text hinterlegt. Du kannst entweder einen Override-Text für diese PM setzen oder das Firmenprofil ergänzen.') }}
</div>
@endif
@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:error name="boilerplateOverride" />
</div>
@endif
<p class="pr-form-help">
{{ __('Wird automatisch unter jeder Pressemitteilung dieser Firma angefügt. Pro PM editierbar.') }}
</p>
</div>
</section>
</div>
{{-- /Schreibfläche --}}
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
<aside class="space-y-4 lg:sticky lg:top-4 self-start">
{{-- Status & Absenden --}}
<article class="panel" style="border-color:var(--color-hub);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status & Absenden') }}</span>
<span class="badge muted dot">{{ __('Entwurf') }}</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">
@php
$okCount = collect($this->presubmitChecks)->where('status', 'ok')->count();
$totalCount = count($this->presubmitChecks);
@endphp
<div class="flex items-center justify-between mb-1">
<span class="eyebrow muted" style="font-size:9.5px;letter-spacing:0.16em;">
{{ __('Pre-Submit-Check') }}
</span>
<span class="text-[10.5px] font-mono font-semibold text-[color:var(--color-ok)]">
{{ $okCount }} / {{ $totalCount }} ok
</span>
</div>
@foreach ($this->presubmitChecks as $check)
<div class="pr-check-row {{ $check['status'] }}">
<span class="ic">
@if ($check['status'] === 'ok')
<flux:icon name="check" variant="micro" class="size-3" />
@elseif ($check['status'] === 'warn')
<flux:icon name="exclamation-triangle" variant="micro" class="size-3" />
@else
<flux:icon name="x-mark" variant="micro" class="size-3" />
@endif
</span>
<span class="lbl">
{{ $check['label'] }}
@if (! empty($check['sub']))
<span class="sub">{{ $check['sub'] }}</span>
@endif
</span>
</div>
@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>
<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>
<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"
>
{{ __('Als Entwurf speichern') }}
</flux:button>
</div>
</article>
</div>
<aside class="space-y-4">
{{-- Portal (Read-only) --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Metadaten') }}</span>
<span class="section-eyebrow">{{ __('Portal') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Firma') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model="companyId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
<flux:error name="companyId" />
</flux:field>
<div class="p-5">
<div class="flex items-center gap-3">
<span class="badge hub dot">{{ $selectedPortalLabel }}</span>
<span class="text-[11px] text-[color:var(--color-ink-4)]">
{{ __('automatisch aus der Firma') }}
</span>
</div>
<p class="text-[11px] text-[color:var(--color-ink-4)] mt-3 m-0 leading-[1.45]">
{{ __('Cross-Publishing auf beide Portale folgt in Phase 2.') }}
</p>
</div>
</article>
{{-- Pressekontakt --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Pressekontakt') }}</span>
</div>
<div class="p-5 space-y-3">
@if ($selectedCompanyContacts->isEmpty())
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Diese Firma hat noch keine Pressekontakte.') }}
</p>
@if ($selectedCompany)
<flux:button size="sm" variant="ghost" icon="plus" class="w-full"
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
{{ __('Kontakt im Firmenprofil anlegen') }}
</flux:button>
@endif
@else
<flux:field>
<flux:label>{{ __('Kontakt für diese PM') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model.live="contactId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($selectedCompanyContacts as $contact)
@php
$contactName = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''))
?: __('Kontakt #:n', ['n' => $contact->id]);
$contactRole = $contact->responsibility ?: __('Kontakt');
@endphp
<option value="{{ $contact->id }}">
{{ $contactName }} {{ $contactRole }}
</option>
@endforeach
</flux:select>
<flux:error name="contactId" />
</flux:field>
@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" />
<span>{{ __('Kein Telefon hinterlegt — Journalisten greifen oft direkt zum Hörer.') }}</span>
</div>
@endif
@endif
</div>
</article>
{{-- Themen-Tags + Kategorie --}}
<article class="panel">
<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
</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">
@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>
</span>
@empty
@if (count($this->tags) === 0)
<span class="text-[11.5px] text-[color:var(--color-ink-4)] italic px-1.5">
{{ __('Tag tippen + Enter…') }}
</span>
@endif
@endforelse
<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)
/>
</div>
@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>
@endif
@endforeach
</div>
</div>
@endif
<flux:field>
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model="categoryId">
<flux:select wire:model.live="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach ($categories as $cat)
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
@ -214,7 +781,56 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:error name="categoryId" />
</flux:field>
<flux:input :label="__('Portal')" :value="$selectedPortalLabel" disabled />
<p class="text-[10.5px] text-[color:var(--color-ink-4)] m-0 leading-[1.45]">
{{ __('Tags helfen bei SEO und Auffindbarkeit. Die Kategorie steuert, in welcher Rubrik die PM erscheint.') }}
</p>
</div>
</article>
{{-- Veröffentlichung --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
</div>
<div class="p-5 space-y-2">
<label class="pr-pub-opt {{ $publishMode === 'now' ? 'is-checked' : '' }}">
<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">
{{ __('Sofort nach Freigabe') }}
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('geht live, sobald die Redaktion grünes Licht gibt') }}
</span>
</span>
</label>
<span class="pr-pub-opt is-disabled">
<span class="dot-out"></span>
<span class="flex-1">
<span class="text-[12.5px] font-semibold text-[color:var(--color-ink-2)] leading-tight flex items-center gap-2">
{{ __('Geplanter Termin') }}
<span class="pr-bald-badge">{{ __('bald') }}</span>
</span>
<span class="text-[11px] text-[color:var(--color-ink-3)] block mt-0.5 leading-tight">
{{ __('Datum + Uhrzeit, automatische Veröffentlichung') }}
</span>
</span>
</span>
</div>
</article>
{{-- Weitere Felder --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Weitere Felder') }}</span>
</div>
<div class="p-5 space-y-3">
<flux:field>
<flux:label>{{ __('Backlink-URL') }}</flux:label>
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
<flux:error name="backlinkUrl" />
</flux:field>
<flux:field>
<flux:label>{{ __('Sprache') }}</flux:label>
@ -226,19 +842,22 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
{{-- Phase-2-Footer --}}
<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)]" />
<span class="eyebrow accent">{{ __('Phase 2 — bald') }}</span>
</div>
<div class="p-5 space-y-2">
<flux:button type="button" variant="primary" class="w-full" wire:click="save('review')">
{{ __('Zur Prüfung einreichen') }}
</flux:button>
<flux:button type="button" variant="ghost" class="w-full" wire:click="save('draft')">
{{ __('Als Entwurf speichern') }}
</flux:button>
</div>
</article>
<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>· {{ __('Geplante Veröffentlichung / Scheduling') }}</li>
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
</ul>
</div>
</aside>
</div>
</div>

View file

@ -5,6 +5,7 @@ use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
@ -89,13 +90,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
$pr->update([
'portal' => $this->portal,
'language' => $this->language,
'company_id' => (int) $this->companyId,
'category_id' => (int) $this->categoryId,
'title' => $this->title,
'text' => $this->text,
'text' => $cleanText,
'keywords' => $this->keywords ?: null,
'backlink_url' => $this->backlinkUrl ?: null,
]);
@ -178,7 +181,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="20" />
<flux:editor
wire:model="text"
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
/>
<flux:error name="text" />
</flux:field>

View file

@ -382,7 +382,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! nl2br(e($pr->text)) !!}
{!! $pr->renderedText() !!}
</div>
@if ($pr->keywords || $pr->backlink_url)

View file

@ -119,7 +119,7 @@
</div>
@endif
<div class="body">{!! nl2br(e($pressRelease->text)) !!}</div>
<div class="body">{!! $pressRelease->renderedText() !!}</div>
@if($pressRelease->keywords)
<p class="keywords"><strong>{{ __('Stichwörter') }}:</strong> {{ $pressRelease->keywords }}</p>