presseportale/resources/views/livewire/components/press-release-attachments-manager.blade.php
Kevin Adametz e8c47b7553
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
22-05-2026 Optimierung der User und Admin Panels
2026-05-22 11:18:59 +02:00

382 lines
16 KiB
PHP

<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Services\PressRelease\PressReleaseAttachmentStorage;
use Flux\Flux;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
/**
* Reusable attachments manager for a single press release. Mirrors the API
* of the press-release-images-manager: upload, edit metadata, reorder,
* delete. Authorisation is delegated to the `update` ability on the
* PressReleasePolicy so the component is safe for admins and customers.
*
* Storage uses the `public` disk with path obscurity (UUID prefix). Embargo
* / unpublished-state guards live at the PressRelease level.
*/
new class extends Component
{
use WithFileUploads;
#[Locked]
public int $pressReleaseId;
public $newFile = null;
public string $newTitle = '';
public string $newDescription = '';
public ?int $editingId = null;
public string $editTitle = '';
public string $editDescription = '';
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function upload(PressReleaseAttachmentStorage $storage): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
$this->addError('newFile', __('Anhänge können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
return;
}
$maxKb = (int) (PressReleaseAttachmentStorage::MAX_BYTES / 1024);
$allowedExtensions = implode(',', PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS);
$this->validate([
'newFile' => ['required', 'file', 'mimes:'.$allowedExtensions, 'max:'.$maxKb],
'newTitle' => ['nullable', 'string', 'max:120'],
'newDescription' => ['nullable', 'string', 'max:500'],
]);
$stored = $storage->store($this->newFile, $pressRelease->id);
$pressRelease->attachments()->create([
'disk' => $stored['disk'],
'path' => $stored['path'],
'original_name' => $stored['original_name'],
'mime' => $stored['mime'],
'size' => $stored['size'],
'title' => $this->newTitle ?: null,
'description' => $this->newDescription ?: null,
'sort_order' => ((int) $pressRelease->attachments()->max('sort_order')) + 1,
]);
$this->reset(['newFile', 'newTitle', 'newDescription']);
Flux::toast(text: __('Anhang hochgeladen.'), variant: 'success');
}
public function startEdit(int $attachmentId): void
{
$attachment = $this->getAttachment($attachmentId);
if (! $attachment) {
return;
}
$this->editingId = $attachment->id;
$this->editTitle = $attachment->title ?? '';
$this->editDescription = $attachment->description ?? '';
}
public function cancelEdit(): void
{
$this->reset(['editingId', 'editTitle', 'editDescription']);
}
public function updateAttachment(): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease) || $this->editingId === null) {
return;
}
$this->validate([
'editTitle' => ['nullable', 'string', 'max:120'],
'editDescription' => ['nullable', 'string', 'max:500'],
]);
$attachment = $this->getAttachment($this->editingId);
if (! $attachment) {
return;
}
$attachment->update([
'title' => trim($this->editTitle) ?: null,
'description' => trim($this->editDescription) ?: null,
]);
$this->cancelEdit();
Flux::toast(text: __('Anhang aktualisiert.'), variant: 'success');
}
public function moveUp(int $attachmentId): void
{
$this->swapSortOrder($attachmentId, -1);
}
public function moveDown(int $attachmentId): void
{
$this->swapSortOrder($attachmentId, 1);
}
public function remove(int $attachmentId, PressReleaseAttachmentStorage $storage): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
return;
}
$attachment = $this->getAttachment($attachmentId);
if (! $attachment) {
return;
}
$storage->delete($attachment->disk, $attachment->path);
$attachment->delete();
Flux::toast(text: __('Anhang entfernt.'), variant: 'success');
}
public function with(): array
{
$pressRelease = $this->getPressRelease();
return [
'attachments' => $pressRelease->attachments()
->orderBy('sort_order')
->orderBy('id')
->get(),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
&& $this->canChangeAttachments($pressRelease),
'maxMb' => round(PressReleaseAttachmentStorage::MAX_BYTES / 1024 / 1024),
'allowedExtensions' => PressReleaseAttachmentStorage::ALLOWED_EXTENSIONS,
];
}
private function swapSortOrder(int $attachmentId, int $direction): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeAttachments($pressRelease)) {
return;
}
$attachments = $pressRelease->attachments()->orderBy('sort_order')->orderBy('id')->get();
$currentIndex = $attachments->search(fn (PressReleaseAttachment $att) => $att->id === $attachmentId);
if ($currentIndex === false) {
return;
}
$targetIndex = $currentIndex + $direction;
if ($targetIndex < 0 || $targetIndex >= $attachments->count()) {
return;
}
$current = $attachments[$currentIndex];
$target = $attachments[$targetIndex];
$currentSort = $current->sort_order;
$current->update(['sort_order' => $target->sort_order]);
$target->update(['sort_order' => $currentSort]);
}
private function getPressRelease(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->findOrFail($this->pressReleaseId);
}
private function getAttachment(int $attachmentId): ?PressReleaseAttachment
{
return PressReleaseAttachment::query()
->where('press_release_id', $this->pressReleaseId)
->whereKey($attachmentId)
->first();
}
private function canChangeAttachments(PressRelease $pressRelease): bool
{
if (auth()->user()?->canAccessAdmin()) {
return ! in_array($pressRelease->status, [PressReleaseStatus::Archived], true);
}
return in_array(
$pressRelease->status,
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
true,
);
}
}; ?>
<section class="panel">
<div class="p-5">
<div class="flex items-center justify-between mb-3 gap-4 flex-wrap">
<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;">
— {{ count($attachments) }}/10
</span>
</span>
<div class="flex items-center gap-2">
<span class="text-[10.5px] text-[color:var(--color-ink-4)]">
{{ strtoupper(implode(' · ', $allowedExtensions)) }} · max. {{ $maxMb }} MB
</span>
</div>
</div>
@if ($canEdit)
<form wire:submit="upload"
class="rounded-[5px] border border-dashed border-[color:var(--color-hub-soft-2)] bg-[color:var(--color-bg-elev)] p-4 space-y-3">
<div class="grid gap-3 sm:grid-cols-[1fr_auto] items-start">
<flux:field>
<flux:label>{{ __('Datei') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input
type="file"
wire:model="newFile"
accept=".{{ implode(',.', $allowedExtensions) }}"
/>
<flux:error name="newFile" />
</flux:field>
<flux:button type="submit" variant="primary" icon="arrow-up-tray" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="upload,newFile">{{ __('Hochladen') }}</span>
<span wire:loading wire:target="upload,newFile">{{ __('Lädt…') }}</span>
</flux:button>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:field>
<flux:label>{{ __('Titel (optional)') }}</flux:label>
<flux:input wire:model="newTitle" placeholder="{{ __('z.B. Pressemappe Frühjahr 2026') }}" />
<flux:error name="newTitle" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung (optional)') }}</flux:label>
<flux:input wire:model="newDescription" placeholder="{{ __('Kurze Beschreibung des Dokuments') }}" />
<flux:error name="newDescription" />
</flux:field>
</div>
</form>
@endif
@if ($attachments->isEmpty())
<div class="mt-4 rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-6 text-center">
<flux:icon name="document" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Anhänge — Pressemappen, Factsheets oder Bildmaterial-Pakete passen hier rein.') }}
</p>
</div>
@else
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@foreach ($attachments as $attachment)
<article class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-card)] p-3"
wire:key="att-{{ $attachment->id }}">
@if ($editingId === $attachment->id && $canEdit)
{{-- Inline-Edit-Form --}}
<div class="space-y-2">
<flux:field>
<flux:label>{{ __('Titel') }}</flux:label>
<flux:input wire:model="editTitle" />
<flux:error name="editTitle" />
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:input wire:model="editDescription" />
<flux:error name="editDescription" />
</flux:field>
<div class="flex justify-end gap-1 pt-1">
<flux:button size="xs" variant="ghost" wire:click="cancelEdit">{{ __('Abbrechen') }}</flux:button>
<flux:button size="xs" variant="primary" icon="check" wire:click="updateAttachment">{{ __('Speichern') }}</flux:button>
</div>
</div>
@else
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-11 h-11 rounded-[4px] bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)] flex items-center justify-center">
@php
$ext = strtolower(pathinfo($attachment->original_name ?? '', PATHINFO_EXTENSION));
$iconName = match (true) {
$ext === 'pdf' => 'document-text',
in_array($ext, ['doc', 'docx'], true) => 'document-text',
in_array($ext, ['xls', 'xlsx'], true) => 'table-cells',
in_array($ext, ['ppt', 'pptx'], true) => 'presentation-chart-bar',
$ext === 'zip' => 'archive-box',
default => 'document',
};
@endphp
<flux:icon :name="$iconName" variant="mini" class="size-5" />
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-semibold text-[color:var(--color-ink)] m-0 truncate"
title="{{ $attachment->title ?? $attachment->original_name }}">
{{ $attachment->title ?? $attachment->original_name }}
</p>
@if ($attachment->title)
<p class="text-[11px] text-[color:var(--color-ink-4)] m-0 truncate"
title="{{ $attachment->original_name }}">
{{ $attachment->original_name }}
</p>
@endif
@if ($attachment->description)
<p class="text-[11.5px] text-[color:var(--color-ink-3)] mt-1 mb-0 line-clamp-2">
{{ $attachment->description }}
</p>
@endif
<p class="text-[10.5px] text-[color:var(--color-ink-4)] font-mono mt-1 mb-0">
{{ strtoupper($ext ?: '?') }} ·
@php
$bytes = (int) $attachment->size;
$sizeLabel = $bytes >= 1048576
? number_format($bytes / 1048576, 1, ',', '.').' MB'
: number_format(max(1, (int) round($bytes / 1024)), 0, ',', '.').' KB';
@endphp
{{ $sizeLabel }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-1 mt-3 pt-3 border-t border-[color:var(--color-bg-rule)]">
@if ($attachment->url())
<flux:button size="xs" variant="ghost" icon="arrow-down-tray"
href="{{ $attachment->url() }}" target="_blank" rel="noopener">
{{ __('Download') }}
</flux:button>
@endif
@if ($canEdit)
<flux:button size="xs" variant="ghost" icon="pencil-square" wire:click="startEdit({{ $attachment->id }})" :title="__('Bearbeiten')" />
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $attachment->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $attachment->id }})" :title="__('Runter')" />
<span class="flex-1"></span>
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $attachment->id }})"
wire:confirm="{{ __('Anhang wirklich entfernen?') }}" :title="__('Entfernen')" />
@endif
</div>
@endif
</article>
@endforeach
</div>
@endif
</div>
</section>