Files
isaaccladandCursor 01325d3edf
Deploy Ladill Hosting / deploy (push) Successful in 27s
Fix file manager select-all and delete actions.
Correct a JavaScript syntax error that prevented the file manager Alpine component from loading, and include the confirm modal in the hosting panel layout so delete prompts render.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 13:54:18 +00:00

783 lines
52 KiB
PHP

<x-hosting-panel-layout :account="$account">
<x-slot name="title">File Manager - {{ $account->username }}</x-slot>
<x-slot name="header">File Manager</x-slot>
<div x-data="fileManager()" class="space-y-6">
{{-- Action Feedback --}}
<template x-teleport="body">
<div class="fixed right-4 top-4 z-[70] w-[calc(100%-2rem)] max-w-sm space-y-2 sm:right-6 sm:top-6 sm:w-full" aria-live="polite" aria-atomic="true">
<template x-for="toast in toasts" :key="toast.id">
<div
x-show="toast.visible"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-y-2 opacity-0 sm:translate-x-2 sm:translate-y-0"
x-transition:enter-end="translate-y-0 opacity-100 sm:translate-x-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="rounded-xl border bg-white p-4 shadow-lg"
:class="toast.type === 'success' ? 'border-emerald-200' : (toast.type === 'error' ? 'border-red-200' : 'border-indigo-200')"
>
<div class="flex items-start gap-3">
<div class="mt-0.5 rounded-full p-1"
:class="toast.type === 'success' ? 'bg-emerald-100 text-emerald-600' : (toast.type === 'error' ? 'bg-red-100 text-red-600' : 'bg-indigo-100 text-indigo-600')">
<svg x-show="toast.type === 'success'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
<svg x-show="toast.type === 'error'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
<svg x-show="toast.type !== 'success' && toast.type !== 'error'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"/></svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-slate-900" x-text="toast.title"></p>
<p class="mt-0.5 text-sm text-slate-600" x-text="toast.message"></p>
</div>
<button type="button" @click="dismissToast(toast.id)" class="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
</template>
</div>
</template>
<div x-show="actionStatus.message" x-cloak class="rounded-xl border p-4"
:class="actionStatus.type === 'success' ? 'border-emerald-200 bg-emerald-50' : (actionStatus.type === 'error' ? 'border-red-200 bg-red-50' : 'border-indigo-200 bg-indigo-50')">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold"
:class="actionStatus.type === 'success' ? 'text-emerald-800' : (actionStatus.type === 'error' ? 'text-red-800' : 'text-indigo-800')"
x-text="actionStatus.title"></p>
<p class="mt-0.5 text-sm"
:class="actionStatus.type === 'success' ? 'text-emerald-700' : (actionStatus.type === 'error' ? 'text-red-700' : 'text-indigo-700')"
x-text="actionStatus.message"></p>
</div>
<button type="button" @click="clearStatus()" class="rounded-md p-1 text-slate-400 hover:bg-white/70 hover:text-slate-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
{{-- Toolbar --}}
<div class="flex flex-wrap items-center gap-3 rounded-xl border border-slate-200 bg-white p-4">
<button @click="createModal = true; createType = 'folder'" class="btn-primary">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"/></svg>
New Folder
</button>
<button @click="createModal = true; createType = 'file'" class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
New File
</button>
<label class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition cursor-pointer">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
Upload
<input type="file" class="hidden" @change="uploadFile($event)" multiple>
</label>
</div>
{{-- Upload Progress --}}
<div x-show="uploading" x-cloak class="rounded-xl border border-indigo-200 bg-indigo-50 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-indigo-700">Uploading <span x-text="uploadFileName"></span></span>
<span class="text-sm font-semibold text-indigo-700" x-text="uploadProgress + '%'"></span>
</div>
<div class="h-2 w-full rounded-full bg-indigo-100 overflow-hidden">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width: ' + uploadProgress + '%'"></div>
</div>
<p x-show="uploadProgress >= 100" class="mt-2 text-xs text-indigo-600">Processing file on server...</p>
</div>
{{-- Extraction Progress --}}
<div x-show="extracting" x-cloak class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-emerald-600 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div>
<span class="text-sm font-medium text-emerald-700">Extracting <span x-text="extractFileName"></span></span>
<p class="text-xs text-emerald-600 mt-0.5">This may take a while for large archives...</p>
</div>
</div>
</div>
{{-- Bulk Action Bar --}}
<div x-show="selected.length > 0" x-cloak class="flex flex-wrap items-center gap-2 rounded-xl border border-indigo-200 bg-indigo-50 p-3">
<span class="text-sm font-medium text-indigo-700 mr-1" x-text="selected.length + ' selected'"></span>
<div class="h-5 w-px bg-indigo-200"></div>
<button @click="bulkAction('move')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
Move
</button>
<button @click="bulkAction('copy')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"/></svg>
Copy
</button>
<button @click="bulkAction('compress')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/></svg>
Compress
</button>
<button @click="bulkAction('chmod')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
Permissions
</button>
<button @click="bulkAction('delete')" class="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 border border-red-200 hover:bg-red-100 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
Delete
</button>
<div class="ml-auto">
<button @click="selected = []" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">Clear</button>
</div>
</div>
{{-- Breadcrumbs --}}
<div class="flex items-center gap-2 text-sm">
@foreach($breadcrumbs as $index => $crumb)
@if($index > 0)
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
@endif
<a href="{{ route('hosting.panel.files', ['account' => $account, 'path' => $crumb['path']]) }}" class="text-slate-600 hover:text-indigo-600 transition {{ $loop->last ? 'font-medium text-slate-900' : '' }}">{{ $crumb['name'] }}</a>
@endforeach
</div>
{{-- File Table --}}
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="w-10 px-4 py-3"><input type="checkbox" @change="toggleAll($event)" :checked="selected.length === allPaths.length && allPaths.length > 0" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"></th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Size</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Modified</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@forelse($files as $file)
<tr class="hover:bg-slate-50 transition" :class="{ 'bg-indigo-50/50': selected.includes('{{ $file['path'] }}') }">
<td class="w-10 px-4 py-3"><input type="checkbox" value="{{ $file['path'] }}" x-model="selected" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"></td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-3">
@if($file['type'] === 'folder')
<svg class="h-5 w-5 text-amber-500 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M19.5 21a3 3 0 003-3v-4.5a3 3 0 00-3-3h-15a3 3 0 00-3 3V18a3 3 0 003 3h15zM1.5 10.146V6a3 3 0 013-3h5.379a2.25 2.25 0 011.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 013 3v1.146A4.483 4.483 0 0019.5 9h-15a4.483 4.483 0 00-3 1.146z"/></svg>
<a href="{{ route('hosting.panel.files', ['account' => $account, 'path' => $file['path']]) }}" class="text-sm font-medium text-slate-900 hover:text-indigo-600">{{ $file['name'] }}</a>
@else
<svg class="h-5 w-5 text-slate-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
<span class="text-sm text-slate-900">{{ $file['name'] }}</span>
@endif
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-slate-500">{{ $file['size_formatted'] }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-slate-500">{{ $file['modified'] }}</td>
<td class="px-4 py-3 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1">
@if($file['type'] === 'file')
@if(in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['zip', 'tar', 'tgz']) || str_ends_with(strtolower($file['name']), '.tar.gz') || str_ends_with(strtolower($file['name']), '.tar.bz2'))
<button @click="extractFile('{{ $file['path'] }}', '{{ $file['name'] }}')" class="rounded px-2 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-300 hover:bg-emerald-50 transition" title="Unzip / Extract archive">
Unzip
</button>
@endif
<button @click="editFile('{{ $file['path'] }}')" class="p-1.5 text-slate-400 hover:text-indigo-600 transition" title="Edit">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"/></svg>
</button>
@endif
<button @click="renameFile('{{ $file['path'] }}', '{{ $file['name'] }}')" class="p-1.5 text-slate-400 hover:text-amber-600 transition" title="Rename">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"/></svg>
</button>
<button @click="deleteFile('{{ $file['path'] }}')" class="p-1.5 text-slate-400 hover:text-red-600 transition" title="Delete">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-6 py-12 text-center text-sm text-slate-500">This folder is empty</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- Create Modal --}}
<template x-teleport="body">
<div x-show="createModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="createModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900" x-text="'Create New ' + (createType === 'folder' ? 'Folder' : 'File')"></h3>
<form @submit.prevent="submitCreate">
<input type="text" x-model="createName" placeholder="Enter name..." class="mt-4 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="createModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="btn-primary" :disabled="loading">Create</button>
</div>
</form>
</div>
</div>
</template>
{{-- Edit Modal --}}
<template x-teleport="body">
<div x-show="editModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-4xl sm:h-[80vh] h-full sm:rounded-2xl bg-white shadow-xl flex flex-col">
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<h3 class="text-lg font-semibold text-slate-900" x-text="'Editing: ' + editPath"></h3>
<button @click="editModal = false" class="p-1 text-slate-400 hover:text-slate-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button>
</div>
<div class="flex-1 p-4"><textarea x-model="editContent" class="w-full h-full font-mono text-sm border border-slate-200 rounded-lg p-4 focus:border-indigo-500 focus:ring-indigo-500 resize-none"></textarea></div>
<div class="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
<button @click="editModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button @click="saveEditedFile" class="btn-primary" :disabled="loading">Save</button>
</div>
</div>
</div>
</template>
{{-- Rename Modal --}}
<template x-teleport="body">
<div x-show="renameModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="renameModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">Rename</h3>
<form @submit.prevent="submitRename">
<input type="text" x-model="renameName" class="mt-4 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="renameModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="btn-primary" :disabled="loading">Rename</button>
</div>
</form>
</div>
</div>
</template>
{{-- Move/Copy Modal --}}
<template x-teleport="body">
<div x-show="moveModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="moveModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900" x-text="moveAction === 'move' ? 'Move Items' : 'Copy Items'"></h3>
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
<form @submit.prevent="submitMoveCopy">
<label class="mt-4 block text-sm font-medium text-slate-700">Destination path</label>
<input type="text" x-model="moveDestination" placeholder="/public_html" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500" required>
<p class="mt-1 text-xs text-slate-400">Relative to home directory, e.g. /public_html/backup</p>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="moveModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="btn-primary" :disabled="loading" x-text="moveAction === 'move' ? 'Move' : 'Copy'"></button>
</div>
</form>
</div>
</div>
</template>
{{-- Compress Modal --}}
<template x-teleport="body">
<div x-show="compressModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="compressModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">Compress to Zip</h3>
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
<form @submit.prevent="submitCompress">
<label class="mt-4 block text-sm font-medium text-slate-700">Archive name</label>
<input type="text" x-model="archiveName" placeholder="archive.zip" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="compressModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="btn-primary" :disabled="loading">Compress</button>
</div>
</form>
</div>
</div>
</template>
{{-- Chmod Modal --}}
<template x-teleport="body">
<div x-show="chmodModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="chmodModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">Change Permissions</h3>
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
<form @submit.prevent="submitChmod">
<label class="mt-4 block text-sm font-medium text-slate-700">Permissions (octal)</label>
<input type="text" x-model="chmodValue" placeholder="755" maxlength="4" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" @click="chmodValue = '644'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">644</button>
<button type="button" @click="chmodValue = '755'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">755</button>
<button type="button" @click="chmodValue = '775'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">775</button>
<button type="button" @click="chmodValue = '777'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">777</button>
<button type="button" @click="chmodValue = '600'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">600</button>
</div>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="chmodModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="btn-primary" :disabled="loading">Apply</button>
</div>
</form>
</div>
</div>
</template>
</div>
@push('scripts')
<script>
function fileManager() {
return {
loading: false,
selected: [],
allPaths: @json(collect($files)->pluck('path')->values()),
toasts: [],
toastId: 0,
actionStatus: { type: '', title: '', message: '' },
// Create
createModal: false,
createType: 'folder',
createName: '',
// Edit
editModal: false,
editPath: '',
editContent: '',
// Rename
renameModal: false,
renamePath: '',
renameName: '',
// Upload
uploadProgress: 0,
uploadFileName: '',
uploading: false,
// Move/Copy
moveModal: false,
moveAction: 'move',
moveDestination: '',
// Compress
compressModal: false,
archiveName: 'archive.zip',
// Chmod
chmodModal: false,
chmodValue: '755',
// Extract
extracting: false,
extractFileName: '',
init() {
const stored = sessionStorage.getItem('fileManagerToast');
if (!stored) return;
sessionStorage.removeItem('fileManagerToast');
try {
const toast = JSON.parse(stored);
this.notify(toast.type || 'success', toast.title || 'Done', toast.message || 'Action completed.');
} catch (e) {
this.notify('success', 'Done', stored);
}
},
notify(type, title, message, options = {}) {
const toast = {
id: ++this.toastId,
type,
title,
message,
visible: true,
};
this.toasts.push(toast);
this.actionStatus = { type, title, message };
window.setTimeout(() => this.dismissToast(toast.id), options.duration || 4500);
},
dismissToast(id) {
const toast = this.toasts.find(item => item.id === id);
if (toast) toast.visible = false;
window.setTimeout(() => {
this.toasts = this.toasts.filter(item => item.id !== id);
}, 200);
},
clearStatus() {
this.actionStatus = { type: '', title: '', message: '' };
},
rememberToast(type, title, message) {
sessionStorage.setItem('fileManagerToast', JSON.stringify({ type, title, message }));
},
reloadWithToast(type, title, message) {
this.rememberToast(type, title, message);
location.reload();
},
async parseJsonResponse(res, fallbackMessage) {
let data = {};
try {
data = await res.json();
} catch (e) {
data = {};
}
if (!res.ok || data.success === false) {
throw new Error(data.error || data.message || fallbackMessage || 'Operation failed');
}
return data;
},
errorMessage(error, fallback) {
return error?.message || fallback || 'Operation failed';
},
toggleAll(event) {
this.selected = event.target.checked ? [...this.allPaths] : [];
},
async bulkAction(action) {
if (action === 'delete') {
if (!await this.$store.ladillConfirm.ask({ title: 'Delete items?', message: 'Delete ' + this.selected.length + ' item(s)? This cannot be undone.' })) return;
this.submitBulkDelete();
} else if (action === 'move' || action === 'copy') {
this.moveAction = action;
this.moveDestination = '{{ $currentPath }}';
this.moveModal = true;
} else if (action === 'compress') {
this.archiveName = 'archive.zip';
this.compressModal = true;
} else if (action === 'chmod') {
this.chmodValue = '755';
this.chmodModal = true;
}
},
async submitBulkDelete() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.bulk-delete", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected })
});
const data = await this.parseJsonResponse(res, 'Delete failed');
this.reloadWithToast('success', 'Items deleted', data.message || 'Selected items were deleted.');
} catch (e) {
this.notify('error', 'Delete failed', this.errorMessage(e, 'Delete failed'));
} finally { this.loading = false; }
},
async submitMoveCopy() {
this.loading = true;
const url = this.moveAction === 'move'
? '{{ route("hosting.panel.files.bulk-move", $account) }}'
: '{{ route("hosting.panel.files.bulk-copy", $account) }}';
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected, destination: this.moveDestination })
});
const data = await this.parseJsonResponse(res, 'Operation failed');
this.reloadWithToast('success', this.moveAction === 'move' ? 'Items moved' : 'Items copied', data.message || 'Selected items were updated.');
} catch (e) {
this.notify('error', 'Operation failed', this.errorMessage(e, 'Operation failed'));
} finally { this.loading = false; this.moveModal = false; }
},
async submitCompress() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.bulk-compress", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected, archive_name: this.archiveName, base_path: '{{ $currentPath }}' })
});
const data = await this.parseJsonResponse(res, 'Compression failed');
this.reloadWithToast('success', 'Archive created', data.message || 'Selected items were compressed.');
} catch (e) {
this.notify('error', 'Compression failed', this.errorMessage(e, 'Compression failed'));
} finally { this.loading = false; this.compressModal = false; }
},
async submitChmod() {
if (!/^[0-7]{3,4}$/.test(this.chmodValue)) {
this.notify('error', 'Invalid permissions', 'Use 3 or 4 octal digits, for example 755.');
return;
}
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.chmod", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected, permissions: this.chmodValue })
});
const data = await this.parseJsonResponse(res, 'Failed to change permissions');
this.chmodModal = false;
this.selected = [];
this.notify('success', 'Permissions updated', data.message || 'Selected permissions were updated.');
} catch (e) {
this.notify('error', 'Permission update failed', this.errorMessage(e, 'Failed to change permissions'));
} finally { this.loading = false; }
},
async submitCreate() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.create", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path: '{{ $currentPath }}', name: this.createName, type: this.createType })
});
const data = await this.parseJsonResponse(res, 'Failed to create');
const label = this.createType === 'folder' ? 'Folder created' : 'File created';
this.reloadWithToast('success', label, data.message || `${this.createName} was created.`);
} catch (e) {
this.notify('error', 'Create failed', this.errorMessage(e, 'Failed to create'));
} finally { this.loading = false; }
},
async editFile(path) {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.read", $account) }}?path=' + encodeURIComponent(path));
const data = await this.parseJsonResponse(res, 'Failed to read file');
this.editPath = path;
this.editContent = data.content;
this.editModal = true;
} catch (e) {
this.notify('error', 'File could not be opened', this.errorMessage(e, 'Failed to read file'));
} finally { this.loading = false; }
},
async saveEditedFile() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.save", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path: this.editPath, content: this.editContent })
});
const data = await this.parseJsonResponse(res, 'Failed to save');
this.editModal = false;
this.notify('success', 'File saved', data.message || `${this.editPath} was saved.`);
} catch (e) {
this.notify('error', 'Save failed', this.errorMessage(e, 'Failed to save'));
} finally { this.loading = false; }
},
renameFile(path, name) { this.renamePath = path; this.renameName = name; this.renameModal = true; },
async submitRename() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.rename", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path: this.renamePath, newName: this.renameName })
});
const data = await this.parseJsonResponse(res, 'Failed to rename');
this.reloadWithToast('success', 'Item renamed', data.message || `${this.renameName} was renamed.`);
} catch (e) {
this.notify('error', 'Rename failed', this.errorMessage(e, 'Failed to rename'));
} finally { this.loading = false; }
},
async deleteFile(path) {
if (!await this.$store.ladillConfirm.ask({ title: 'Delete item?', message: 'Are you sure you want to delete this?' })) return;
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.delete", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path })
});
const data = await this.parseJsonResponse(res, 'Failed to delete');
this.reloadWithToast('success', 'Item deleted', data.message || 'The item was deleted.');
} catch (e) {
this.notify('error', 'Delete failed', this.errorMessage(e, 'Failed to delete'));
} finally { this.loading = false; }
},
async extractFile(path, name) {
if (!await this.$store.ladillConfirm.ask({ title: 'Extract archive?', message: 'Extract "' + name + '" in the current directory?\n\nThis may take a while for large archives.', confirmLabel: 'Extract', variant: 'primary' })) return;
this.loading = true;
this.extracting = true;
this.extractFileName = name;
try {
const res = await fetch('{{ route("hosting.panel.files.extract", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path })
});
const data = await this.parseJsonResponse(res, 'Failed to extract');
const msg = data.total_files
? `Extracted ${data.extracted_files || data.total_files} files successfully.`
: (data.message || 'Archive extracted successfully.');
this.reloadWithToast('success', 'Archive extracted', msg);
} catch (e) {
this.notify('error', 'Extract failed', this.errorMessage(e, 'Failed to extract'));
}
finally {
this.loading = false;
this.extracting = false;
this.extractFileName = '';
}
},
async uploadFile(event) {
const files = event.target.files;
if (!files.length) return;
this.loading = true;
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
const CHUNKED_THRESHOLD = 5 * 1024 * 1024; // Use chunked upload for files > 5MB
let completedUploads = 0;
let failedUploads = 0;
for (const file of files) {
this.uploading = true;
this.uploadFileName = file.name;
this.uploadProgress = 0;
try {
if (file.size > CHUNKED_THRESHOLD) {
// Chunked upload for large files
await this.uploadFileChunked(file, CHUNK_SIZE);
} else {
// Regular upload for small files
await this.uploadFileRegular(file);
}
completedUploads++;
} catch (e) {
failedUploads++;
console.error('Upload error:', e);
this.notify('error', 'Upload failed', e.message || 'Unknown error');
}
}
this.uploading = false;
this.loading = false;
event.target.value = '';
if (completedUploads > 0) {
const type = failedUploads > 0 ? 'info' : 'success';
const title = failedUploads > 0 ? 'Upload partially complete' : 'Upload complete';
const message = failedUploads > 0
? `${completedUploads} file(s) uploaded, ${failedUploads} failed.`
: (files.length === 1 ? `${files[0].name} was uploaded.` : `${files.length} files were uploaded.`);
this.reloadWithToast(type, title, message);
} else {
this.notify('error', 'Upload failed', 'No files were uploaded.');
}
},
async uploadFileRegular(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
formData.append('path', '{{ $currentPath }}');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) this.uploadProgress = Math.round((e.loaded / e.total) * 100);
});
xhr.addEventListener('load', () => {
try {
const data = JSON.parse(xhr.responseText);
if (!data.success) {
reject(new Error(data.error || 'Failed to upload ' + file.name));
return;
}
} catch (e) {
if (xhr.status !== 200) {
reject(new Error('Upload failed (HTTP ' + xhr.status + ')'));
return;
}
reject(new Error('Upload failed: invalid server response'));
return;
}
resolve();
});
xhr.addEventListener('error', () => { reject(new Error('Network error')); });
xhr.addEventListener('abort', () => { reject(new Error('Upload cancelled')); });
xhr.open('POST', '{{ route("hosting.panel.files.upload", $account) }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
});
},
async uploadFileChunked(file, chunkSize) {
const totalChunks = Math.ceil(file.size / chunkSize);
const maxRetries = 3;
// Step 1: Initialize upload session
const initRes = await fetch('{{ route("hosting.panel.files.upload.init", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({
filename: file.name,
filesize: file.size,
path: '{{ $currentPath }}'
})
});
const initData = await initRes.json();
if (!initData.success) throw new Error(initData.error || 'Failed to initialize upload');
const uploadId = initData.upload_id;
// Step 2: Upload chunks with retry logic
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const expectedSize = end - start;
let success = false;
let lastError = null;
for (let attempt = 0; attempt < maxRetries && !success; attempt++) {
try {
const formData = new FormData();
formData.append('upload_id', uploadId);
formData.append('chunk_index', i);
formData.append('chunk_size', expectedSize);
formData.append('chunk', chunk, `chunk_${i}`);
const chunkRes = await fetch('{{ route("hosting.panel.files.upload.chunk", $account) }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: formData
});
if (!chunkRes.ok) {
throw new Error(`HTTP ${chunkRes.status}`);
}
const chunkData = await chunkRes.json();
if (!chunkData.success) {
throw new Error(chunkData.error || 'Upload failed');
}
success = true;
} catch (e) {
lastError = e;
if (attempt < maxRetries - 1) {
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}
if (!success) {
throw new Error(`Failed to upload chunk ${i + 1}/${totalChunks} after ${maxRetries} attempts: ${lastError?.message}`);
}
// Update progress
this.uploadProgress = Math.round(((i + 1) / totalChunks) * 100);
}
// Step 3: Finalize upload
const finalRes = await fetch('{{ route("hosting.panel.files.upload.finalize", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({
upload_id: uploadId,
total_chunks: totalChunks
})
});
const finalData = await finalRes.json();
if (!finalData.success) throw new Error(finalData.error || 'Failed to finalize upload');
}
};
}
</script>
@endpush
</x-hosting-panel-layout>