Files
Imago-Vault/resources/views/dashboard.edge
2026-05-04 14:16:59 +02:00

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 &amp; 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