382 lines
16 KiB
PHP
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>
|