mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
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:
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user