Files
ladill-transfer/resources/views/transfer/files/index.blade.php
T
isaaccladandCursor 7bf92e2d2c
Deploy Ladill Transfer / deploy (push) Successful in 27s
Create a Ladill folder when uploading a local folder and place files inside it.
Folder upload now names and creates the destination folder from the picked directory, uploads all files into it, and redirects to the new folder view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 22:03:03 +00:00

355 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<x-user-layout>
<x-slot name="title">Files</x-slot>
@php
$iconBase = asset('images/ladill-icons');
$fmtBytes = function (int $bytes) {
if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2).' GB';
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB';
return number_format($bytes / 1024, 0).' KB';
};
$fileRows = $files->map(fn ($file) => [
'id' => $file->id,
'name' => $file->original_name,
'size' => $file->humanSize(),
'downloads' => $file->downloads_total,
'modified' => $file->updated_at?->format('M j, Y') ?? '—',
'shareUrl' => $file->transfer->qrCode?->publicUrl() ?? '',
'downloadUrl' => route('transfer.files.download', $file),
'shared' => $file->transfer->recipient_email !== null || $file->downloads_total > 0,
])->values();
@endphp
<div class="space-y-4"
x-data="filesManager(@js([
'fileIds' => $fileRows->pluck('id')->all(),
'files' => $fileRows->all(),
'csrf' => csrf_token(),
'currentFolderId' => $folderTransfer?->id,
'routes' => [
'bulkDownload' => route('transfer.files.bulk-download'),
'bulkDelete' => route('transfer.files.bulk-delete'),
'move' => route('transfer.files.move'),
'share' => route('transfer.files.share'),
'openInEmail' => route('transfer.files.open-in-email'),
'uploadCheck' => route('transfer.files.upload.check'),
'upload' => route('transfer.files.upload'),
'filesIndex' => route('transfer.files.index'),
'createFolder' => route('transfer.files.folders.store'),
],
'moveTargets' => $moveTargets->map(fn ($t) => [
'id' => $t->id,
'title' => $t->is_root_storage ? 'My files (root)' : $t->title,
])->values()->all(),
]))">
{{-- Header --}}
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Files</h1>
<p class="mt-1 text-sm text-slate-500">{{ $fmtBytes($storageBytes) }} stored (~GHS {{ number_format($storageGb * $pricePerGb, 2) }}/month)</p>
</div>
{{-- Create or upload --}}
<div class="relative" @click.outside="createMenuOpen = false">
<button type="button"
@click="createMenuOpen = !createMenuOpen"
class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-indigo-600 to-violet-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:from-indigo-700 hover:to-violet-700">
<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 4.5v15m7.5-7.5h-15"/></svg>
Create or upload
<svg class="h-4 w-4 opacity-80" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
</button>
<div x-show="createMenuOpen"
x-cloak
x-transition
class="absolute right-0 z-30 mt-2 w-56 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg">
<button type="button" @click="openFolderModal()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50">
<img src="{{ $iconBase }}/folder.svg" alt="" class="h-5 w-5">
<span>Folder</span>
</button>
<div class="my-1 border-t border-slate-100"></div>
<button type="button" @click="triggerFileUpload()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50">
<img src="{{ $iconBase }}/file.svg" alt="" class="h-5 w-5">
<span>Files upload</span>
</button>
<button type="button" @click="triggerFolderUpload()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50">
<span class="relative h-5 w-5">
<img src="{{ $iconBase }}/folder.svg" alt="" class="h-5 w-5">
</span>
<span>Folder upload</span>
</button>
</div>
<input type="file" x-ref="fileUploadInput" multiple class="hidden" @change="handleUploadPick($event)">
<input type="file" x-ref="folderUploadInput" multiple webkitdirectory class="hidden" @change="handleUploadPick($event)">
</div>
</div>
{{-- Breadcrumbs --}}
<nav class="flex flex-wrap items-center gap-1 text-sm text-slate-500">
<a href="{{ route('transfer.files.index') }}" class="font-medium text-indigo-600 hover:text-indigo-800">My files</a>
@if($folderTransfer)
<span>/</span>
<span class="font-medium text-slate-900">{{ $folderTransfer->title }}</span>
@endif
</nav>
{{-- Action toolbar + file list --}}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-2.5">
<template x-if="selected.length === 0">
<div class="flex flex-wrap items-center gap-2 text-sm">
<button type="button" disabled class="rounded-lg px-3 py-1.5 text-slate-400">Download</button>
<button type="button" disabled class="rounded-lg px-3 py-1.5 text-slate-400">Share</button>
<button type="button" disabled class="rounded-lg px-3 py-1.5 text-slate-400">Copy link</button>
<button type="button" disabled class="rounded-lg px-3 py-1.5 text-slate-400">Delete</button>
<button type="button" disabled class="rounded-lg px-3 py-1.5 text-slate-400">Move to</button>
<button type="button" disabled class="rounded-lg px-3 py-1.5 text-slate-400">Open in email</button>
</div>
</template>
<template x-if="selected.length > 0">
<div class="flex flex-wrap items-center gap-2 text-sm">
<button type="button" @click="downloadSelected()" class="rounded-lg px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-100">Download</button>
<button type="button" @click="shareSelected()" class="rounded-lg px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-100">Share</button>
<button type="button" @click="copyLinks()" class="rounded-lg px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-100">Copy link</button>
<button type="button" @click="deleteSelected()" class="rounded-lg px-3 py-1.5 font-medium text-red-700 hover:bg-red-50">Delete</button>
<button type="button" @click="moveModalOpen = true" class="rounded-lg px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-100">Move to</button>
<button type="button" @click="openInEmail()" class="rounded-lg bg-indigo-600 px-3 py-1.5 font-medium text-white hover:bg-indigo-700">Open in email</button>
<span class="ml-2 text-xs text-slate-500" x-text="`${selected.length} selected`"></span>
<button type="button" @click="clearSelection()" class="text-xs text-slate-400 hover:text-slate-600">Clear</button>
</div>
</template>
<div class="ml-auto flex flex-wrap items-center gap-2">
<form method="get" action="{{ route('transfer.files.index') }}" class="flex items-center gap-2">
@if($folderTransfer)
<input type="hidden" name="folder" value="{{ $folderTransfer->id }}">
@endif
<input type="search" name="q" value="{{ $search }}" placeholder="Search files…"
class="w-40 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100 sm:w-52">
<select name="sort" onchange="this.form.submit()" class="min-w-[7.5rem] rounded-lg border border-slate-200 py-1.5 pl-3 pr-8 text-xs text-slate-700">
<option value="newest" @selected($sort === 'newest')>Newest</option>
<option value="oldest" @selected($sort === 'oldest')>Oldest</option>
<option value="name" @selected($sort === 'name')>Name</option>
<option value="size" @selected($sort === 'size')>Size</option>
<option value="downloads" @selected($sort === 'downloads')>Downloads</option>
</select>
</form>
</div>
</div>
@if($files->isEmpty() && ($folderTransfer || $folders->isEmpty() || $search !== ''))
<div class="px-6 py-12 text-center text-sm text-slate-500">
@if($search !== '')
No files match your search.
@elseif($folderTransfer)
This folder is empty. Upload files or move items here.
@else
No files yet. Use Create or upload to add files.
@endif
</div>
@else
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="w-10 px-4 py-3">
<input type="checkbox" @change="toggleAll($event)" :checked="allSelected" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</th>
<th class="px-4 py-3">Name</th>
<th class="hidden px-4 py-3 sm:table-cell">Modified</th>
<th class="px-4 py-3">Size</th>
<th class="hidden px-4 py-3 md:table-cell">Sharing</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{-- User-created folders (root only) --}}
@if(! $folderTransfer && $search === '')
@foreach($folders as $folder)
<tr class="hover:bg-slate-50/80">
<td class="px-4 py-3"></td>
<td class="px-4 py-3" colspan="4">
<a href="{{ route('transfer.files.index', ['folder' => $folder->id]) }}" class="flex items-center gap-3">
@include('transfer.files.partials.folder-list-icon', ['size' => 'xs'])
<div>
<p class="font-medium text-slate-900">{{ $folder->title }}</p>
<p class="text-xs text-slate-500">{{ $folder->files_count }} item{{ $folder->files_count === 1 ? '' : 's' }}</p>
</div>
</a>
</td>
</tr>
@endforeach
@endif
@foreach($files as $file)
<tr class="transition"
:class="selected.includes({{ $file->id }}) ? 'bg-indigo-50/80' : 'hover:bg-slate-50/80'">
<td class="px-4 py-3">
<input type="checkbox" value="{{ $file->id }}" x-model.number="selected" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-3">
@include('transfer.files.partials.file-list-icon', ['size' => 'xs'])
<div class="min-w-0">
<p class="truncate font-medium text-slate-900">{{ $file->original_name }}</p>
</div>
<a href="{{ route('transfer.files.download', $file) }}" class="ml-auto rounded p-1 text-slate-400 hover:text-indigo-600" title="Download">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 7.5 12 3m0 0L7.5 7.5M12 3v13.5"/></svg>
</a>
</div>
</td>
<td class="hidden px-4 py-3 text-slate-600 sm:table-cell">{{ $file->updated_at?->format('M j, Y') ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $file->humanSize() }}</td>
<td class="hidden px-4 py-3 md:table-cell">
@if($file->transfer->recipient_email || $file->downloads_total > 0)
<span class="inline-flex items-center gap-1 text-xs text-slate-600">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
Shared
</span>
@else
<span class="text-xs text-slate-400">Private</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@if($files->isNotEmpty())
<div class="flex items-center justify-between border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
<span>{{ $files->total() }} file{{ $files->total() === 1 ? '' : 's' }}</span>
<span>Showing {{ $files->firstItem() }}{{ $files->lastItem() }}</span>
</div>
@endif
@endif
</div>
@if($files->hasPages())
<div>{{ $files->links() }}</div>
@endif
{{-- Create folder modal --}}
<div x-show="folderModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @keydown.escape.window="folderModalOpen = false">
<div @click.outside="folderModalOpen = false" class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">New folder</h3>
<form method="post" action="{{ route('transfer.files.folders.store') }}" class="mt-4 space-y-4">
@csrf
@if($folderTransfer)
<input type="hidden" name="folder" value="{{ $folderTransfer->id }}">
@endif
<input x-ref="folderNameInput" type="text" name="name" x-model="newFolderName" required maxlength="120" placeholder="Folder name"
class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100">
<div class="flex justify-end gap-2">
<button type="button" @click="folderModalOpen = false" class="rounded-xl px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100">Cancel</button>
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Create</button>
</div>
</form>
</div>
</div>
{{-- Upload confirmation --}}
<div
x-show="uploadConfirmOpen"
x-cloak
class="fixed inset-0 z-[60] flex items-end justify-center bg-black/40 p-4 sm:items-center"
role="dialog"
aria-modal="true"
aria-labelledby="upload-confirm-title"
@keydown.escape.window="uploadConfirmOpen && cancelUploadConfirm()"
>
<div @click.outside="cancelUploadConfirm()" class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl">
<h3 id="upload-confirm-title" class="text-lg font-semibold text-slate-900">
Upload <span x-text="pendingConfirmFiles?.length || 0"></span>
<span x-text="(pendingConfirmFiles?.length || 0) === 1 ? 'file' : 'files'"></span>?
</h3>
<p class="mt-2 text-sm text-slate-600">
<span x-show="uploadConfirmIsFolder">
A folder named
<span class="font-medium text-slate-800">'<span x-text="uploadConfirmFolderName"></span>'</span>
will be created with
<span x-text="pendingConfirmFiles?.length || 0"></span>
<span x-text="(pendingConfirmFiles?.length || 0) === 1 ? 'file' : 'files'"></span>
inside it.
</span>
<span x-show="!uploadConfirmIsFolder">
Selected files will be added to
<span class="font-medium text-slate-800" x-text="currentFolderId ? 'this folder' : 'My files'"></span>.
</span>
</p>
<p class="mt-1 text-xs text-slate-500">
Total size: <span x-text="formatBytes(uploadConfirmTotalBytes())"></span>
</p>
<ul class="mt-4 max-h-36 space-y-1 overflow-y-auto rounded-xl bg-slate-50 px-3 py-2 text-sm">
<template x-for="name in uploadConfirmPreviewNames()" :key="name">
<li class="truncate text-slate-700" x-text="name"></li>
</template>
<li
x-show="uploadConfirmRemainingCount() > 0"
class="text-slate-500"
x-text="'and ' + uploadConfirmRemainingCount() + ' more…'"
></li>
</ul>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
@click="cancelUploadConfirm()"
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100"
>
Cancel
</button>
<button
type="button"
@click="confirmUpload()"
class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
>
Upload
</button>
</div>
</div>
</div>
{{-- Duplicate file modal --}}
<div x-show="duplicateModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl">
<p class="text-sm text-slate-700">
A file with this name already exists so we couldn't upload
<strong x-text="duplicateFile?.filename"></strong>.
</p>
<p class="mt-2 text-sm text-slate-500">Add it as a new version of the existing file, or keep them both.</p>
<div class="mt-5 flex gap-4">
<button type="button" @click="resolveDuplicate('replace')" class="text-sm font-semibold text-indigo-600 hover:text-indigo-800">Replace</button>
<button type="button" @click="resolveDuplicate('keep_both')" class="text-sm font-semibold text-indigo-600 hover:text-indigo-800">Keep both</button>
</div>
</div>
</div>
{{-- Move modal --}}
<div x-show="moveModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @keydown.escape.window="moveModalOpen = false">
<div @click.outside="moveModalOpen = false" class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">Move to folder</h3>
<p class="mt-1 text-sm text-slate-500">Group selected files into a folder you created.</p>
<form method="post" action="{{ route('transfer.files.move') }}" class="mt-4 space-y-4">
@csrf
<template x-for="id in selected" :key="id">
<input type="hidden" name="files[]" :value="id">
</template>
<select name="destination" required class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100">
<option value="">Choose a folder…</option>
@foreach($moveTargets as $target)
<option value="{{ $target->id }}">{{ $target->is_root_storage ? 'My files (root)' : $target->title }}</option>
@endforeach
</select>
<div class="flex justify-end gap-2">
<button type="button" @click="moveModalOpen = false" class="rounded-xl px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100">Cancel</button>
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Move</button>
</div>
</form>
</div>
</div>
{{-- Upload overlay --}}
<div x-show="uploading" x-cloak class="fixed inset-0 z-40 flex items-center justify-center bg-black/20">
<div class="rounded-xl bg-white px-5 py-3 text-sm font-medium text-slate-700 shadow-lg">Uploading…</div>
</div>
{{-- Toast --}}
<div x-show="toast" x-cloak x-transition class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-xl bg-slate-900 px-4 py-2 text-sm text-white shadow-lg lg:bottom-8" x-text="toast"></div>
</div>
</x-user-layout>