feat: immersive lightbox preview + remove upload HUD

- Preview modal redesigned: fullscreen dark immersive lightbox (no white
  shell) — frosted gradient top bar (filename + position + close), centered
  media with soft shadow, frosted bottom bar (mime + blue Download pill),
  translucent circular nav buttons, mobile touch-swipe navigation
- Upload progress HUD (bottom-right panel) removed; upload progress now
  surfaces via the existing toast (x/y uploading → ✓ uploaded); cancelAll /
  hud state dropped
- CSS: lightbox-* replaces preview-* and upload-hud blocks; zero leftover refs
This commit is contained in:
OpenClaw Sub-agent
2026-08-03 17:16:04 +10:00
parent ca932b995e
commit cdfe7beac8
+224 -283
View File
@@ -327,38 +327,100 @@
}
.apple-toast svg { width: 1rem; height: 1rem; color: #30d158; flex: none; }
/* ---------- Upload HUD (frosted card) ---------- */
.upload-hud {
position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 9999;
width: 20rem; max-width: calc(100vw - 2rem);
background: rgba(255, 255, 255, 0.88);
-webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(0, 0, 0, 0.06); border-radius: var(--apple-radius-md);
box-shadow: var(--apple-shadow-lift); overflow: hidden;
/* ---------- Preview lightbox (immersive, no white shell) ---------- */
.lightbox {
position: fixed; inset: 0; z-index: 100;
display: flex; flex-direction: column;
background: rgba(8, 8, 10, 0.9);
-webkit-backdrop-filter: blur(18px) saturate(120%); backdrop-filter: blur(18px) saturate(120%);
}
.hud-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0.85rem 1.1rem; cursor: pointer; user-select: none;
border-bottom: 1px solid var(--apple-separator);
/* Top frosted bar — filename + position + close */
.lightbox-top {
position: absolute; top: 0; left: 0; right: 0; z-index: 30;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: 0.8rem 1.1rem;
background: linear-gradient(180deg, rgba(0,0,0,0.55), rgba(0,0,0,0));
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
.hud-title { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; font-weight: 600; color: var(--apple-text); }
.hud-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }
.hud-dot.live { background: var(--apple-blue); animation: hud-pulse 1.2s ease-in-out infinite; }
.hud-dot.idle { background: #c7c7cc; }
@keyframes hud-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.hud-cancel { font-size: 0.75rem; font-weight: 600; color: var(--apple-red); padding: 0.15rem 0.5rem; border-radius: 980px; background: none; border: none; cursor: pointer; }
.hud-cancel:hover { background: rgba(255, 59, 48, 0.10); }
.hud-chev { width: 1rem; height: 1rem; color: #8e8e93; transition: transform 0.2s ease; }
.lightbox-title { display: flex; align-items: baseline; gap: 0.7rem; min-width: 0; }
.lightbox-name {
font-size: 0.92rem; font-weight: 600; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lightbox-meta { font-size: 0.78rem; font-weight: 500; color: rgba(255,255,255,0.55); flex: none; }
.lightbox-close {
width: 2.4rem; height: 2.4rem; border-radius: 50%; flex: none;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.14); color: #fff; border: none; cursor: pointer;
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
transition: background 0.2s ease, transform 0.2s var(--apple-ease);
}
.lightbox-close:hover { background: rgba(255,255,255,0.26); }
.lightbox-close:active { transform: scale(0.92); }
.lightbox-close svg { width: 1.05rem; height: 1.05rem; }
.hud-body { max-height: 15rem; overflow-y: auto; }
.hud-empty { padding: 1.25rem 1rem; text-align: center; font-size: 0.75rem; color: var(--apple-gray); }
.hud-item { padding: 0.75rem 1.1rem; border-bottom: 1px solid var(--apple-separator); }
.hud-item:last-child { border-bottom: none; }
.hud-item-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
.hud-item-name { font-size: 0.75rem; font-weight: 600; color: var(--apple-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 11rem; }
.hud-item-status { font-size: 0.75rem; font-weight: 600; flex: none; margin-left: 0.5rem; }
.hud-bar { height: 0.375rem; border-radius: 980px; background: rgba(0, 0, 0, 0.06); overflow: hidden; }
.hud-bar-fill { height: 100%; border-radius: 980px; transition: width 0.2s ease; }
/* Stage — media centred on the dark canvas, no borders */
.lightbox-stage {
flex: 1 1 auto; min-height: 0; position: relative;
display: flex; align-items: center; justify-content: center;
padding: 4.5rem 4.75rem 3.5rem;
}
.lightbox-stage img,
.lightbox-stage video {
display: block; max-width: 100%; max-height: 100%;
width: auto; height: auto; object-fit: contain;
border-radius: 6px; box-shadow: 0 24px 60px rgba(0,0,0,0.5);
}
.lightbox-nav {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 20;
width: 3rem; height: 3rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.12); color: #fff; border: none; cursor: pointer;
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
transition: background 0.2s ease, transform 0.2s var(--apple-ease);
}
.lightbox-nav:hover { background: rgba(255,255,255,0.24); }
.lightbox-nav:active { transform: translateY(-50%) scale(0.92); }
.lightbox-nav svg { width: 1.35rem; height: 1.35rem; }
/* Bottom frosted bar — meta + download */
.lightbox-bottom {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 30;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: 0.75rem 1.1rem;
background: linear-gradient(0deg, rgba(0,0,0,0.55), rgba(0,0,0,0));
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
.lightbox-meta-row { display: flex; align-items: center; gap: 0.7rem; font-size: 0.78rem; font-weight: 500; color: rgba(255,255,255,0.6); min-width: 0; }
.lightbox-mime { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lightbox-dl {
display: inline-flex; align-items: center; gap: 0.4rem; flex: none;
min-height: 2.15rem; padding: 0.4rem 1rem; border-radius: 980px;
background: var(--apple-blue); color: #fff;
font-size: 0.82rem; font-weight: 600; text-decoration: none;
box-shadow: 0 6px 18px rgba(0, 113, 227, 0.35);
transition: background 0.2s ease, transform 0.2s var(--apple-ease);
}
.lightbox-dl:hover { background: #2487e6; }
.lightbox-dl:active { transform: scale(0.96); }
.lightbox-dl svg { width: 0.95rem; height: 0.95rem; }
/* Non-previewable fallback on dark */
.lightbox-fallback {
display: flex; flex-direction: column; align-items: center; gap: 1rem;
text-align: center; padding: 2rem 2.5rem; max-width: 26rem; color: #fff;
}
.lightbox-fallback svg { width: 3.6rem; height: 3.6rem; color: rgba(255,255,255,0.35); }
.lightbox-fallback .fb-title { font-size: 1rem; font-weight: 600; }
.lightbox-fallback .fb-sub { font-size: 0.85rem; color: rgba(255,255,255,0.55); }
@media (max-width: 640px) {
.lightbox-stage { padding: 4rem 0.75rem 3.25rem; }
.lightbox-nav { width: 2.5rem; height: 2.5rem; }
.lightbox-nav svg { width: 1.1rem; height: 1.1rem; }
.lightbox-bottom, .lightbox-top { padding: 0.7rem 0.85rem; }
.lightbox-name { font-size: 0.84rem; max-width: 45vw; }
}
/* ---------- Modals (Apple style) ---------- */
.apple-modal-bg {
@@ -397,38 +459,6 @@
.text-link { font-size: 0.875rem; font-weight: 600; color: var(--apple-red); background: none; border: none; cursor: pointer; padding: 0; }
.text-link:hover { text-decoration: underline; }
/* ---------- Preview modal ---------- */
.preview-bg {
position: fixed; inset: 0; z-index: 50;
display: flex; align-items: center; justify-content: center; padding: 1rem;
background: rgba(0, 0, 0, 0.82);
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
.preview-shell {
position: relative; background: #fff; border-radius: var(--apple-radius-md);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45);
display: flex; flex-direction: column; overflow: hidden;
max-width: min(92vw, 1280px); max-height: 92vh; width: max-content; min-width: 300px;
}
.preview-head { display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--apple-separator); flex: none; }
.preview-head h3 { font-size: 0.9rem; font-weight: 600; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview-stage { flex: 1 1 auto; min-height: 0; overflow: auto; display: flex; align-items: center; justify-content: center; background: var(--apple-bg); position: relative; }
.preview-foot { display: flex; align-items: center; justify-content: space-between; padding: 0.6rem 1.25rem; border-top: 1px solid var(--apple-separator); flex: none; background: #fff; }
.preview-foot .meta { font-size: 0.75rem; color: var(--apple-gray); display: flex; gap: 0.75rem; }
.preview-nav {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 10;
width: 2.5rem; height: 2.5rem; border-radius: 50%; display: flex;
align-items: center; justify-content: center;
background: rgba(0,0,0,0.4); color: #fff; border: none; cursor: pointer;
transition: background 0.2s ease;
}
.preview-nav:hover { background: rgba(0,0,0,0.6); }
.preview-nav svg { width: 1.25rem; height: 1.25rem; }
.preview-fallback { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 3rem 2.5rem; width: 26rem; }
.preview-fallback svg { width: 4rem; height: 4rem; color: #c7c7cc; }
.preview-fallback .fb-title { font-size: 1rem; font-weight: 600; color: var(--apple-text); }
.preview-fallback .fb-sub { font-size: 0.875rem; color: var(--apple-gray); }
/* ---------- Accessibility: visible keyboard focus ---------- */
a:focus-visible, button:focus-visible {
outline: 2px solid var(--apple-blue); outline-offset: 3px; border-radius: 12px;
@@ -441,7 +471,6 @@
.file-row { padding: 0.85rem 1.1rem; }
}
@media (prefers-reduced-motion: reduce) {
.hud-dot.live { animation: none !important; }
.apple-btn, .icon-btn, .file-row, .badge, .fan-card { transition: none !important; }
}
</style>
@@ -805,160 +834,84 @@
</div>
</div>
<!-- ── Upload Progress HUD ──────────────────────────────────────── -->
<div class="upload-hud">
<div class="apple-card" style="border:none;box-shadow:none;border-radius:inherit;overflow:visible">
<!-- Header — click to fold/unfold -->
<div @click="hudOpen = !hudOpen"
class="hud-header"
role="button" tabindex="0" @keydown.enter.prevent="hudOpen = !hudOpen" @keydown.space.prevent="hudOpen = !hudOpen"
:aria-expanded="hudOpen">
<div class="hud-title">
<span x-show="uploads.some(u => u.status === 'uploading')" class="hud-dot live"></span>
<span x-show="!uploads.some(u => u.status === 'uploading')" class="hud-dot idle"></span>
<span>
<span x-show="uploads.some(u => u.status === 'uploading')"
x-text="'{% trans "Uploading" %} ' + uploads.filter(u => u.status === 'uploading').length + ' / ' + uploads.length"></span>
<span x-show="!uploads.some(u => u.status === 'uploading')">{% trans "Uploads" %}</span>
</span>
</div>
<div style="display:flex;align-items:center;gap:0.5rem">
<button x-show="uploads.some(u => u.status === 'uploading')"
@click.stop="cancelAll()"
class="hud-cancel">
{% trans "Cancel" %}
</button>
<svg class="hud-chev" :class="hudOpen ? '' : 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Collapsible body -->
<div x-show="hudOpen" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1">
<div x-show="uploads.length === 0" class="hud-empty">
{% trans "Drag files anywhere or click Upload Files." %}
</div>
<div x-show="uploads.length > 0" class="hud-body">
<template x-for="u in uploads" :key="u.id">
<div class="hud-item">
<div class="hud-item-top">
<span class="hud-item-name" x-text="u.name"></span>
<span class="hud-item-status"
:class="{
'text-blue-500': u.status === 'uploading',
'text-green-600': u.status === 'done',
'text-red-500': u.status === 'error',
'text-gray-400': u.status === 'cancelled'
}"
:title="u.errorMsg || ''"
x-text="u.status === 'uploading' ? u.progress + '%' : (u.errorMsg || u.status)"></span>
</div>
<div class="hud-bar">
<div class="hud-bar-fill"
:class="{
'bg-blue-500': u.status === 'uploading',
'bg-green-500': u.status === 'done',
'bg-red-400': u.status === 'error',
'bg-gray-300': u.status === 'cancelled'
}"
:style="'width:' + u.progress + '%'"></div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- ── File Preview Modal ─────────────────────────────────────── -->
<!-- ── File Preview Lightbox (immersive) ───────────────────────── -->
<div x-show="previewModal.open" x-cloak
class="preview-bg"
class="lightbox"
@click.self="closePreview()"
@keydown.escape.window="closePreview()"
role="dialog" aria-modal="true" aria-label="{% trans 'File preview' %}">
<div class="preview-shell">
<!-- Top frosted bar: filename + position + close -->
<div class="lightbox-top">
<div class="lightbox-title">
<span class="lightbox-name" x-text="previewModal.name"></span>
<span class="lightbox-meta" x-show="fileList.length > 1"
x-text="(currentIndex + 1) + ' / ' + fileList.length"></span>
</div>
<button type="button" @click="closePreview()" class="lightbox-close" aria-label="{% trans 'Close' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="preview-head">
<h3 x-text="previewModal.name"></h3>
<button type="button" @click="closePreview()" class="modal-close" aria-label="{% trans 'Close' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Stage: media centred on the dark canvas -->
<div class="lightbox-stage"
@touchstart.passive="swipeStart = $event.touches[0].clientX"
@touchend.passive="onSwipeEnd($event)">
<button type="button" x-show="fileList.length > 1" @click="prevFile()"
class="lightbox-nav" style="left:1rem"
title="{% trans 'Previous' %} (←)" aria-label="{% trans 'Previous' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<button type="button" x-show="fileList.length > 1" @click="nextFile()"
class="lightbox-nav" style="right:1rem"
title="{% trans 'Next' %} (→)" aria-label="{% trans 'Next' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<img x-show="previewModal.open && previewModal.mimeType.startsWith('image/')" x-cloak
:src="previewModal.url" :alt="previewModal.name">
<video x-show="previewModal.open && previewModal.mimeType.startsWith('video/')" x-cloak
controls playsinline webkit-playsinline
:src="previewModal.url"></video>
<div x-show="previewModal.open && previewModal.mimeType.startsWith('audio/')" x-cloak
class="lightbox-fallback" style="width:22rem">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
<p class="fb-title" x-text="previewModal.name"></p>
<audio controls style="width:100%" :src="previewModal.url"></audio>
</div>
<div class="preview-stage">
<button type="button" x-show="fileList.length > 1" @click="prevFile()"
class="preview-nav" style="left:0.5rem"
title="{% trans 'Previous' %} (←)" aria-label="{% trans 'Previous' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<button type="button" x-show="fileList.length > 1" @click="nextFile()"
class="preview-nav" style="right:0.5rem"
title="{% trans 'Next' %} (→)" aria-label="{% trans 'Next' %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<img x-show="previewModal.open && previewModal.mimeType.startsWith('image/')" x-cloak
:src="previewModal.url" :alt="previewModal.name"
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto; object-fit:contain;">
<video x-show="previewModal.open && previewModal.mimeType.startsWith('video/')" x-cloak
controls playsinline webkit-playsinline
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto;"
:src="previewModal.url"></video>
<div x-show="previewModal.open && previewModal.mimeType.startsWith('audio/')" x-cloak
class="preview-fallback" style="width:22rem">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
<p class="fb-title" x-text="previewModal.name"></p>
<audio controls style="width:100%" :src="previewModal.url"></audio>
</div>
<div x-show="previewModal.open && !previewModal.mimeType.startsWith('image/') && !previewModal.mimeType.startsWith('video/') && !previewModal.mimeType.startsWith('audio/')" x-cloak
class="preview-fallback">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="fb-title">{% trans "Unfortunately, we can't preview this file." %}</p>
<p class="fb-sub">{% trans "But you can click the link below to download it." %}</p>
<a :href="previewModal.url" :download="previewModal.name" class="apple-btn apple-btn-primary">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span x-text="previewModal.name"></span>
</a>
</div>
<div x-show="previewModal.open && !previewModal.mimeType.startsWith('image/') && !previewModal.mimeType.startsWith('video/') && !previewModal.mimeType.startsWith('audio/')" x-cloak
class="lightbox-fallback">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="fb-title">{% trans "Unfortunately, we can't preview this file." %}</p>
<p class="fb-sub">{% trans "But you can click the button below to download it." %}</p>
</div>
</div>
<div class="preview-foot">
<div class="meta">
<span x-text="previewModal.mimeType"></span>
<span x-show="fileList.length > 1" x-text="(currentIndex + 1) + ' / ' + fileList.length"></span>
</div>
<a :href="previewModal.url" :download="previewModal.name" class="apple-link">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{% trans "Download" %}
</a>
<!-- Bottom frosted bar: mime + download -->
<div class="lightbox-bottom">
<div class="lightbox-meta-row">
<span class="lightbox-mime" x-text="previewModal.mimeType"></span>
</div>
<a :href="previewModal.url" :download="previewModal.name" class="lightbox-dl">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{% trans "Download" %}
</a>
</div>
</div>
@@ -979,8 +932,8 @@ function fileManager() {
{ key: 'archive', label: '{% trans "Archive" %}' },
{ key: 'binary', label: '{% trans "Other" %}' },
],
uploads: [],
hudOpen: false,
uploadProgress: { total: 0, done: 0, failed: 0 },
swipeStart: 0,
layout: 'list',
toastMsg: '',
toastVisible: false,
@@ -1168,98 +1121,77 @@ function fileManager() {
this.$nextTick(() => this._applyCoverFlow());
},
// ── Uploads ─────────────────────────────────────────────────
// ── Uploads (progress via toast; HUD removed) ─────────────────
uploadFiles(fileList) {
this.hudOpen = true;
Array.from(fileList).forEach(file => {
const id = Date.now() + '_' + Math.random().toString(36).slice(2);
this.uploads.push({ id, name: file.name, progress: 0, status: 'uploading', errorMsg: '', xhr: null });
this._uploadOne(file, id);
});
const files = Array.from(fileList);
if (!files.length) return;
this.uploadProgress = { total: files.length, done: 0, failed: 0 };
this._toastUploadProgress();
files.forEach(file => this._uploadOne(file));
},
_uploadOne(file, itemId) {
_toastUploadProgress() {
const { total, done, failed } = this.uploadProgress;
if (done + failed >= total) {
if (!failed) this.showToast('✓ ' + total + ' {% trans "uploaded" %}');
else this.showToast(done + ' {% trans "uploaded" %}, ' + failed + ' {% trans "failed" %}');
} else {
this.showToast('{% trans "Uploading" %} ' + (done + failed + 1) + ' / ' + total + '…');
}
},
_uploadOne(file) {
const formData = new FormData();
formData.append('files', file);
formData.append('csrfmiddlewaretoken', getCsrfToken());
const xhr = new XMLHttpRequest();
const find = () => this.uploads.find(u => u.id === itemId);
const u0 = find(); if (u0) u0.xhr = xhr;
xhr.upload.onprogress = e => {
if (e.lengthComputable) { const u = find(); if (u) u.progress = Math.round((e.loaded / e.total) * 100); }
};
xhr.onload = () => {
const u = find();
if (u) {
if (xhr.status >= 200 && xhr.status < 300) {
u.status = 'done';
u.progress = 100;
// Insert uploaded rows from server response — no full page reload
try {
const data = JSON.parse(xhr.responseText);
const inserted = (data && data.uploaded) || [];
inserted.forEach(rec => {
const newFile = {
id: rec.id,
name: rec.name,
mime_type: rec.mime_type || guessMime(file.name),
size: rec.formatted_size || formatBytes(rec.size || file.size),
download_count: 0,
created_at: rec.created_at || new Date().toISOString(),
is_public: false,
public_url: '',
expires_at: null,
download_url: rec.download_url || ('/ui/files/' + rec.id + '-' + encodeURIComponent(rec.name)),
is_image: (rec.mime_type || guessMime(file.name)).startsWith('image/'),
thumbnail_url: rec.thumbnail_url || '',
};
// Avoid inserting duplicate (key by id); add to the source
// list and re-apply the active filter so the new row shows
// only when it matches the current type filter.
if (!this.allFiles.some(x => String(x.id) === String(newFile.id))) {
this.allFiles.unshift(newFile);
this.applyFilter();
this.$nextTick(() => this._applyCoverFlow());
}
});
} catch (e) { /* ignore parse error */ }
} else {
u.status = 'error';
try {
const data = JSON.parse(xhr.responseText);
const firstError = data && Array.isArray(data.errors) && data.errors[0];
if (firstError && firstError.error) u.errorMsg = firstError.error;
} catch (e) { /* ignore parse error, keep generic 'error' */ }
}
if (xhr.status >= 200 && xhr.status < 300) {
this.uploadProgress.done++;
// Insert uploaded rows from server response — no full page reload
try {
const data = JSON.parse(xhr.responseText);
const inserted = (data && data.uploaded) || [];
inserted.forEach(rec => {
const newFile = {
id: rec.id,
name: rec.name,
mime_type: rec.mime_type || guessMime(file.name),
size: rec.formatted_size || formatBytes(rec.size || file.size),
download_count: 0,
created_at: rec.created_at || new Date().toISOString(),
is_public: false,
public_url: '',
expires_at: null,
download_url: rec.download_url || ('/ui/files/' + rec.id + '-' + encodeURIComponent(rec.name)),
is_image: (rec.mime_type || guessMime(file.name)).startsWith('image/'),
thumbnail_url: rec.thumbnail_url || '',
};
// Avoid inserting duplicate (key by id); add to the source
// list and re-apply the active filter so the new row shows
// only when it matches the current type filter.
if (!this.allFiles.some(x => String(x.id) === String(newFile.id))) {
this.allFiles.unshift(newFile);
this.applyFilter();
this.$nextTick(() => this._applyCoverFlow());
}
});
} catch (e) { /* ignore parse error */ }
} else {
this.uploadProgress.failed++;
}
this._maybeCollapseHud();
this._toastUploadProgress();
};
xhr.onerror = () => {
this.uploadProgress.failed++;
this._toastUploadProgress();
};
xhr.onerror = () => { const u = find(); if (u) u.status = 'error'; this._maybeCollapseHud(); };
xhr.onabort = () => { const u = find(); if (u) u.status = 'cancelled'; this._maybeCollapseHud(); };
xhr.open('POST', '{% url "file-upload" %}');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(formData);
},
_maybeCollapseHud(delay = 2000) {
if (this.uploads.length && this.uploads.every(u => u.status !== 'uploading')) {
setTimeout(() => {
if (this.uploads.every(u => u.status !== 'uploading')) {
this.hudOpen = false;
// Optionally clear the list after a moment so the HUD shows its empty state
setTimeout(() => { this.uploads = []; }, 800);
}
}, delay);
}
},
cancelAll() {
this.uploads.forEach(u => { if (u.xhr && u.status === 'uploading') { u.xhr.abort(); u.status = 'cancelled'; } });
setTimeout(() => { this.uploads = []; }, 800);
},
removeFile(id) {
this.allFiles = this.allFiles.filter(f => String(f.id) !== String(id));
this.applyFilter();
@@ -1287,6 +1219,15 @@ function fileManager() {
this.previewModal.open = false;
document.querySelectorAll('video, audio').forEach(el => el.pause());
},
// Touch swipe navigation inside the lightbox (mobile)
onSwipeEnd(ev) {
if (this.fileList.length < 2) return;
const endX = ev.changedTouches[0].clientX;
const dx = endX - this.swipeStart;
if (Math.abs(dx) > 48) {
if (dx < 0) this.nextFile(); else this.prevFile();
}
},
// ── Expiry modal ─────────────────────────────────────────────
openExpiry({ pk, expiresAt }) {