143 lines
8.7 KiB
PHP
143 lines
8.7 KiB
PHP
@php
|
|
$dirs = collect($entries)->where('type', 'dir')->count();
|
|
$files = collect($entries)->where('type', 'file')->count();
|
|
|
|
// Human-readable byte sizes for the live SFTP listing.
|
|
$fmtSize = function (?int $bytes): string {
|
|
if ($bytes === null) {
|
|
return '—';
|
|
}
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
$i = 0;
|
|
$val = (float) $bytes;
|
|
while ($val >= 1024 && $i < count($units) - 1) {
|
|
$val /= 1024;
|
|
$i++;
|
|
}
|
|
|
|
return ($i === 0 ? (int) $val : number_format($val, 1)).' '.$units[$i];
|
|
};
|
|
@endphp
|
|
|
|
<div class="space-y-4" wire:init="load">
|
|
{{-- Header --}}
|
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('files.eyebrow') }}</p>
|
|
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('files.heading') }}</h2>
|
|
</div>
|
|
<x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('files.sftp_connected') : __('files.sftp_disconnected') }}</x-status-pill>
|
|
</div>
|
|
|
|
{{-- Breadcrumb / path --}}
|
|
<x-panel :padded="false">
|
|
<div class="flex flex-wrap items-center gap-x-1 gap-y-1.5 px-4 py-3 sm:px-5">
|
|
<x-icon name="folder" class="mr-1 h-4 w-4 shrink-0 text-ink-3" />
|
|
@foreach ($this->crumbs as $crumb)
|
|
@if (! $loop->first)
|
|
<span wire:key="crumb-sep-{{ $loop->index }}" class="font-mono text-xs text-ink-4">/</span>
|
|
@endif
|
|
<button type="button" wire:key="crumb-{{ $loop->index }}" wire:click="go({{ $loop->index }})" @class([
|
|
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
|
|
'text-ink-3' => $loop->last,
|
|
'text-accent-text hover:text-accent' => ! $loop->last,
|
|
])>{{ $crumb['label'] }}</button>
|
|
@endforeach
|
|
</div>
|
|
</x-panel>
|
|
|
|
{{-- Listing --}}
|
|
<x-panel :title="$path" :subtitle="__('files.panel_subtitle', ['dirs' => $dirs, 'files' => $files])" :padded="false">
|
|
<x-slot:actions>
|
|
<label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 text-xs font-medium text-accent-text transition-colors hover:bg-accent/15">
|
|
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="upload" />
|
|
<svg wire:loading wire:target="upload" 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>
|
|
{{ __('files.upload') }}
|
|
<input type="file" wire:model="upload" class="hidden" />
|
|
</label>
|
|
</x-slot:actions>
|
|
|
|
{{-- Column header (wide desktop only — below xl the rows render as stacked cards) --}}
|
|
<div class="hidden border-b border-line px-4 py-2 sm:px-5 xl:grid xl:grid-cols-[minmax(0,1fr)_7rem_5rem_10rem_auto] xl:items-center xl:gap-4">
|
|
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_name') }}</span>
|
|
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_perms') }}</span>
|
|
<span class="text-right font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_size') }}</span>
|
|
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('files.col_modified') }}</span>
|
|
<span class="sr-only">{{ __('common.actions') }}</span>
|
|
</div>
|
|
|
|
<div class="divide-y divide-line" wire:loading.class="opacity-40" wire:target="load,open,go,up">
|
|
@if (! $ready)
|
|
@for ($i = 0; $i < 8; $i++)
|
|
<div class="flex items-center gap-3 px-4 py-3 sm:px-5">
|
|
<x-skeleton class="h-8 w-8 rounded-sm" />
|
|
<x-skeleton class="h-3 w-48 max-w-full" />
|
|
<x-skeleton class="ml-auto hidden h-3 w-24 xl:block" />
|
|
</div>
|
|
@endfor
|
|
@else
|
|
@foreach ($entries as $e)
|
|
@php $isDir = $e['type'] === 'dir'; @endphp
|
|
{{-- Stable wire:key per row: without it Livewire's morph clones nodes by position and
|
|
the inline Alpine x-data (modalTrigger) loses its scope → "pending is not defined". --}}
|
|
{{-- Below xl: a stacked card (name on its own full-width line, meta + actions beneath) so
|
|
the filename is never squeezed by the meta/action columns. xl+: a real table grid. --}}
|
|
<div wire:key="fe-{{ md5($e['type'].'/'.$e['name']) }}"
|
|
class="group px-4 py-3 transition-colors hover:bg-raised sm:px-5 xl:grid xl:grid-cols-[minmax(0,1fr)_7rem_5rem_10rem_auto] xl:items-center xl:gap-4">
|
|
{{-- Name + icon (directories look clickable) --}}
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<span @class([
|
|
'grid h-8 w-8 shrink-0 place-items-center rounded-sm',
|
|
'bg-accent/10 text-accent-text' => $isDir,
|
|
'bg-raised text-ink-3' => ! $isDir,
|
|
])>
|
|
<x-icon :name="$isDir ? 'folder' : 'file'" class="h-4 w-4" />
|
|
</span>
|
|
|
|
@if ($isDir)
|
|
<button type="button" wire:click="open({{ $loop->index }})" class="min-w-0 flex-1 text-left">
|
|
<span class="block truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
|
|
</button>
|
|
@else
|
|
{{-- Filename opens the editor modal too — inline modalTrigger keeps the
|
|
link layout but adds the pending state + timeout error toast. --}}
|
|
<button type="button"
|
|
x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)"
|
|
wire:click="edit({{ $loop->index }})" x-on:click="arm()"
|
|
x-bind:disabled="pending" x-bind:aria-busy="pending"
|
|
class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono text-sm text-ink-2 transition-colors hover:text-accent-text">
|
|
<span class="truncate">{{ $e['name'] }}</span>
|
|
<svg x-show="pending" x-cloak class="h-3 w-3 shrink-0 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>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Meta as real table cells (xl only) --}}
|
|
<p class="hidden truncate font-mono text-xs text-ink-3 xl:block">{{ $e['perms'] }}</p>
|
|
<p class="hidden font-mono text-xs text-ink-3 xl:block xl:text-right">{{ $fmtSize($e['size']) }}</p>
|
|
<p class="hidden truncate font-mono text-xs text-ink-3 xl:block">{{ $e['modified'] }}</p>
|
|
|
|
{{-- Folded meta line under the name (mobile + tablet) --}}
|
|
<p class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 pl-11 font-mono text-[11px] text-ink-4 xl:hidden">
|
|
<span>{{ $e['perms'] }}</span>
|
|
<span class="opacity-50">·</span>
|
|
<span>{{ $fmtSize($e['size']) }}</span>
|
|
<span class="opacity-50">·</span>
|
|
<span>{{ $e['modified'] }}</span>
|
|
</p>
|
|
|
|
{{-- Row actions --}}
|
|
<div class="mt-2.5 flex flex-wrap items-center gap-1.5 pl-11 xl:mt-0 xl:justify-end xl:gap-1 xl:pl-0">
|
|
@unless ($isDir)
|
|
<x-btn variant="secondary" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn>
|
|
<x-modal-trigger variant="secondary" action="edit({{ $loop->index }})">{{ __('common.edit') }}</x-modal-trigger>
|
|
@endunless
|
|
<x-modal-trigger variant="danger-soft" action="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-modal-trigger>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
@endif
|
|
</div>
|
|
</x-panel>
|
|
</div>
|