create PM v0.5
This commit is contained in:
parent
9b47296cea
commit
d2ba22c0cf
25 changed files with 2155 additions and 72 deletions
|
|
@ -310,6 +310,12 @@ components:
|
||||||
maxLength: 255
|
maxLength: 255
|
||||||
text:
|
text:
|
||||||
type: string
|
type: string
|
||||||
|
description: |
|
||||||
|
Body of the press release. Since Phase 7 (May 2026) this may
|
||||||
|
contain sanitized HTML produced by the rich-text editor
|
||||||
|
(allowed tags: p, br, h2, h3, strong, em, ul, ol, li,
|
||||||
|
blockquote, a). PMs imported before Phase 7 are plain text.
|
||||||
|
API consumers should treat the value as untrusted HTML.
|
||||||
backlink_url:
|
backlink_url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class Company extends Model
|
||||||
'website',
|
'website',
|
||||||
'logo_path',
|
'logo_path',
|
||||||
'logo_variants',
|
'logo_variants',
|
||||||
|
'boilerplate',
|
||||||
'is_active',
|
'is_active',
|
||||||
'disable_footer_code',
|
'disable_footer_code',
|
||||||
'legacy_portal',
|
'legacy_portal',
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Enums\Portal;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Models\Concerns\HasUniqueSlug;
|
use App\Models\Concerns\HasUniqueSlug;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||||
use Database\Factories\PressReleaseFactory;
|
use Database\Factories\PressReleaseFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class PressRelease extends Model
|
class PressRelease extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -45,8 +47,10 @@ class PressRelease extends Model
|
||||||
'category_id',
|
'category_id',
|
||||||
'language',
|
'language',
|
||||||
'title',
|
'title',
|
||||||
|
'subtitle',
|
||||||
'slug',
|
'slug',
|
||||||
'text',
|
'text',
|
||||||
|
'boilerplate_override',
|
||||||
'backlink_url',
|
'backlink_url',
|
||||||
'keywords',
|
'keywords',
|
||||||
'status',
|
'status',
|
||||||
|
|
@ -55,6 +59,8 @@ class PressRelease extends Model
|
||||||
'teaser_end',
|
'teaser_end',
|
||||||
'no_export',
|
'no_export',
|
||||||
'published_at',
|
'published_at',
|
||||||
|
'scheduled_at',
|
||||||
|
'embargo_at',
|
||||||
'legacy_portal',
|
'legacy_portal',
|
||||||
'legacy_id',
|
'legacy_id',
|
||||||
];
|
];
|
||||||
|
|
@ -69,6 +75,8 @@ class PressRelease extends Model
|
||||||
'teaser_end' => 'integer',
|
'teaser_end' => 'integer',
|
||||||
'no_export' => 'boolean',
|
'no_export' => 'boolean',
|
||||||
'published_at' => 'datetime',
|
'published_at' => 'datetime',
|
||||||
|
'scheduled_at' => 'datetime',
|
||||||
|
'embargo_at' => 'datetime',
|
||||||
'deleted_at' => 'datetime',
|
'deleted_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +101,11 @@ class PressRelease extends Model
|
||||||
return $this->hasMany(PressReleaseImage::class);
|
return $this->hasMany(PressReleaseImage::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PressReleaseAttachment::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function contacts(): BelongsToMany
|
public function contacts(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Contact::class, 'press_release_contact');
|
return $this->belongsToMany(Contact::class, 'press_release_contact');
|
||||||
|
|
@ -102,4 +115,18 @@ class PressRelease extends Model
|
||||||
{
|
{
|
||||||
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
|
return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-ready text. Returns sanitized HTML for Phase-7+ PMs and
|
||||||
|
* <p>/<br>-wrapped legacy plain text for older imports.
|
||||||
|
*/
|
||||||
|
public function renderedText(): HtmlString
|
||||||
|
{
|
||||||
|
return app(PressReleaseHtmlSanitizer::class)->render($this->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function plainTextLength(): int
|
||||||
|
{
|
||||||
|
return app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
app/Models/PressReleaseAttachment.php
Normal file
67
app/Models/PressReleaseAttachment.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\PressReleaseAttachmentFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PressReleaseAttachment extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PressReleaseAttachmentFactory> */
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'press_release_id',
|
||||||
|
'disk',
|
||||||
|
'path',
|
||||||
|
'original_name',
|
||||||
|
'mime',
|
||||||
|
'size',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'sort_order',
|
||||||
|
'legacy_portal',
|
||||||
|
'legacy_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'size' => 'integer',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
'deleted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pressRelease(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PressRelease::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function url(): ?string
|
||||||
|
{
|
||||||
|
if (blank($this->path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->disk === 'public') {
|
||||||
|
return asset('storage/'.ltrim((string) $this->path, '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$disk = Storage::disk($this->disk);
|
||||||
|
|
||||||
|
if (method_exists($disk, 'url')) {
|
||||||
|
return $disk->url($this->path);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/Services/PressRelease/PressReleaseHtmlSanitizer.php
Normal file
85
app/Services/PressRelease/PressReleaseHtmlSanitizer.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease;
|
||||||
|
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
use Mews\Purifier\Facades\Purifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes press-release HTML coming out of the Flux/Tiptap editor.
|
||||||
|
*
|
||||||
|
* - On save: clean() strips everything outside the press_release allowlist.
|
||||||
|
* - On display: render() returns a safe HtmlString. Legacy plain-text content
|
||||||
|
* (PMs imported before Phase 7) is detected heuristically and wrapped in
|
||||||
|
* <p>/<br> so it renders consistently next to new HTML content.
|
||||||
|
*/
|
||||||
|
class PressReleaseHtmlSanitizer
|
||||||
|
{
|
||||||
|
private const string PURIFIER_PROFILE = 'press_release';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize HTML before persisting to the database.
|
||||||
|
*/
|
||||||
|
public function clean(?string $html): string
|
||||||
|
{
|
||||||
|
if ($html === null || trim($html) === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) Purifier::clean($html, self::PURIFIER_PROFILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the stored text is already HTML (Phase 7+) or
|
||||||
|
* legacy plain text from older imports.
|
||||||
|
*/
|
||||||
|
public function isHtml(?string $text): bool
|
||||||
|
{
|
||||||
|
if ($text === null || $text === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) preg_match('/<(p|br|h2|h3|strong|em|ul|ol|li|blockquote|a)\b[^>]*>/i', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a display-ready, safe HtmlString.
|
||||||
|
*/
|
||||||
|
public function render(?string $text): HtmlString
|
||||||
|
{
|
||||||
|
if ($text === null || trim($text) === '') {
|
||||||
|
return new HtmlString('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isHtml($text)) {
|
||||||
|
return new HtmlString($this->clean($text));
|
||||||
|
}
|
||||||
|
|
||||||
|
$escaped = e($text);
|
||||||
|
$withBreaks = nl2br($escaped, false);
|
||||||
|
$paragraphs = preg_split('/(?:<br\s*\/?>\s*){2,}/i', $withBreaks) ?: [$withBreaks];
|
||||||
|
|
||||||
|
$html = collect($paragraphs)
|
||||||
|
->map(fn (string $chunk): string => trim($chunk))
|
||||||
|
->filter()
|
||||||
|
->map(fn (string $chunk): string => '<p>'.$chunk.'</p>')
|
||||||
|
->implode('');
|
||||||
|
|
||||||
|
return new HtmlString($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-text length for character counters (without HTML noise).
|
||||||
|
*/
|
||||||
|
public function plainTextLength(?string $text): int
|
||||||
|
{
|
||||||
|
if ($text === null || $text === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripped = strip_tags($text);
|
||||||
|
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
return mb_strlen(trim((string) preg_replace('/\s+/u', ' ', $decoded)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"livewire/flux": "^2.1.1",
|
"livewire/flux": "^2.1.1",
|
||||||
"livewire/flux-pro": "^2.1",
|
"livewire/flux-pro": "^2.1",
|
||||||
"livewire/volt": "^1.7.0",
|
"livewire/volt": "^1.7.0",
|
||||||
|
"mews/purifier": "^3.4",
|
||||||
"spatie/laravel-permission": "^6.17"
|
"spatie/laravel-permission": "^6.17"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
|
||||||
141
composer.lock
generated
141
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7e9069d62aef1b99ea677b768afeda3f",
|
"content-hash": "cbc29fc1cf64ca319c7c0ef7e0c1088c",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
|
|
@ -763,6 +763,67 @@
|
||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ezyang/htmlpurifier",
|
||||||
|
"version": "v4.19.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/ezyang/htmlpurifier.git",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"cerdic/css-tidy": "^1.7 || ^2.0",
|
||||||
|
"simpletest/simpletest": "dev-master"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
|
||||||
|
"ext-bcmath": "Used for unit conversion and imagecrash protection",
|
||||||
|
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
|
||||||
|
"ext-tidy": "Used for pretty-printing HTML"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"library/HTMLPurifier.composer.php"
|
||||||
|
],
|
||||||
|
"psr-0": {
|
||||||
|
"HTMLPurifier": "library/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/library/HTMLPurifier/Language/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"LGPL-2.1-or-later"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Edward Z. Yang",
|
||||||
|
"email": "admin@htmlpurifier.org",
|
||||||
|
"homepage": "http://ezyang.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Standards compliant HTML filter written in PHP",
|
||||||
|
"homepage": "http://htmlpurifier.org/",
|
||||||
|
"keywords": [
|
||||||
|
"html"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/ezyang/htmlpurifier/issues",
|
||||||
|
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
|
||||||
|
},
|
||||||
|
"time": "2025-10-17T16:34:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "fruitcake/php-cors",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
|
|
@ -2687,6 +2748,84 @@
|
||||||
},
|
},
|
||||||
"time": "2026-03-18T14:16:30+00:00"
|
"time": "2026-03-18T14:16:30+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mews/purifier",
|
||||||
|
"version": "3.4.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/mewebstudio/Purifier.git",
|
||||||
|
"reference": "b2705cc6c832ce7229373418e191d71b6c037841"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/mewebstudio/Purifier/zipball/b2705cc6c832ce7229373418e191d71b6c037841",
|
||||||
|
"reference": "b2705cc6c832ce7229373418e191d71b6c037841",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ezyang/htmlpurifier": "^4.16.0",
|
||||||
|
"illuminate/config": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/filesystem": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"php": "^7.2|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"graham-campbell/testbench": "^3.2|^5.5.1|^6.1",
|
||||||
|
"mockery/mockery": "^1.3.3",
|
||||||
|
"phpunit/phpunit": "^8.0|^9.0|^10.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"laravel/framework": "To test the Laravel bindings",
|
||||||
|
"laravel/lumen-framework": "To test the Lumen bindings"
|
||||||
|
},
|
||||||
|
"type": "package",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Purifier": "Mews\\Purifier\\Facades\\Purifier"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Mews\\Purifier\\PurifierServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Mews\\Purifier\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Muharrem ERİN",
|
||||||
|
"email": "me@mewebstudio.com",
|
||||||
|
"homepage": "https://github.com/mewebstudio",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel 5/6/7/8/9/10 HtmlPurifier Package",
|
||||||
|
"homepage": "https://github.com/mewebstudio/purifier",
|
||||||
|
"keywords": [
|
||||||
|
"Laravel Purifier",
|
||||||
|
"Laravel Security",
|
||||||
|
"Purifier",
|
||||||
|
"htmlpurifier",
|
||||||
|
"laravel HtmlPurifier",
|
||||||
|
"security",
|
||||||
|
"xss"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/mewebstudio/Purifier/issues",
|
||||||
|
"source": "https://github.com/mewebstudio/Purifier/tree/3.4.4"
|
||||||
|
},
|
||||||
|
"time": "2026-04-15T16:41:08+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
|
|
|
||||||
127
config/purifier.php
Normal file
127
config/purifier.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ok, glad you are here
|
||||||
|
* first we get a config instance, and set the settings
|
||||||
|
* $config = HTMLPurifier_Config::createDefault();
|
||||||
|
* $config->set('Core.Encoding', $this->config->get('purifier.encoding'));
|
||||||
|
* $config->set('Cache.SerializerPath', $this->config->get('purifier.cachePath'));
|
||||||
|
* if ( ! $this->config->get('purifier.finalize')) {
|
||||||
|
* $config->autoFinalize = false;
|
||||||
|
* }
|
||||||
|
* $config->loadArray($this->getConfig());
|
||||||
|
*
|
||||||
|
* You must NOT delete the default settings
|
||||||
|
* anything in settings should be compacted with params that needed to instance HTMLPurifier_Config.
|
||||||
|
*
|
||||||
|
* @link http://htmlpurifier.org/live/configdoc/plain.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'encoding' => 'UTF-8',
|
||||||
|
'finalize' => true,
|
||||||
|
'ignoreNonStrings' => false,
|
||||||
|
'cachePath' => storage_path('app/purifier'),
|
||||||
|
'cacheFileMode' => 0755,
|
||||||
|
'settings' => [
|
||||||
|
'default' => [
|
||||||
|
'HTML.Doctype' => 'HTML 4.01 Transitional',
|
||||||
|
'HTML.Allowed' => 'div,b,strong,i,em,u,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src]',
|
||||||
|
'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align',
|
||||||
|
'AutoFormat.AutoParagraph' => true,
|
||||||
|
'AutoFormat.RemoveEmpty' => true,
|
||||||
|
],
|
||||||
|
/*
|
||||||
|
* Press-Release-Editor (Phase 7).
|
||||||
|
* Tight allowlist matching the reduced flux:editor toolbar:
|
||||||
|
* heading (h2,h3) | bold italic | bullet ordered blockquote | link.
|
||||||
|
* Links open in a new tab with safe rel attributes.
|
||||||
|
*/
|
||||||
|
'press_release' => [
|
||||||
|
'HTML.Doctype' => 'HTML 4.01 Transitional',
|
||||||
|
'HTML.Allowed' => 'p,br,h2,h3,strong,em,ul,ol,li,blockquote,a[href|title|target|rel]',
|
||||||
|
'HTML.TargetBlank' => true,
|
||||||
|
'HTML.Nofollow' => true,
|
||||||
|
'AutoFormat.AutoParagraph' => true,
|
||||||
|
'AutoFormat.RemoveEmpty' => true,
|
||||||
|
'URI.AllowedSchemes' => [
|
||||||
|
'http' => true,
|
||||||
|
'https' => true,
|
||||||
|
'mailto' => true,
|
||||||
|
'tel' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'test' => [
|
||||||
|
'Attr.EnableID' => 'true',
|
||||||
|
],
|
||||||
|
'youtube' => [
|
||||||
|
'HTML.SafeIframe' => 'true',
|
||||||
|
'URI.SafeIframeRegexp' => '%^(http://|https://|//)(www.youtube.com/embed/|player.vimeo.com/video/)%',
|
||||||
|
],
|
||||||
|
'custom_definition' => [
|
||||||
|
'id' => 'html5-definitions',
|
||||||
|
'rev' => 1,
|
||||||
|
'debug' => false,
|
||||||
|
'elements' => [
|
||||||
|
// http://developers.whatwg.org/sections.html
|
||||||
|
['section', 'Block', 'Flow', 'Common'],
|
||||||
|
['nav', 'Block', 'Flow', 'Common'],
|
||||||
|
['article', 'Block', 'Flow', 'Common'],
|
||||||
|
['aside', 'Block', 'Flow', 'Common'],
|
||||||
|
['header', 'Block', 'Flow', 'Common'],
|
||||||
|
['footer', 'Block', 'Flow', 'Common'],
|
||||||
|
|
||||||
|
// Content model actually excludes several tags, not modelled here
|
||||||
|
['address', 'Block', 'Flow', 'Common'],
|
||||||
|
['hgroup', 'Block', 'Required: h1 | h2 | h3 | h4 | h5 | h6', 'Common'],
|
||||||
|
|
||||||
|
// http://developers.whatwg.org/grouping-content.html
|
||||||
|
['figure', 'Block', 'Optional: (figcaption, Flow) | (Flow, figcaption) | Flow', 'Common'],
|
||||||
|
['figcaption', 'Inline', 'Flow', 'Common'],
|
||||||
|
|
||||||
|
// http://developers.whatwg.org/the-video-element.html#the-video-element
|
||||||
|
['video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [
|
||||||
|
'src' => 'URI',
|
||||||
|
'type' => 'Text',
|
||||||
|
'width' => 'Length',
|
||||||
|
'height' => 'Length',
|
||||||
|
'poster' => 'URI',
|
||||||
|
'preload' => 'Enum#auto,metadata,none',
|
||||||
|
'controls' => 'Bool',
|
||||||
|
]],
|
||||||
|
['source', 'Block', 'Flow', 'Common', [
|
||||||
|
'src' => 'URI',
|
||||||
|
'type' => 'Text',
|
||||||
|
]],
|
||||||
|
|
||||||
|
// http://developers.whatwg.org/text-level-semantics.html
|
||||||
|
['s', 'Inline', 'Inline', 'Common'],
|
||||||
|
['var', 'Inline', 'Inline', 'Common'],
|
||||||
|
['sub', 'Inline', 'Inline', 'Common'],
|
||||||
|
['sup', 'Inline', 'Inline', 'Common'],
|
||||||
|
['mark', 'Inline', 'Inline', 'Common'],
|
||||||
|
['wbr', 'Inline', 'Empty', 'Core'],
|
||||||
|
|
||||||
|
// http://developers.whatwg.org/edits.html
|
||||||
|
['ins', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
|
||||||
|
['del', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']],
|
||||||
|
],
|
||||||
|
'attributes' => [
|
||||||
|
['iframe', 'allowfullscreen', 'Bool'],
|
||||||
|
['table', 'height', 'Text'],
|
||||||
|
['td', 'border', 'Text'],
|
||||||
|
['th', 'border', 'Text'],
|
||||||
|
['tr', 'width', 'Text'],
|
||||||
|
['tr', 'height', 'Text'],
|
||||||
|
['tr', 'border', 'Text'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'custom_attributes' => [
|
||||||
|
['a', 'target', 'Enum#_blank,_self,_target,_top'],
|
||||||
|
],
|
||||||
|
'custom_elements' => [
|
||||||
|
['u', 'Inline', 'Inline', 'Common'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
33
database/factories/PressReleaseAttachmentFactory.php
Normal file
33
database/factories/PressReleaseAttachmentFactory.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseAttachment;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<PressReleaseAttachment>
|
||||||
|
*/
|
||||||
|
class PressReleaseAttachmentFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$name = $this->faker->slug(2).'.pdf';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'press_release_id' => PressRelease::factory(),
|
||||||
|
'disk' => 'public',
|
||||||
|
'path' => 'press-release-attachments/dummy/'.$name,
|
||||||
|
'original_name' => $name,
|
||||||
|
'mime' => 'application/pdf',
|
||||||
|
'size' => $this->faker->numberBetween(50_000, 5_000_000),
|
||||||
|
'title' => $this->faker->optional()->sentence(3),
|
||||||
|
'description' => $this->faker->optional()->sentence(),
|
||||||
|
'sort_order' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('companies', function (Blueprint $table) {
|
||||||
|
$table->text('boilerplate')->nullable()->after('website');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('companies', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('boilerplate');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table) {
|
||||||
|
$table->string('subtitle', 255)->nullable()->after('title');
|
||||||
|
$table->text('boilerplate_override')->nullable()->after('text');
|
||||||
|
$table->timestamp('scheduled_at')->nullable()->after('published_at');
|
||||||
|
$table->timestamp('embargo_at')->nullable()->after('scheduled_at');
|
||||||
|
|
||||||
|
$table->index('scheduled_at', 'press_releases_scheduled_at_idx');
|
||||||
|
$table->index('embargo_at', 'press_releases_embargo_at_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('press_releases', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('press_releases_scheduled_at_idx');
|
||||||
|
$table->dropIndex('press_releases_embargo_at_idx');
|
||||||
|
$table->dropColumn(['subtitle', 'boilerplate_override', 'scheduled_at', 'embargo_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('press_release_attachments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('press_release_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('disk', 30)->default('public');
|
||||||
|
$table->string('path', 512);
|
||||||
|
$table->string('original_name', 255);
|
||||||
|
$table->string('mime', 100)->nullable();
|
||||||
|
$table->unsignedBigInteger('size')->nullable();
|
||||||
|
$table->string('title', 120)->nullable();
|
||||||
|
$table->string('description', 500)->nullable();
|
||||||
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->enum('legacy_portal', [
|
||||||
|
Portal::Presseecho->value,
|
||||||
|
Portal::Businessportal24->value,
|
||||||
|
])->nullable();
|
||||||
|
$table->unsignedBigInteger('legacy_id')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->unique(['legacy_portal', 'legacy_id'], 'press_release_attachments_legacy_portal_legacy_id_unique');
|
||||||
|
$table->index(['press_release_id', 'sort_order'], 'press_release_attachments_release_sort_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('press_release_attachments');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -219,6 +219,14 @@ aber:
|
||||||
> Die hub-flux-Roadmap ist mit Phase 6 **vollständig** abgeschlossen.
|
> Die hub-flux-Roadmap ist mit Phase 6 **vollständig** abgeschlossen.
|
||||||
> Alle weiteren Themen sind eigene Initiativen.
|
> Alle weiteren Themen sind eigene Initiativen.
|
||||||
|
|
||||||
|
**🟡 In Planung — Phase 7 (Press-Release-Form-Refactor):**
|
||||||
|
Mockup `User Neue Mitteilung presseportale.html` wird auf den
|
||||||
|
Customer-Create/Edit-Flow übertragen. Plan-Doc:
|
||||||
|
`19-PHASE-7-PRESS-RELEASE-FORM.md`.
|
||||||
|
Päckchen 7A (Migrations) → 7B (flux:editor + Sanitizer) →
|
||||||
|
7C (Customer-Create-UI) → 7D (Customer-Edit-UI) →
|
||||||
|
7E (Anhänge-Manager) → 7F (Scheduling/Embargo, optional).
|
||||||
|
|
||||||
1. **Manueller Dark-Mode Smoke-Test**: Im Browser User-Menü →
|
1. **Manueller Dark-Mode Smoke-Test**: Im Browser User-Menü →
|
||||||
Erscheinung → „Dunkel" und durch die Hauptseiten klicken
|
Erscheinung → „Dunkel" und durch die Hauptseiten klicken
|
||||||
(Dashboard, Listen, Detail, Security mit QR, Tokens). Erwartung:
|
(Dashboard, Listen, Detail, Security mit QR, Tokens). Erwartung:
|
||||||
|
|
|
||||||
240
dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md
Normal file
240
dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Phase 7 — Press-Release-Form-Refactor
|
||||||
|
|
||||||
|
> Großes Modul-Refactor: das zentrale „Neue Pressemitteilung"-Form
|
||||||
|
> wird auf das Mockup `User Neue Mitteilung presseportale.html` gehoben.
|
||||||
|
> Bekommt deshalb eine eigene Phase außerhalb der bisherigen
|
||||||
|
> `hub-flux`-Roadmap (Phase 0–6 sind dort abgeschlossen).
|
||||||
|
|
||||||
|
**Status**: 🟡 in Planung · **Aufwand**: 2–3 Tage · **Risiko**: mittel
|
||||||
|
(Datenmodell-Erweiterung, Editor-Format-Migration, Composer-Dependency)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
|
||||||
|
| Datei | Status |
|
||||||
|
|---|---|
|
||||||
|
| `resources/views/livewire/customer/press-releases/create.blade.php` | nur 1-Spalter, `<flux:textarea>`, fehlende Felder |
|
||||||
|
| `resources/views/livewire/customer/press-releases/edit.blade.php` | gleicher Stand, plus Image-Manager |
|
||||||
|
| `resources/views/livewire/admin/press-releases/create.blade.php` | Admin-Variante, dünner |
|
||||||
|
| `resources/views/livewire/admin/press-releases/edit.blade.php` | Admin-Variante |
|
||||||
|
|
||||||
|
Das Mockup verlangt einen 2-Spalter mit eigener
|
||||||
|
Settings-Sidebar (Status & Submit, Portal, Pressekontakt, Tags,
|
||||||
|
Veröffentlichung, SEO). Linke Spalte: Firma-Selector, Titel,
|
||||||
|
Untertitel, Editor, Medien, Anhänge, Boilerplate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidungen (vom User abgesegnet)
|
||||||
|
|
||||||
|
| Frage | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| Scope | **full** — Anhänge-Tabelle + Schema-Vorbereitung für Scheduling/Embargo |
|
||||||
|
| Portal-Auswahl | **read-only** — Portal kommt immer aus der Firma; UI zeigt nur Badge |
|
||||||
|
| HTML-Sanitizer | **`mews/purifier`** — explizit approved, wird in 7B installiert |
|
||||||
|
| Pressekontakt | **genau 1 pro PM**, Single-Select aus Firmen-Kontakten; Datenmodell bleibt n:m-Pivot, Validation erzwingt `count == 1` |
|
||||||
|
| Admin-Forms | **mitziehen** in 7C+7D, gleiches Layout + Admin-only Felder |
|
||||||
|
| Default-Kontakt | **erster Firmen-Kontakt alphabetisch** (keine Schema-Änderung) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Päckchen-Aufteilung
|
||||||
|
|
||||||
|
### 7A — Migrations + Models
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- `press_releases.subtitle` (string 255, nullable)
|
||||||
|
- `press_releases.boilerplate_override` (text, nullable) — pro PM überschreibbare Firmen-Boilerplate
|
||||||
|
- `press_releases.scheduled_at` (timestamp, nullable) — Schema da, UI „bald"
|
||||||
|
- `press_releases.embargo_at` (timestamp, nullable) — Schema da, UI „bald"
|
||||||
|
- `companies.boilerplate` (text, nullable) — Firmenprofil-Boilerplate
|
||||||
|
- Neue Tabelle `press_release_attachments` analog `press_release_images`
|
||||||
|
(`disk`, `path`, `original_name`, `mime`, `size`, `sort_order`,
|
||||||
|
`title`, `description`, `legacy_portal`, `legacy_id`, soft-deletes,
|
||||||
|
timestamps).
|
||||||
|
- Models: `PressRelease`, `Company`, neues `PressReleaseAttachment`
|
||||||
|
+ Factory + Relationen + Casts.
|
||||||
|
- Bestehende Tests müssen grün bleiben (alle Felder nullable,
|
||||||
|
keine Verhaltensänderung).
|
||||||
|
|
||||||
|
**Akzeptanz**
|
||||||
|
- [ ] Migration up/down sauber
|
||||||
|
- [ ] `php artisan test --compact` grün (Baseline)
|
||||||
|
- [ ] `php artisan db:show press_releases / companies / press_release_attachments` zeigt neue Spalten
|
||||||
|
|
||||||
|
### 7B — Editor-Integration
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- `composer require mews/purifier` (explizite Approval einholen)
|
||||||
|
- Service `App\Services\PressRelease\PressReleaseHtmlSanitizer`
|
||||||
|
(Allowlist: `p,br,h2,h3,strong,em,u,ul,ol,li,blockquote,a[href|rel|target]`)
|
||||||
|
- Create/Edit-Form: `<flux:textarea>` → `<flux:editor>` mit
|
||||||
|
reduzierter Toolbar:
|
||||||
|
```
|
||||||
|
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
|
||||||
|
```
|
||||||
|
- Save-Pfad: `$this->text = $sanitizer->clean($this->text)`
|
||||||
|
- Display-Pfad (`show.blade.php`, Portal-Detail-Seiten,
|
||||||
|
`PressReleaseResource`):
|
||||||
|
- Wenn `text` HTML-Tags enthält → `{!! $clean !!}` (sanitized)
|
||||||
|
- Wenn nicht (legacy) → `{!! nl2br(e($text)) !!}`
|
||||||
|
- Helper `PressRelease::renderedText(): HtmlString`
|
||||||
|
- API: `PressReleaseResource` liefert weiterhin String (HTML),
|
||||||
|
Doku-Update `_docs/api/v1.yml` und `dev/migration 2026/07-API-MIGRATION.md`.
|
||||||
|
|
||||||
|
**Akzeptanz**
|
||||||
|
- [ ] `mews/purifier` in `composer.json`
|
||||||
|
- [ ] Alle bestehenden Plain-Text-PMs werden korrekt angezeigt
|
||||||
|
- [ ] Neue HTML-PMs werden korrekt sanitized gespeichert
|
||||||
|
- [ ] Pest-Test: `<script>`-Tag wird bei Save gestrippt
|
||||||
|
- [ ] Pest-Test: legacy plain text → `<p>`/`<br>` Rendering
|
||||||
|
|
||||||
|
### 7C — Customer-Create-Form (UI)
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Page-Header wie Mockup (Crumb-Trail + Topbar mit Autosave-Status,
|
||||||
|
„Speichern", „Vorschau-bald")
|
||||||
|
- 2-Spalter `grid-cols-[1fr,360px]`
|
||||||
|
- Linke Spalte (Schreibfläche):
|
||||||
|
1. **Firma-Selector** als kompakte Inline-Pille (`flux:dropdown`)
|
||||||
|
mit „Neue Firma anlegen"-Link
|
||||||
|
2. **Titel** (`<flux:input>` mit Title-Font, Counter-Pille
|
||||||
|
`meter good/warn` 40–90 Zeichen empfohlen)
|
||||||
|
3. **Untertitel** (optional, Counter-Pille 0/200)
|
||||||
|
4. **Fließtext** (`<flux:editor>` mit reduzierter Toolbar,
|
||||||
|
Counter-Pille 600–3500 Z., KI-Lektorat-bald-Hint)
|
||||||
|
5. **Medien** (bestehende `press-release-images-manager`-Komponente
|
||||||
|
im neuen Tile-Style anpassen — Titelbild-Flag,
|
||||||
|
Caption/Alt-Text Pflicht-Hint)
|
||||||
|
6. **Anhänge** (neue Livewire-Komponente in 7E)
|
||||||
|
7. **Boilerplate** (Read-only-Box mit Toggle „Für diese PM
|
||||||
|
überschreiben" → öffnet `<flux:textarea>`)
|
||||||
|
- Rechte Spalte (Settings-Sidebar, sticky):
|
||||||
|
1. **Status & Absenden** (Pre-Submit-Checkliste aus
|
||||||
|
Pflichtfeldern, primärer Submit „Zur Prüfung senden")
|
||||||
|
2. **Portal** (read-only-Badge aus Firma)
|
||||||
|
3. **Pressekontakt** (Single-Select aus Firmen-Kontakten,
|
||||||
|
Pflichtfeld, Warn-Hint falls Telefon fehlt)
|
||||||
|
4. **Themen-Tags** (Chip-Eingabe, parst `keywords` als
|
||||||
|
Komma-getrennt, max 5 Tags, Vorschläge aus Firma)
|
||||||
|
5. **Veröffentlichung** (RadioGroup: „Sofort nach Freigabe"
|
||||||
|
aktiv, „Geplanter Termin" als `bald`-Badge)
|
||||||
|
6. **SEO** (Collapsed, „automatisch aus Titel" als `bald`-Hint)
|
||||||
|
7. **Phase-2-Footer-Card** wie im Mockup
|
||||||
|
- Validation:
|
||||||
|
- `title`: required, 5–255
|
||||||
|
- `subtitle`: nullable, max 255
|
||||||
|
- `text`: required, min 50 (HTML-stripped)
|
||||||
|
- `company_id`: required, muss zur User-Firma gehören
|
||||||
|
- `category_id`: required
|
||||||
|
- `contact_id`: required, muss zur Firma gehören
|
||||||
|
- `keywords`: nullable, max 5 Tags
|
||||||
|
- `boilerplate_override`: nullable
|
||||||
|
- Pre-Submit-Check (Read-only-Anzeige, blockiert nicht):
|
||||||
|
- Titel vorhanden + Länge ok → ok
|
||||||
|
- Fließtext min 600 Z. → ok / sonst warn
|
||||||
|
- Firma gewählt → ok
|
||||||
|
- Mind. 1 Bild + Titelbild gesetzt → ok / sonst warn
|
||||||
|
- Mind. 1 Tag → ok / sonst warn
|
||||||
|
- Pressekontakt mit Telefon → ok / sonst warn
|
||||||
|
|
||||||
|
**Was NICHT ins 7C kommt:**
|
||||||
|
- Anhänge-Manager UI (kommt in 7E)
|
||||||
|
- Scheduling/Embargo-Logik (kommt in 7F)
|
||||||
|
- Autosave („alle paar Sek.") — erstmal nur Status-Anzeige
|
||||||
|
(„Manuell gespeichert vor X") via Livewire-Event nach `save()`;
|
||||||
|
echtes Autosave optional in 7F
|
||||||
|
|
||||||
|
**Akzeptanz**
|
||||||
|
- [ ] Bestehender Test `CustomerCompanyContextTest > customer press releases create …` grün
|
||||||
|
- [ ] Neuer Test: Create mit allen Pflichtfeldern → PR persistiert
|
||||||
|
- [ ] Neuer Test: Create ohne Pressekontakt → Validation-Fehler
|
||||||
|
- [ ] Pint + Build grün
|
||||||
|
|
||||||
|
### 7D — Customer-Edit-Form
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Gleiches Layout wie 7C
|
||||||
|
- Submit-Sektion zeigt aktuellen Status (Entwurf/Abgelehnt) +
|
||||||
|
ggf. letzten Reject-Grund
|
||||||
|
- Image-Manager und Attachments-Manager weiterhin verfügbar
|
||||||
|
- Form ist nur editierbar, wenn `status in [draft, rejected]`
|
||||||
|
(bestehende Policy bleibt)
|
||||||
|
|
||||||
|
**Akzeptanz**
|
||||||
|
- [ ] Edit-Test (Update aller neuen Felder) grün
|
||||||
|
- [ ] Reject-Reason-Anzeige bleibt sichtbar
|
||||||
|
- [ ] Bestehende Tests bleiben grün
|
||||||
|
|
||||||
|
### 7E — Anhänge-Manager
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- Neue Volt-Komponente
|
||||||
|
`resources/views/livewire/components/press-release-attachments-manager.blade.php`
|
||||||
|
analog `press-release-images-manager.blade.php`
|
||||||
|
- Methoden: `upload`, `remove`, `moveUp`, `moveDown`
|
||||||
|
- Storage via `Storage::disk('public')` unter
|
||||||
|
`press-release-attachments/{press_release_id}/`
|
||||||
|
- Validation: PDF/DOCX/XLSX/PPTX, max. 25 MB pro Datei
|
||||||
|
- Tile-Layout wie im Mockup (PDF-Badge + Filename + Größe + Aktionen)
|
||||||
|
- Berechtigung wie beim Image-Manager (Policy `update` auf PR
|
||||||
|
+ Status `draft|rejected` für Customer)
|
||||||
|
|
||||||
|
**Akzeptanz**
|
||||||
|
- [ ] Upload-Test
|
||||||
|
- [ ] Remove-Test
|
||||||
|
- [ ] Reorder-Test
|
||||||
|
- [ ] Datei-Type-Validation grün
|
||||||
|
|
||||||
|
### 7F — Scheduling + Embargo (Schema vorhanden, UI aktivieren)
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
- UI-Elemente aus 7C (Veröffentlichung-RadioGroup,
|
||||||
|
Embargo-Checkbox + Date-Picker) aktivieren
|
||||||
|
- `bald`-Badges entfernen
|
||||||
|
- Service-Logik in `PressReleaseService`:
|
||||||
|
- bei `submitForReview` mit `scheduled_at` → Status bleibt
|
||||||
|
`review`, beim `publish` durch Admin/Job wird `published_at`
|
||||||
|
auf `scheduled_at` gesetzt
|
||||||
|
- bei `embargo_at` → öffentliche Anzeige erst ab `embargo_at`
|
||||||
|
- Background-Job (`PublishScheduledPressReleases`) für die
|
||||||
|
Veröffentlichung um `scheduled_at`
|
||||||
|
- Test: Geplante PM wird nicht vor Termin öffentlich
|
||||||
|
- Test: Embargo-Filter im Portal-Index
|
||||||
|
|
||||||
|
**Hinweis:** 7F ist optional und kann nach 7C/D/E in eine eigene
|
||||||
|
kleine Sub-Phase ausgelagert werden, weil es einen Background-Job
|
||||||
|
einführt und der Test-Umfang separat sauber bleibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risiken & Mitigation
|
||||||
|
|
||||||
|
| Risiko | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Editor-HTML bricht alte Anzeigen | Helper `renderedText()` mit Fallback auf `nl2br(e(…))` |
|
||||||
|
| Composer-Dependency `mews/purifier` ungeprüft | Vor Install explizite User-Approval; alternative `decide_later` möglich |
|
||||||
|
| Pressekontakt-Pivot mit > 1 Eintrag in DB | Migrations-Skript prüft & loggt; Validation in Save erzwingt 1 |
|
||||||
|
| Test-Suite-Regression | Jedes Päckchen einzeln + `php artisan test --compact` zwischen den Päckchen |
|
||||||
|
|
||||||
|
## Out of Scope (bewusst nicht in Phase 7)
|
||||||
|
|
||||||
|
- KI-Titel-Optimierung (Phase 8)
|
||||||
|
- KI-Lektorat (Phase 8)
|
||||||
|
- KI-Bildgenerierung (Phase 8)
|
||||||
|
- Mehrfach-Portal-Publishing (Phase 2 laut Mockup-Disclaimer)
|
||||||
|
- Versionshistorie (Phase 2 laut Mockup-Disclaimer)
|
||||||
|
- Portal-Vorschau (Phase 2)
|
||||||
|
- Echtes Autosave alle paar Sekunden (Polish, in 7F optional)
|
||||||
|
|
||||||
|
## Reihenfolge
|
||||||
|
|
||||||
|
1. **7A** Migrations + Models
|
||||||
|
2. **7B** Editor + Sanitizer
|
||||||
|
3. **7C** Customer-Create-Form
|
||||||
|
4. **7D** Customer-Edit-Form
|
||||||
|
5. **7E** Anhänge-Manager
|
||||||
|
6. **7F** Scheduling/Embargo (optional, eigene Sub-Phase)
|
||||||
|
|
||||||
|
Nach jedem Päckchen: User-Approval + Test-Run + Build + Pint + PROGRESS-Update.
|
||||||
|
|
@ -697,3 +697,264 @@
|
||||||
padding-inline-end: 18px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Services\Admin\AdminPerformanceCache;
|
use App\Services\Admin\AdminPerformanceCache;
|
||||||
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -69,6 +70,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
'language' => $this->language,
|
'language' => $this->language,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
|
||||||
|
|
||||||
$pr = PressRelease::query()->create([
|
$pr = PressRelease::query()->create([
|
||||||
'uuid' => (string) Str::uuid(),
|
'uuid' => (string) Str::uuid(),
|
||||||
'portal' => $this->portal,
|
'portal' => $this->portal,
|
||||||
|
|
@ -78,7 +81,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
'category_id' => (int) $this->categoryId,
|
'category_id' => (int) $this->categoryId,
|
||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'text' => $this->text,
|
'text' => $cleanText,
|
||||||
'keywords' => $this->keywords ?: null,
|
'keywords' => $this->keywords ?: null,
|
||||||
'backlink_url' => $this->backlinkUrl ?: null,
|
'backlink_url' => $this->backlinkUrl ?: null,
|
||||||
'status' => $status->value,
|
'status' => $status->value,
|
||||||
|
|
@ -176,7 +179,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
|
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
<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:error name="text" />
|
||||||
</flux:field>
|
</flux:field>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Services\Admin\AdminPerformanceCache;
|
use App\Services\Admin\AdminPerformanceCache;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
@ -95,6 +96,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
||||||
$slug = $pr->slug;
|
$slug = $pr->slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
|
||||||
|
|
||||||
$pr->update([
|
$pr->update([
|
||||||
'portal' => $this->portal,
|
'portal' => $this->portal,
|
||||||
'language' => $this->language,
|
'language' => $this->language,
|
||||||
|
|
@ -102,7 +105,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
||||||
'category_id' => (int) $this->categoryId,
|
'category_id' => (int) $this->categoryId,
|
||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'text' => $this->text,
|
'text' => $cleanText,
|
||||||
'keywords' => $this->keywords ?: null,
|
'keywords' => $this->keywords ?: null,
|
||||||
'backlink_url' => $this->backlinkUrl ?: null,
|
'backlink_url' => $this->backlinkUrl ?: null,
|
||||||
'no_export' => $this->noExport,
|
'no_export' => $this->noExport,
|
||||||
|
|
@ -327,7 +330,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
||||||
|
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
<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:error name="text" />
|
||||||
</flux:field>
|
</flux:field>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
|
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
|
||||||
{!! nl2br(e($pr->text)) !!}
|
{!! $pr->renderedText() !!}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ use App\Enums\Portal;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Services\Customer\CustomerCompanyContext;
|
use App\Services\Customer\CustomerCompanyContext;
|
||||||
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Volt\Component;
|
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 $categoryId = null;
|
||||||
|
|
||||||
|
public int|string|null $contactId = null;
|
||||||
|
|
||||||
public string $title = '';
|
public string $title = '';
|
||||||
|
|
||||||
|
public string $subtitle = '';
|
||||||
|
|
||||||
public string $text = '';
|
public string $text = '';
|
||||||
|
|
||||||
public string $keywords = '';
|
public string $keywords = '';
|
||||||
|
|
||||||
public string $backlinkUrl = '';
|
public string $backlinkUrl = '';
|
||||||
|
|
||||||
|
public string $boilerplateOverride = '';
|
||||||
|
|
||||||
|
public bool $useBoilerplateOverride = false;
|
||||||
|
|
||||||
|
public string $publishMode = 'now';
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
@ -39,19 +53,74 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
if ($firstCompany) {
|
if ($firstCompany) {
|
||||||
$this->companyId = $firstCompany->id;
|
$this->companyId = $firstCompany->id;
|
||||||
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
|
$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
|
public function save(string $submitStatus = 'draft'): void
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'language' => ['required', Rule::in(['de', 'en'])],
|
'language' => ['required', Rule::in(['de', 'en'])],
|
||||||
'companyId' => ['required', 'integer'],
|
'companyId' => ['required', 'integer'],
|
||||||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||||||
|
'contactId' => ['required', 'integer'],
|
||||||
'title' => ['required', 'string', 'min:5', 'max:255'],
|
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||||||
|
'subtitle' => ['nullable', 'string', 'max:255'],
|
||||||
'text' => ['required', 'string', 'min:50'],
|
'text' => ['required', 'string', 'min:50'],
|
||||||
'keywords' => ['nullable', 'string', 'max:255'],
|
'keywords' => ['nullable', 'string', 'max:255'],
|
||||||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||||||
|
'boilerplateOverride' => ['nullable', 'string', 'max:5000'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
@ -63,6 +132,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
return;
|
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;
|
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||||||
|
|
||||||
$status = $submitStatus === 'review' ? PressReleaseStatus::Review : PressReleaseStatus::Draft;
|
$status = $submitStatus === 'review' ? PressReleaseStatus::Review : PressReleaseStatus::Draft;
|
||||||
|
|
@ -72,6 +149,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
'language' => $this->language,
|
'language' => $this->language,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
|
||||||
|
|
||||||
$pr = PressRelease::query()->create([
|
$pr = PressRelease::query()->create([
|
||||||
'uuid' => (string) Str::uuid(),
|
'uuid' => (string) Str::uuid(),
|
||||||
'portal' => $this->portal,
|
'portal' => $this->portal,
|
||||||
|
|
@ -80,13 +159,19 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
'company_id' => (int) $this->companyId,
|
'company_id' => (int) $this->companyId,
|
||||||
'category_id' => (int) $this->categoryId,
|
'category_id' => (int) $this->categoryId,
|
||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
|
'subtitle' => trim($this->subtitle) ?: null,
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'text' => $this->text,
|
'text' => $cleanText,
|
||||||
|
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
|
||||||
|
? trim($this->boilerplateOverride)
|
||||||
|
: null,
|
||||||
'keywords' => $this->keywords ?: null,
|
'keywords' => $this->keywords ?: null,
|
||||||
'backlink_url' => $this->backlinkUrl ?: null,
|
'backlink_url' => $this->backlinkUrl ?: null,
|
||||||
'status' => $status->value,
|
'status' => $status->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$pr->contacts()->sync([$contact->id]);
|
||||||
|
|
||||||
session()->flash('success', $status === PressReleaseStatus::Review
|
session()->flash('success', $status === PressReleaseStatus::Review
|
||||||
? __('Pressemitteilung zur Prüfung eingereicht.')
|
? __('Pressemitteilung zur Prüfung eingereicht.')
|
||||||
: __('Entwurf gespeichert.'));
|
: __('Entwurf gespeichert.'));
|
||||||
|
|
@ -99,6 +184,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$context = app(CustomerCompanyContext::class);
|
$context = app(CustomerCompanyContext::class);
|
||||||
$myCompanies = $context->companiesFor($user);
|
$myCompanies = $context->companiesFor($user);
|
||||||
|
$selectedCompany = $this->selectedCompany();
|
||||||
|
|
||||||
$categories = Category::query()
|
$categories = Category::query()
|
||||||
->with('translations')
|
->with('translations')
|
||||||
|
|
@ -109,102 +195,583 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
return [
|
return [
|
||||||
'myCompanies' => $myCompanies,
|
'myCompanies' => $myCompanies,
|
||||||
'categories' => $categories,
|
'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
|
private function selectedCompany(): ?Company
|
||||||
{
|
{
|
||||||
|
if (! $this->companyId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return app(CustomerCompanyContext::class)
|
return app(CustomerCompanyContext::class)
|
||||||
->findFor(auth()->user(), (int) $this->companyId);
|
->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 ============== --}}
|
{{-- ============== PAGE HEADER ============== --}}
|
||||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||||||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||||
<span class="eyebrow muted">{{ __('Mein Bereich · Neue PM') }}</span>
|
<span class="eyebrow muted">{{ __('Mein Bereich · Neue PM') }}</span>
|
||||||
|
<span class="badge muted dot">{{ __('Entwurf') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||||
{{ __('Neue Pressemitteilung') }}
|
{{ __('Neue Pressemitteilung') }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||||
{{ __('Zurück') }}
|
{{ __('Zur Liste') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
|
{{-- ============== 2-COLUMN GRID ============== --}}
|
||||||
<div class="space-y-6">
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr),360px]">
|
||||||
<article class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
|
||||||
</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:field>
|
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
||||||
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
<div class="space-y-6 min-w-0">
|
||||||
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text…') }}" />
|
|
||||||
<flux:error name="text" />
|
|
||||||
</flux:field>
|
|
||||||
|
|
||||||
<flux:field>
|
{{-- 1) FIRMA-SELEKTOR --}}
|
||||||
<flux:label>{{ __('Stichwörter') }}</flux:label>
|
<section class="panel">
|
||||||
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennt…') }}" />
|
<div class="p-4 flex flex-wrap items-center gap-4">
|
||||||
</flux:field>
|
<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]">
|
||||||
<flux:field>
|
|
||||||
<flux:label>{{ __('Backlink-URL') }}</flux:label>
|
|
||||||
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
|
|
||||||
<flux:error name="backlinkUrl" />
|
|
||||||
</flux:field>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="space-y-4">
|
|
||||||
<article class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<span class="section-eyebrow">{{ __('Metadaten') }}</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>
|
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||||
@foreach ($myCompanies as $c)
|
@foreach ($myCompanies as $c)
|
||||||
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:select>
|
</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>
|
||||||
<flux:error name="companyId" />
|
<flux:error name="companyId" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- 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">
|
||||||
|
{{ __('40–90 Zeichen empfohlen. Konkret, ohne Marketing-Floskeln.') }}
|
||||||
|
</p>
|
||||||
|
<flux:error name="title" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- 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>
|
||||||
|
|
||||||
|
{{-- 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>
|
||||||
|
|
||||||
|
{{-- Portal (Read-only) --}}
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="section-eyebrow">{{ __('Portal') }}</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
</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:field>
|
||||||
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
<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>
|
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||||
@foreach ($categories as $cat)
|
@foreach ($categories as $cat)
|
||||||
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
|
@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:error name="categoryId" />
|
||||||
</flux:field>
|
</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:field>
|
||||||
<flux:label>{{ __('Sprache') }}</flux:label>
|
<flux:label>{{ __('Sprache') }}</flux:label>
|
||||||
|
|
@ -226,19 +842,22 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel">
|
{{-- Phase-2-Footer --}}
|
||||||
<div class="panel-head">
|
<div class="rounded-[5px] border p-3.5"
|
||||||
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
|
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>
|
||||||
<div class="p-5 space-y-2">
|
<ul class="text-[11.5px] text-[color:var(--color-accent-deep)] leading-[1.55] list-none p-0 m-0 space-y-1">
|
||||||
<flux:button type="button" variant="primary" class="w-full" wire:click="save('review')">
|
<li>· {{ __('KI-Titel-Optimierung & KI-Lektorat live') }}</li>
|
||||||
{{ __('Zur Prüfung einreichen') }}
|
<li>· {{ __('Geplante Veröffentlichung / Scheduling') }}</li>
|
||||||
</flux:button>
|
<li>· {{ __('Versionshistorie & Kommentare') }}</li>
|
||||||
<flux:button type="button" variant="ghost" class="w-full" wire:click="save('draft')">
|
<li>· {{ __('Portal-Vorschau (presseecho vs. BP24)') }}</li>
|
||||||
{{ __('Als Entwurf speichern') }}
|
</ul>
|
||||||
</flux:button>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use App\Enums\PressReleaseStatus;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Locked;
|
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;
|
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||||||
|
|
||||||
|
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
|
||||||
|
|
||||||
$pr->update([
|
$pr->update([
|
||||||
'portal' => $this->portal,
|
'portal' => $this->portal,
|
||||||
'language' => $this->language,
|
'language' => $this->language,
|
||||||
'company_id' => (int) $this->companyId,
|
'company_id' => (int) $this->companyId,
|
||||||
'category_id' => (int) $this->categoryId,
|
'category_id' => (int) $this->categoryId,
|
||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'text' => $this->text,
|
'text' => $cleanText,
|
||||||
'keywords' => $this->keywords ?: null,
|
'keywords' => $this->keywords ?: null,
|
||||||
'backlink_url' => $this->backlinkUrl ?: null,
|
'backlink_url' => $this->backlinkUrl ?: null,
|
||||||
]);
|
]);
|
||||||
|
|
@ -178,7 +181,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
||||||
|
|
||||||
<flux:field>
|
<flux:field>
|
||||||
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
<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:error name="text" />
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
|
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
|
||||||
{!! nl2br(e($pr->text)) !!}
|
{!! $pr->renderedText() !!}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($pr->keywords || $pr->backlink_url)
|
@if ($pr->keywords || $pr->backlink_url)
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="body">{!! nl2br(e($pressRelease->text)) !!}</div>
|
<div class="body">{!! $pressRelease->renderedText() !!}</div>
|
||||||
|
|
||||||
@if($pressRelease->keywords)
|
@if($pressRelease->keywords)
|
||||||
<p class="keywords"><strong>{{ __('Stichwörter') }}:</strong> {{ $pressRelease->keywords }}</p>
|
<p class="keywords"><strong>{{ __('Stichwörter') }}:</strong> {{ $pressRelease->keywords }}</p>
|
||||||
|
|
|
||||||
179
tests/Feature/CustomerPressReleaseCreatePhase7Test.php
Normal file
179
tests/Feature/CustomerPressReleaseCreatePhase7Test.php
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Livewire\Volt\Volt as LivewireVolt;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mount picks the alphabetically first contact of the default company', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
$company = Company::factory()->presseecho()->create(['name' => 'Alpha GmbH']);
|
||||||
|
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$contactZ = Contact::factory()->for($company)->create([
|
||||||
|
'first_name' => 'Zacharias',
|
||||||
|
'last_name' => 'Zimmer',
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
]);
|
||||||
|
$contactA = Contact::factory()->for($company)->create([
|
||||||
|
'first_name' => 'Alfred',
|
||||||
|
'last_name' => 'Acker',
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.create')
|
||||||
|
->assertSet('companyId', $company->id)
|
||||||
|
->assertSet('contactId', $contactA->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the company resets the contactId to the new company default', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
|
||||||
|
$alphaCo = Company::factory()->presseecho()->create(['name' => 'Alpha']);
|
||||||
|
$betaCo = Company::factory()->presseecho()->create(['name' => 'Beta']);
|
||||||
|
$customer->companies()->attach($alphaCo->id, ['role' => 'owner']);
|
||||||
|
$customer->companies()->attach($betaCo->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$alphaContact = Contact::factory()->for($alphaCo)->create(['portal' => $alphaCo->portal->value]);
|
||||||
|
$betaContact = Contact::factory()->for($betaCo)->create(['portal' => $betaCo->portal->value]);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.create')
|
||||||
|
->set('companyId', $betaCo->id)
|
||||||
|
->assertSet('contactId', $betaContact->id)
|
||||||
|
->set('companyId', $alphaCo->id)
|
||||||
|
->assertSet('contactId', $alphaContact->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save with all required fields persists the press release and syncs contact', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
$company = Company::factory()->presseecho()->create(['boilerplate' => 'Über uns…']);
|
||||||
|
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||||
|
$contact = Contact::factory()->for($company)->create(['portal' => $company->portal->value]);
|
||||||
|
$category = Category::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.create')
|
||||||
|
->set('title', 'Eine neue Eröffnung der Brauerei')
|
||||||
|
->set('subtitle', 'Subline mit ein wenig Kontext.')
|
||||||
|
->set('text', str_repeat('Inhaltlich relevanter Fließtext. ', 5))
|
||||||
|
->set('categoryId', $category->id)
|
||||||
|
->set('contactId', $contact->id)
|
||||||
|
->set('keywords', 'Brauerei, Eröffnung')
|
||||||
|
->set('useBoilerplateOverride', true)
|
||||||
|
->set('boilerplateOverride', 'Spezielle Boilerplate für diese PM.')
|
||||||
|
->call('save', 'draft')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$pr = PressRelease::query()->where('user_id', $customer->id)->latest('id')->firstOrFail();
|
||||||
|
|
||||||
|
expect($pr->title)->toBe('Eine neue Eröffnung der Brauerei');
|
||||||
|
expect($pr->subtitle)->toBe('Subline mit ein wenig Kontext.');
|
||||||
|
expect($pr->boilerplate_override)->toBe('Spezielle Boilerplate für diese PM.');
|
||||||
|
expect($pr->company_id)->toBe($company->id);
|
||||||
|
expect($pr->category_id)->toBe($category->id);
|
||||||
|
expect($pr->status)->toBe(PressReleaseStatus::Draft);
|
||||||
|
expect($pr->contacts)->toHaveCount(1);
|
||||||
|
expect($pr->contacts->first()->id)->toBe($contact->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save without a contact id fails validation', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||||
|
$category = Category::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.create')
|
||||||
|
->set('contactId', null)
|
||||||
|
->set('title', 'Titel mit genug Zeichen')
|
||||||
|
->set('text', str_repeat('x', 60))
|
||||||
|
->set('categoryId', $category->id)
|
||||||
|
->call('save', 'draft')
|
||||||
|
->assertHasErrors(['contactId']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boilerplate override is null when toggle is off even if text is filled', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||||
|
$contact = Contact::factory()->for($company)->create(['portal' => $company->portal->value]);
|
||||||
|
$category = Category::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.create')
|
||||||
|
->set('title', 'Genug Zeichen Titel')
|
||||||
|
->set('text', str_repeat('x', 60))
|
||||||
|
->set('categoryId', $category->id)
|
||||||
|
->set('contactId', $contact->id)
|
||||||
|
->set('useBoilerplateOverride', false)
|
||||||
|
->set('boilerplateOverride', 'Wird nicht gespeichert.')
|
||||||
|
->call('save', 'draft')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$pr = PressRelease::query()->where('user_id', $customer->id)->latest('id')->firstOrFail();
|
||||||
|
|
||||||
|
expect($pr->boilerplate_override)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addTag appends to keywords and removeTag drops it', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.create')
|
||||||
|
->call('addTag', 'Brauerei')
|
||||||
|
->call('addTag', 'Tegernsee')
|
||||||
|
->call('addTag', 'Brauerei')
|
||||||
|
->assertSet('keywords', 'Brauerei, Tegernsee')
|
||||||
|
->call('removeTag', 'Brauerei')
|
||||||
|
->assertSet('keywords', 'Tegernsee');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addTag stops adding once the 5-tag limit is reached', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->assignRole('customer');
|
||||||
|
$company = Company::factory()->presseecho()->create();
|
||||||
|
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
$component = LivewireVolt::test('customer.press-releases.create');
|
||||||
|
|
||||||
|
foreach (['A', 'B', 'C', 'D', 'E'] as $tag) {
|
||||||
|
$component->call('addTag', $tag);
|
||||||
|
}
|
||||||
|
$component->call('addTag', 'F'); // Soft-cap, kein Fehler
|
||||||
|
$component->assertSet('keywords', 'A, B, C, D, E');
|
||||||
|
});
|
||||||
125
tests/Feature/PressReleaseHtmlSanitizerTest.php
Normal file
125
tests/Feature/PressReleaseHtmlSanitizerTest.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||||
|
|
||||||
|
function sanitizer(): PressReleaseHtmlSanitizer
|
||||||
|
{
|
||||||
|
return app(PressReleaseHtmlSanitizer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('clean strips script and style tags', function () {
|
||||||
|
$dirty = '<p>Hallo</p><script>alert("xss")</script><style>body{}</style>';
|
||||||
|
|
||||||
|
$clean = sanitizer()->clean($dirty);
|
||||||
|
|
||||||
|
expect($clean)->toContain('<p>Hallo</p>');
|
||||||
|
expect($clean)->not->toContain('<script');
|
||||||
|
expect($clean)->not->toContain('alert');
|
||||||
|
expect($clean)->not->toContain('<style');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clean strips iframes and event handlers', function () {
|
||||||
|
$dirty = '<p onclick="bad()">Hallo</p><iframe src="https://evil.test"></iframe>';
|
||||||
|
|
||||||
|
$clean = sanitizer()->clean($dirty);
|
||||||
|
|
||||||
|
expect($clean)->not->toContain('onclick');
|
||||||
|
expect($clean)->not->toContain('<iframe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clean keeps allowed tags in press release allowlist', function () {
|
||||||
|
$dirty = '<h2>Headline</h2><p>Absatz mit <strong>fett</strong> und <em>kursiv</em>.</p>'
|
||||||
|
.'<ul><li>Punkt A</li><li>Punkt B</li></ul>'
|
||||||
|
.'<blockquote>Zitat</blockquote>'
|
||||||
|
.'<a href="https://example.test">Link</a>';
|
||||||
|
|
||||||
|
$clean = sanitizer()->clean($dirty);
|
||||||
|
|
||||||
|
expect($clean)->toContain('<h2>Headline</h2>');
|
||||||
|
expect($clean)->toContain('<strong>fett</strong>');
|
||||||
|
expect($clean)->toContain('<em>kursiv</em>');
|
||||||
|
expect($clean)->toContain('<ul>');
|
||||||
|
expect($clean)->toContain('<li>Punkt A</li>');
|
||||||
|
expect($clean)->toContain('<blockquote>');
|
||||||
|
expect($clean)->toContain('href="https://example.test"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clean removes disallowed tags like h1 table img', function () {
|
||||||
|
$dirty = '<h1>Big</h1><table><tr><td>x</td></tr></table><img src="x.jpg">';
|
||||||
|
|
||||||
|
$clean = sanitizer()->clean($dirty);
|
||||||
|
|
||||||
|
expect($clean)->not->toContain('<h1');
|
||||||
|
expect($clean)->not->toContain('<table');
|
||||||
|
expect($clean)->not->toContain('<img');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('external links get rel nofollow and target blank', function () {
|
||||||
|
$dirty = '<p><a href="https://example.test">Link</a></p>';
|
||||||
|
|
||||||
|
$clean = sanitizer()->clean($dirty);
|
||||||
|
|
||||||
|
expect($clean)->toContain('rel=');
|
||||||
|
expect($clean)->toContain('nofollow');
|
||||||
|
expect($clean)->toContain('target="_blank"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clean returns empty string for null and whitespace input', function () {
|
||||||
|
expect(sanitizer()->clean(null))->toBe('');
|
||||||
|
expect(sanitizer()->clean(' '))->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isHtml detects html content', function () {
|
||||||
|
expect(sanitizer()->isHtml('<p>Hi</p>'))->toBeTrue();
|
||||||
|
expect(sanitizer()->isHtml('<strong>foo</strong>'))->toBeTrue();
|
||||||
|
expect(sanitizer()->isHtml('Plain text only'))->toBeFalse();
|
||||||
|
expect(sanitizer()->isHtml(''))->toBeFalse();
|
||||||
|
expect(sanitizer()->isHtml(null))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('render wraps legacy plain text into paragraphs and br tags', function () {
|
||||||
|
$plain = "Zeile eins\nZeile zwei\n\nNeuer Absatz";
|
||||||
|
|
||||||
|
$html = (string) sanitizer()->render($plain);
|
||||||
|
|
||||||
|
expect($html)->toContain('<p>');
|
||||||
|
expect($html)->toContain('Zeile eins<br');
|
||||||
|
expect($html)->toContain('Zeile zwei');
|
||||||
|
expect($html)->toContain('Neuer Absatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('render escapes html-special chars in legacy plain text', function () {
|
||||||
|
$plain = 'Skript: <script>alert(1)</script>';
|
||||||
|
|
||||||
|
$html = (string) sanitizer()->render($plain);
|
||||||
|
|
||||||
|
expect($html)->not->toContain('<script');
|
||||||
|
expect($html)->toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('render returns sanitized html for stored html content', function () {
|
||||||
|
$stored = '<p>Hallo</p><script>bad()</script>';
|
||||||
|
|
||||||
|
$html = (string) sanitizer()->render($stored);
|
||||||
|
|
||||||
|
expect($html)->toContain('<p>Hallo</p>');
|
||||||
|
expect($html)->not->toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plainTextLength counts chars without html noise', function () {
|
||||||
|
expect(sanitizer()->plainTextLength('<p>Hallo Welt</p>'))->toBe(10);
|
||||||
|
expect(sanitizer()->plainTextLength('<p>Eins</p> <p>Zwei</p>'))->toBe(9);
|
||||||
|
expect(sanitizer()->plainTextLength(null))->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PressRelease::renderedText uses the sanitizer', function () {
|
||||||
|
$pr = PressRelease::factory()->create([
|
||||||
|
'text' => '<p>Hallo</p><script>bad()</script>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rendered = (string) $pr->renderedText();
|
||||||
|
|
||||||
|
expect($rendered)->toContain('<p>Hallo</p>');
|
||||||
|
expect($rendered)->not->toContain('<script');
|
||||||
|
});
|
||||||
54
tests/Feature/PressReleasePhase7SchemaTest.php
Normal file
54
tests/Feature/PressReleasePhase7SchemaTest.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseAttachment;
|
||||||
|
|
||||||
|
test('press release accepts new phase 7 fields', function () {
|
||||||
|
$pr = PressRelease::factory()->create([
|
||||||
|
'subtitle' => 'Eine sinnvolle Subline',
|
||||||
|
'boilerplate_override' => 'PM-spezifischer Boilerplate-Text.',
|
||||||
|
'scheduled_at' => now()->addDay(),
|
||||||
|
'embargo_at' => now()->addDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = $pr->fresh();
|
||||||
|
|
||||||
|
expect($fresh->subtitle)->toBe('Eine sinnvolle Subline');
|
||||||
|
expect($fresh->boilerplate_override)->toBe('PM-spezifischer Boilerplate-Text.');
|
||||||
|
expect($fresh->scheduled_at)->not->toBeNull();
|
||||||
|
expect($fresh->embargo_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('company accepts a boilerplate field', function () {
|
||||||
|
$company = Company::factory()->create([
|
||||||
|
'boilerplate' => 'Über die Beispiel GmbH: gegründet 1900, …',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($company->fresh()->boilerplate)->toBe('Über die Beispiel GmbH: gegründet 1900, …');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('press release attachments table works via factory and relation', function () {
|
||||||
|
$pr = PressRelease::factory()->create();
|
||||||
|
|
||||||
|
$attachment = PressReleaseAttachment::factory()->create([
|
||||||
|
'press_release_id' => $pr->id,
|
||||||
|
'original_name' => 'pressemappe.pdf',
|
||||||
|
'mime' => 'application/pdf',
|
||||||
|
'size' => 1_234_567,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($pr->fresh()->attachments)->toHaveCount(1);
|
||||||
|
expect($pr->fresh()->attachments->first()->original_name)->toBe('pressemappe.pdf');
|
||||||
|
expect($attachment->pressRelease->is($pr))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachment is removed when press release is force-deleted', function () {
|
||||||
|
$pr = PressRelease::factory()->create();
|
||||||
|
PressReleaseAttachment::factory()->for($pr)->create();
|
||||||
|
|
||||||
|
$pr->forceDelete();
|
||||||
|
|
||||||
|
expect(PressReleaseAttachment::query()->withTrashed()->count())->toBe(0);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue