341 lines
15 KiB
Plaintext
341 lines
15 KiB
Plaintext
@layouts.main({ title: 'Dashboard — Imago Vault' })
|
|
@slot('main')
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
|
|
{{-- ── Header ──────────────────────────────────────────────────────── --}}
|
|
<header class="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-white">Media Library</h1>
|
|
<p class="text-gray-400 text-sm mt-1">
|
|
{{ files.length }} file{{ files.length !== 1 ? 's' : '' }} stored
|
|
</p>
|
|
</div>
|
|
<a href="/logout"
|
|
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm rounded-lg
|
|
transition border border-gray-700">
|
|
Logout
|
|
</a>
|
|
</header>
|
|
|
|
{{-- ── Upload zone ─────────────────────────────────────────────────── --}}
|
|
<section class="mb-10">
|
|
|
|
<div id="drop-zone"
|
|
class="relative border-2 border-dashed border-gray-700 rounded-2xl p-10
|
|
text-center cursor-pointer transition-all duration-200
|
|
hover:border-indigo-500 hover:bg-indigo-950/20">
|
|
|
|
{{-- Default hint (hidden once a file is selected) --}}
|
|
<div id="drop-hint" class="flex flex-col items-center gap-3 pointer-events-none">
|
|
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
|
</svg>
|
|
<p class="text-gray-400 text-sm">
|
|
Drag & drop a file here, or
|
|
<label for="file-input"
|
|
class="text-indigo-400 hover:text-indigo-300 cursor-pointer underline underline-offset-2">
|
|
click to browse
|
|
</label>
|
|
</p>
|
|
<p class="text-gray-600 text-xs">
|
|
Images (JPG, PNG, GIF, WebP) · Videos (MP4, MOV, AVI, WebM) · Max 100 MB
|
|
</p>
|
|
</div>
|
|
|
|
{{-- Preview area (shown after file selection) --}}
|
|
<div id="preview-container" class="hidden mt-2">
|
|
<img id="image-preview"
|
|
class="hidden max-h-64 mx-auto rounded-lg object-contain"
|
|
alt="Preview" />
|
|
<video id="video-preview"
|
|
class="hidden max-h-64 mx-auto rounded-lg"
|
|
controls muted playsinline></video>
|
|
<p id="preview-filename" class="text-gray-300 text-sm mt-3 font-medium"></p>
|
|
<p id="preview-size" class="text-gray-500 text-xs mt-1"></p>
|
|
</div>
|
|
|
|
{{-- Invisible file input covers the whole zone --}}
|
|
<input type="file"
|
|
id="file-input"
|
|
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/x-msvideo,video/webm"
|
|
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full" />
|
|
</div>
|
|
|
|
{{-- Progress bar (shown during upload) --}}
|
|
<div id="progress-wrapper" class="hidden mt-4">
|
|
<div class="flex items-center justify-between text-sm mb-2">
|
|
<span id="progress-label" class="text-gray-400">Uploading…</span>
|
|
<span id="progress-percent" class="text-indigo-400 font-mono font-medium">0%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-800 rounded-full h-2 overflow-hidden">
|
|
<div id="progress-bar"
|
|
class="h-full bg-indigo-500 rounded-full transition-all duration-100"
|
|
style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Action buttons (shown after file selection) --}}
|
|
<div id="upload-actions" class="hidden flex gap-3 mt-4">
|
|
<button id="upload-btn"
|
|
class="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold
|
|
rounded-lg text-sm transition focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
|
Upload File
|
|
</button>
|
|
<button id="cancel-btn"
|
|
class="px-6 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-300
|
|
rounded-lg text-sm transition border border-gray-700">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
{{-- Inline result message --}}
|
|
<div id="upload-result" class="hidden mt-4 px-4 py-3 rounded-lg text-sm font-medium"></div>
|
|
|
|
</section>
|
|
|
|
{{-- ── Gallery ──────────────────────────────────────────────────────── --}}
|
|
<section>
|
|
<h2 class="text-lg font-semibold text-gray-200 mb-4">Uploaded Files</h2>
|
|
|
|
@if(files.length === 0)
|
|
<div class="text-center py-20 text-gray-600">
|
|
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14
|
|
m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
</svg>
|
|
<p>No files uploaded yet. Drop one above to get started.</p>
|
|
</div>
|
|
@else
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
@each(file in files)
|
|
<div class="bg-gray-900 rounded-xl overflow-hidden border border-gray-800
|
|
hover:border-gray-700 transition group">
|
|
|
|
{{-- Media preview --}}
|
|
@if(file.type === 'image')
|
|
<div class="aspect-video bg-gray-950 overflow-hidden">
|
|
<img src="{{ file.url }}"
|
|
alt="{{ file.filename }}"
|
|
loading="lazy"
|
|
class="w-full h-full object-cover group-hover:scale-105 transition duration-300" />
|
|
</div>
|
|
@elseif(file.type === 'video')
|
|
<div class="aspect-video bg-black">
|
|
<video src="{{ file.url }}"
|
|
class="w-full h-full object-contain"
|
|
controls preload="metadata" muted playsinline></video>
|
|
</div>
|
|
@else
|
|
<div class="aspect-video bg-gray-950 flex items-center justify-center">
|
|
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1
|
|
0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
</div>
|
|
@end
|
|
|
|
{{-- Info row --}}
|
|
<div class="p-3">
|
|
<p class="text-gray-300 text-xs font-medium truncate mb-2"
|
|
title="{{ file.filename }}">
|
|
{{ file.filename }}
|
|
</p>
|
|
<div class="flex items-center gap-2">
|
|
<button onclick="copyUrl('{{ file.url }}')"
|
|
class="flex-1 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-gray-300
|
|
text-xs rounded-lg transition flex items-center gap-1.5 justify-center">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2
|
|
m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
Copy URL
|
|
</button>
|
|
<a href="/api/media/{{ file.filename }}" target="_blank" rel="noopener"
|
|
class="px-3 py-1.5 bg-indigo-900/40 hover:bg-indigo-900/60 text-indigo-400
|
|
text-xs rounded-lg transition">
|
|
Open
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
@end
|
|
</div>
|
|
@end
|
|
</section>
|
|
</div>
|
|
|
|
{{-- ── JavaScript ───────────────────────────────────────────────────── --}}
|
|
<script>
|
|
// CSRF token injected server-side — sent as a header on the XHR upload request
|
|
const CSRF_TOKEN = '{{ csrfToken }}'
|
|
|
|
const dropZone = document.getElementById('drop-zone')
|
|
const fileInput = document.getElementById('file-input')
|
|
const dropHint = document.getElementById('drop-hint')
|
|
const previewCont = document.getElementById('preview-container')
|
|
const imagePreview = document.getElementById('image-preview')
|
|
const videoPreview = document.getElementById('video-preview')
|
|
const previewName = document.getElementById('preview-filename')
|
|
const previewSize = document.getElementById('preview-size')
|
|
const uploadActions = document.getElementById('upload-actions')
|
|
const uploadBtn = document.getElementById('upload-btn')
|
|
const cancelBtn = document.getElementById('cancel-btn')
|
|
const progressWrap = document.getElementById('progress-wrapper')
|
|
const progressBar = document.getElementById('progress-bar')
|
|
const progressPct = document.getElementById('progress-percent')
|
|
const uploadResult = document.getElementById('upload-result')
|
|
|
|
let selectedFile = null
|
|
|
|
// ── Drag & drop ─────────────────────────────────────────────────────
|
|
dropZone.addEventListener('dragover', (e) => {
|
|
e.preventDefault()
|
|
dropZone.classList.add('border-indigo-500', 'bg-indigo-950/20')
|
|
})
|
|
dropZone.addEventListener('dragleave', () => {
|
|
dropZone.classList.remove('border-indigo-500', 'bg-indigo-950/20')
|
|
})
|
|
dropZone.addEventListener('drop', (e) => {
|
|
e.preventDefault()
|
|
dropZone.classList.remove('border-indigo-500', 'bg-indigo-950/20')
|
|
const file = e.dataTransfer.files[0]
|
|
if (file) handleFileSelected(file)
|
|
})
|
|
|
|
fileInput.addEventListener('change', () => {
|
|
if (fileInput.files[0]) handleFileSelected(fileInput.files[0])
|
|
})
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) return bytes + ' B'
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
|
return (bytes / 1048576).toFixed(1) + ' MB'
|
|
}
|
|
|
|
function handleFileSelected(file) {
|
|
selectedFile = file
|
|
imagePreview.classList.add('hidden')
|
|
videoPreview.classList.add('hidden')
|
|
|
|
if (file.type.startsWith('image/')) {
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => {
|
|
imagePreview.src = e.target.result
|
|
imagePreview.classList.remove('hidden')
|
|
}
|
|
reader.readAsDataURL(file)
|
|
} else if (file.type.startsWith('video/')) {
|
|
videoPreview.src = URL.createObjectURL(file)
|
|
videoPreview.classList.remove('hidden')
|
|
}
|
|
|
|
previewName.textContent = file.name
|
|
previewSize.textContent = formatBytes(file.size)
|
|
dropHint.classList.add('hidden')
|
|
previewCont.classList.remove('hidden')
|
|
uploadActions.classList.remove('hidden')
|
|
uploadResult.classList.add('hidden')
|
|
resetProgress()
|
|
}
|
|
|
|
function resetProgress() {
|
|
progressWrap.classList.add('hidden')
|
|
progressBar.style.width = '0%'
|
|
progressPct.textContent = '0%'
|
|
}
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
selectedFile = null
|
|
fileInput.value = ''
|
|
dropHint.classList.remove('hidden')
|
|
previewCont.classList.add('hidden')
|
|
uploadActions.classList.add('hidden')
|
|
uploadResult.classList.add('hidden')
|
|
resetProgress()
|
|
if (videoPreview.src) {
|
|
URL.revokeObjectURL(videoPreview.src)
|
|
videoPreview.src = ''
|
|
}
|
|
})
|
|
|
|
uploadBtn.addEventListener('click', () => {
|
|
if (!selectedFile) return
|
|
doUpload(selectedFile)
|
|
})
|
|
|
|
function doUpload(file) {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const xhr = new XMLHttpRequest()
|
|
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
const pct = Math.round((e.loaded / e.total) * 100)
|
|
progressBar.style.width = pct + '%'
|
|
progressPct.textContent = pct + '%'
|
|
}
|
|
})
|
|
|
|
xhr.addEventListener('load', () => {
|
|
let data = {}
|
|
try { data = JSON.parse(xhr.responseText) } catch {}
|
|
|
|
progressWrap.classList.add('hidden')
|
|
uploadActions.classList.add('hidden')
|
|
uploadResult.classList.remove('hidden')
|
|
|
|
if (xhr.status === 200 && data.success) {
|
|
uploadResult.className =
|
|
'mt-4 px-4 py-3 rounded-lg text-sm font-medium bg-green-900/40 border border-green-700 text-green-300'
|
|
uploadResult.textContent = 'Uploaded: ' + data.filename
|
|
// Reload the gallery so the new file appears
|
|
setTimeout(() => window.location.reload(), 1200)
|
|
} else {
|
|
uploadResult.className =
|
|
'mt-4 px-4 py-3 rounded-lg text-sm font-medium bg-red-900/40 border border-red-700 text-red-300'
|
|
uploadResult.textContent = data.message || 'Upload failed. Please try again.'
|
|
uploadBtn.disabled = false
|
|
}
|
|
})
|
|
|
|
xhr.addEventListener('error', () => {
|
|
progressWrap.classList.add('hidden')
|
|
uploadResult.classList.remove('hidden')
|
|
uploadResult.className =
|
|
'mt-4 px-4 py-3 rounded-lg text-sm font-medium bg-red-900/40 border border-red-700 text-red-300'
|
|
uploadResult.textContent = 'Network error. Please check your connection and try again.'
|
|
uploadBtn.disabled = false
|
|
})
|
|
|
|
xhr.open('POST', '/upload')
|
|
xhr.setRequestHeader('X-CSRF-TOKEN', CSRF_TOKEN)
|
|
uploadBtn.disabled = true
|
|
previewCont.classList.add('hidden')
|
|
progressWrap.classList.remove('hidden')
|
|
resetProgress()
|
|
xhr.send(formData)
|
|
}
|
|
|
|
// ── Copy URL to clipboard ─────────────────────────────────────────
|
|
function copyUrl(url) {
|
|
const fullUrl = window.location.origin + url
|
|
navigator.clipboard.writeText(fullUrl).then(() => {
|
|
const btn = event.currentTarget
|
|
const orig = btn.innerHTML
|
|
btn.textContent = 'Copied!'
|
|
btn.classList.add('text-green-400')
|
|
setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('text-green-400') }, 1500)
|
|
}).catch(() => {
|
|
prompt('Copy this URL:', window.location.origin + url)
|
|
})
|
|
}
|
|
</script>
|
|
@endslot
|
|
@end
|