feat(files): image preview in the file manager; modal polish

Opening an image now renders it (SFTP base64 data-URI, 5 MB cap, save disabled for
images) instead of the text editor. Recovery-codes modal buttons realigned + the
close button relabeled 'Schließen' (it only closes). All modal textareas resize-none.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 23:10:37 +02:00
parent 8b1bbf401a
commit 8cf8c771c8
9 changed files with 102 additions and 21 deletions

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use App\Support\Ssh\Sftp;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
@ -12,9 +13,16 @@ use Throwable;
/**
* View / edit a remote text file over SFTP. Loads lazily (wire:init), refuses
* binary or oversized files, and writes back on save with an AuditEvent.
* Image files are shown as a read-only base64 preview instead of the editor.
*/
class FileEditor extends ModalComponent
{
/** Image preview is capped — larger images are refused (base64 inflates ~33%). */
private const IMAGE_MAX_BYTES = 5_242_880; // 5 MB
/** Extensions rendered as an inline image preview (read-only). */
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'];
public int $serverId;
public string $path;
@ -31,6 +39,9 @@ class FileEditor extends ModalComponent
public ?string $error = null;
/** base64 `data:` URI for image previews; null for text files. */
public ?string $dataUri = null;
public function mount(int $serverId, string $path, string $name): void
{
$this->serverId = $serverId;
@ -43,7 +54,29 @@ class FileEditor extends ModalComponent
return 'lg';
}
public function load(FleetService $fleet): void
/** True when the opened file is an image (preview, not editable). */
public function isImage(): bool
{
return in_array($this->extension(), self::IMAGE_EXTENSIONS, true);
}
private function extension(): string
{
return strtolower(pathinfo($this->name, PATHINFO_EXTENSION));
}
/** MIME type for the image preview data URI, keyed off the extension. */
private function imageMime(): string
{
return match ($this->extension()) {
'svg' => 'image/svg+xml',
'jpg', 'jpeg' => 'image/jpeg',
'ico' => 'image/x-icon',
default => 'image/'.$this->extension(),
};
}
public function load(FleetService $fleet, Sftp $sftp): void
{
$server = Server::find($this->serverId);
if (! $server) {
@ -54,10 +87,14 @@ class FileEditor extends ModalComponent
}
try {
$r = $fleet->readFile($server, $this->path);
$this->content = $r['content'];
$this->binary = $r['binary'];
$this->tooBig = $r['tooBig'];
if ($this->isImage()) {
$this->loadImage($server, $sftp);
} else {
$r = $fleet->readFile($server, $this->path);
$this->content = $r['content'];
$this->binary = $r['binary'];
$this->tooBig = $r['tooBig'];
}
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
@ -65,10 +102,44 @@ class FileEditor extends ModalComponent
$this->loaded = true;
}
/**
* Read the image bytes over SFTP and expose them as a base64 data URI.
* Refuses files larger than IMAGE_MAX_BYTES (checked before download).
*/
private function loadImage(Server $server, Sftp $sftp): void
{
$sftp->connect($server);
try {
if ($sftp->size($this->path) > self::IMAGE_MAX_BYTES) {
$this->tooBig = true;
return;
}
$bytes = $sftp->get($this->path);
if ($bytes === '') {
$this->error = __('modals.file_editor.image_read_failed');
return;
}
$this->dataUri = 'data:'.$this->imageMime().';base64,'.base64_encode($bytes);
} finally {
$sftp->disconnect();
}
}
public function save(FleetService $fleet): void
{
$this->error = null;
// Images are a read-only preview — never write them back.
if ($this->isImage()) {
return;
}
$server = Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');

View File

@ -106,6 +106,8 @@ return [
// ── File editor ───────────────────────────────────────────────────────
'file_editor' => [
'too_big' => 'Datei zu groß zum Bearbeiten (über 256 KB) — bitte herunterladen.',
'image_too_big' => 'Bild zu groß für die Vorschau (über 5 MB) — bitte herunterladen.',
'image_read_failed' => 'Bild konnte nicht gelesen werden.',
'binary' => 'Binärdatei — kann nicht im Editor angezeigt werden.',
'notify_saved' => '„:name“ gespeichert.',
],

View File

@ -105,6 +105,8 @@ return [
// ── File editor ───────────────────────────────────────────────────────
'file_editor' => [
'too_big' => 'File too large to edit (over 256 KB) — please download it.',
'image_too_big' => 'Image too large to preview (over 5 MB) — please download it.',
'image_read_failed' => 'Image could not be read.',
'binary' => 'Binary file — cannot be shown in the editor.',
'notify_saved' => '“:name” saved.',
],

View File

@ -21,7 +21,7 @@
wire:model="publicKey"
rows="3"
placeholder="{{ __('modals.add_ssh_key.public_key_placeholder') }}"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"
class="w-full resize-none rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"
></textarea>
@if ($generatedPrivate)
@ -30,7 +30,7 @@
<x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.add_ssh_key.private_key_warning') }}
</p>
<textarea readonly rows="4" x-on:click="$el.select()"
class="mt-2 w-full rounded-md border border-line bg-void px-3 py-2 font-mono text-[10px] leading-relaxed text-ink-2 focus:outline-none">{{ $generatedPrivate }}</textarea>
class="mt-2 w-full resize-none rounded-md border border-line bg-void px-3 py-2 font-mono text-[10px] leading-relaxed text-ink-2 focus:outline-none">{{ $generatedPrivate }}</textarea>
</div>
@endif

View File

@ -52,7 +52,7 @@
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? __('modals.create_server.secret_key_label') : __('modals.create_server.secret_password_label') }}</label>
@if ($authType === 'key')
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
class="w-full resize-none rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
@else
<input wire:model="secret" type="password" placeholder="••••••••"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />

View File

@ -32,7 +32,7 @@
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? __('modals.edit_credential.secret_key_label') : __('modals.edit_credential.secret_password_label') }}</label>
@if ($authType === 'key')
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
class="w-full resize-none rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
@else
<input wire:model="secret" type="password" placeholder="••••••••"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />

View File

@ -15,18 +15,22 @@
@elseif ($error)
<p class="flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $error }}</p>
@elseif ($tooBig)
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.file_editor.too_big') }}</p>
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $this->isImage() ? __('modals.file_editor.image_too_big') : __('modals.file_editor.too_big') }}</p>
@elseif ($this->isImage())
<div class="overflow-hidden rounded-md border border-line bg-void p-3">
<img src="{{ $dataUri }}" alt="{{ $name }}" class="mx-auto max-h-[70vh] w-full object-contain" />
</div>
@elseif ($binary)
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.file_editor.binary') }}</p>
@else
<textarea wire:model="content" rows="14" spellcheck="false"
class="w-full resize-y rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2 focus:border-accent/40 focus:outline-none"></textarea>
class="w-full resize-none rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2 focus:border-accent/40 focus:outline-none"></textarea>
@endif
</div>
<div class="mt-5 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
@if ($loaded && ! $error && ! $tooBig && ! $binary)
@if ($loaded && ! $error && ! $tooBig && ! $binary && ! $this->isImage())
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('common.save') }}

View File

@ -20,13 +20,15 @@
@endforeach
</div>
<div class="mt-6 flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn variant="primary" class="ml-auto" wire:click="$dispatch('closeModal')">{{ __('auth.recovery_done') }}</x-btn>
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
</div>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
</div>

View File

@ -41,7 +41,7 @@
<div class="mt-3 rounded-md border border-warning/30 bg-warning/10 p-3" x-data>
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ __('modals.ssh_key_provision.key_warning') }}</p>
<textarea x-ref="pk" readonly rows="6" x-on:click="$el.select()"
class="mt-2 w-full rounded-md border border-line bg-void px-3 py-2 font-mono text-[10px] leading-relaxed text-ink-2 focus:outline-none">{{ $privateKey }}</textarea>
class="mt-2 w-full resize-none rounded-md border border-line bg-void px-3 py-2 font-mono text-[10px] leading-relaxed text-ink-2 focus:outline-none">{{ $privateKey }}</textarea>
<div class="mt-2">
<x-btn variant="secondary" size="sm"
x-on:click="const b=new Blob([$refs.pk.value],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='clusev-{{ \Illuminate\Support\Str::slug($serverName) ?: 'server' }}-id_ed25519';a.click();URL.revokeObjectURL(a.href)">