From 8cf8c771c88790115dc29975e5396bb499d21d9d Mon Sep 17 00:00:00 2001
From: boban
Date: Sun, 14 Jun 2026 23:10:37 +0200
Subject: [PATCH] feat(files): image preview in the file manager; modal polish
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
app/Livewire/Modals/FileEditor.php | 81 +++++++++++++++++--
lang/de/modals.php | 2 +
lang/en/modals.php | 2 +
.../livewire/modals/add-ssh-key.blade.php | 4 +-
.../livewire/modals/create-server.blade.php | 2 +-
.../livewire/modals/edit-credential.blade.php | 2 +-
.../livewire/modals/file-editor.blade.php | 10 ++-
.../livewire/modals/recovery-codes.blade.php | 18 +++--
.../modals/ssh-key-provision.blade.php | 2 +-
9 files changed, 102 insertions(+), 21 deletions(-)
diff --git a/app/Livewire/Modals/FileEditor.php b/app/Livewire/Modals/FileEditor.php
index 3fec574..c64b47d 100644
--- a/app/Livewire/Modals/FileEditor.php
+++ b/app/Livewire/Modals/FileEditor.php
@@ -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');
diff --git a/lang/de/modals.php b/lang/de/modals.php
index cb55a2e..0d95f1c 100644
--- a/lang/de/modals.php
+++ b/lang/de/modals.php
@@ -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.',
],
diff --git a/lang/en/modals.php b/lang/en/modals.php
index 90f0f3b..c681ca4 100644
--- a/lang/en/modals.php
+++ b/lang/en/modals.php
@@ -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.',
],
diff --git a/resources/views/livewire/modals/add-ssh-key.blade.php b/resources/views/livewire/modals/add-ssh-key.blade.php
index b22e4d9..d9a9de6 100644
--- a/resources/views/livewire/modals/add-ssh-key.blade.php
+++ b/resources/views/livewire/modals/add-ssh-key.blade.php
@@ -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"
>
@if ($generatedPrivate)
@@ -30,7 +30,7 @@
{{ __('modals.add_ssh_key.private_key_warning') }}
+ 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 }}
@endif
diff --git a/resources/views/livewire/modals/create-server.blade.php b/resources/views/livewire/modals/create-server.blade.php
index 37ae4fe..27ad9a1 100644
--- a/resources/views/livewire/modals/create-server.blade.php
+++ b/resources/views/livewire/modals/create-server.blade.php
@@ -52,7 +52,7 @@
@if ($authType === 'key')
+ 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">
@else
diff --git a/resources/views/livewire/modals/edit-credential.blade.php b/resources/views/livewire/modals/edit-credential.blade.php
index 2a17911..8390aec 100644
--- a/resources/views/livewire/modals/edit-credential.blade.php
+++ b/resources/views/livewire/modals/edit-credential.blade.php
@@ -32,7 +32,7 @@
@if ($authType === 'key')
+ 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">
@else
diff --git a/resources/views/livewire/modals/file-editor.blade.php b/resources/views/livewire/modals/file-editor.blade.php
index 237700d..8854a83 100644
--- a/resources/views/livewire/modals/file-editor.blade.php
+++ b/resources/views/livewire/modals/file-editor.blade.php
@@ -15,18 +15,22 @@
@elseif ($error)
{{ $error }}
@elseif ($tooBig)
- {{ __('modals.file_editor.too_big') }}
+ {{ $this->isImage() ? __('modals.file_editor.image_too_big') : __('modals.file_editor.too_big') }}
+ @elseif ($this->isImage())
+
+

+
@elseif ($binary)
{{ __('modals.file_editor.binary') }}
@else
+ 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">
@endif
{{ __('common.close') }}
- @if ($loaded && ! $error && ! $tooBig && ! $binary)
+ @if ($loaded && ! $error && ! $tooBig && ! $binary && ! $this->isImage())
{{ __('common.save') }}
diff --git a/resources/views/livewire/modals/recovery-codes.blade.php b/resources/views/livewire/modals/recovery-codes.blade.php
index 12c185c..10dbfec 100644
--- a/resources/views/livewire/modals/recovery-codes.blade.php
+++ b/resources/views/livewire/modals/recovery-codes.blade.php
@@ -20,13 +20,15 @@
@endforeach
-
-
- {{ __('auth.recovery_download') }}
-
-
- {{ __('auth.recovery_regenerate') }}
-
-
{{ __('auth.recovery_done') }}
+
+
+
+ {{ __('auth.recovery_download') }}
+
+
+ {{ __('auth.recovery_regenerate') }}
+
+
+
{{ __('common.close') }}
diff --git a/resources/views/livewire/modals/ssh-key-provision.blade.php b/resources/views/livewire/modals/ssh-key-provision.blade.php
index e3a57e8..fe22101 100644
--- a/resources/views/livewire/modals/ssh-key-provision.blade.php
+++ b/resources/views/livewire/modals/ssh-key-provision.blade.php
@@ -41,7 +41,7 @@
{{ __('modals.ssh_key_provision.key_warning') }}
+ 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 }}