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
parent
8b1bbf401a
commit
8cf8c771c8
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
Loading…
Reference in New Issue