release(v2.0.3): polish uploads, header dock, and panel fly animations

This commit is contained in:
Ryan
2025-11-26 03:58:25 -05:00
committed by GitHub
parent 959206c91c
commit 9e6da52691
4 changed files with 508 additions and 89 deletions

View File

@@ -1,5 +1,22 @@
# Changelog
## Changes 11/26/2025 (v2.0.3)
release(v2.0.3): polish uploads, header dock, and panel fly animations
- Rework upload drop area markup to be rebuild-safe and wire a guarded "Choose files" button
so only one OS file-picker dialog can open at a time.
- Centralize file input change handling and reset selectedFiles/_currentResumableIds per batch
to avoid duplicate resumable entries and keep the progress list/drafts in sync.
- Ensure drag-and-drop uploads still support folder drops while file-picker is files-only.
- Add ghost-based animations when collapsing panels into the header dock and expanding them back
to sidebar/top zones, inheriting card background/border/shadow for smooth visuals.
- Offset sidebar ghosts so upload and folder cards don't stack directly on top of each other.
- Respect header-pinned cards: cards saved to HEADER stay as icons and no longer fly out on expand.
- Slightly tighten file summary margin in the file list header for better alignment with actions.
---
## Changes 11/23/2025 (v2.0.2)
release(v2.0.2): add config-driven demo mode and lock demo account changes

View File

@@ -72,6 +72,41 @@ function animateVerticalSlide(card) {
}, 260);
}
function createCardGhost(card, rect, opts) {
const options = opts || {};
const scale = typeof options.scale === 'number' ? options.scale : 1;
const opacity = typeof options.opacity === 'number' ? options.opacity : 1;
const ghost = card.cloneNode(true);
const cs = window.getComputedStyle(card);
// Give the ghost the same “card” chrome even though its attached to <body>
Object.assign(ghost.style, {
position: 'fixed',
left: rect.left + 'px',
top: rect.top + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
margin: '0',
zIndex: '12000',
pointerEvents: 'none',
transformOrigin: 'center center',
transform: 'scale(' + scale + ')',
opacity: String(opacity),
// pull key visuals from the real card
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
borderRadius: cs.borderRadius || '',
boxShadow: cs.boxShadow || '',
borderColor: cs.borderColor || '',
borderWidth: cs.borderWidth || '',
borderStyle: cs.borderStyle || '',
backdropFilter: cs.backdropFilter || '',
});
return ghost;
}
// -------------------- header (icon+modal) --------------------
function saveHeaderOrder() {
const host = getHeaderDropArea();
@@ -325,6 +360,234 @@ function hideHeaderDockPersistent() {
}
}
function animateCardsIntoHeaderAndThen(done) {
const sb = getSidebar();
const top = getTopZone();
const liveCards = [];
if (sb) liveCards.push(...sb.querySelectorAll('#uploadCard, #folderManagementCard'));
if (top) liveCards.push(...top.querySelectorAll('#uploadCard, #folderManagementCard'));
if (!liveCards.length) {
done();
return;
}
// Snapshot their current positions before we move the real DOM
const snapshots = liveCards.map(card => {
const rect = card.getBoundingClientRect();
return { card, rect };
});
// Show dock so icons exist / have positions
showHeaderDockPersistent();
// Move real cards into header (hidden container + icons)
snapshots.forEach(({ card }) => {
try { insertCardInHeader(card); } catch {}
});
const ghosts = [];
snapshots.forEach(({ card, rect }) => {
// remember the size for the expand animation later
card.dataset.lastWidth = String(rect.width);
card.dataset.lastHeight = String(rect.height);
const iconBtn = card.headerIconButton;
if (!iconBtn) return;
const iconRect = iconBtn.getBoundingClientRect();
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 });
ghost.id = card.id + '-ghost-collapse';
ghost.classList.add('card-collapse-ghost');
ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out';
document.body.appendChild(ghost);
ghosts.push({ ghost, from: rect, to: iconRect });
});
if (!ghosts.length) {
done();
return;
}
requestAnimationFrame(() => {
ghosts.forEach(({ ghost, from, to }) => {
const fromCx = from.left + from.width / 2;
const fromCy = from.top + from.height / 2;
const toCx = to.left + to.width / 2;
const toCy = to.top + to.height / 2;
const dx = toCx - fromCx;
const dy = toCy - fromCy;
const rawScale = to.width / from.width;
const scale = Math.max(0.25, Math.min(0.5, rawScale * 0.9));
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
ghost.style.opacity = '0';
});
});
setTimeout(() => {
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
done();
}, 260);
}
function resolveTargetZoneForExpand(cardId) {
const layout = readLayout();
const saved = layout[cardId];
const isUpload = (cardId === 'uploadCard');
// 🔒 If the user explicitly pinned this card to the HEADER,
// it should remain a header-only icon and NEVER fly out.
if (saved === ZONES.HEADER) {
return null; // caller will skip animation + placement
}
let zone = saved || null;
// No saved zone yet: mirror applyUserLayoutOrDefault defaults
if (!zone) {
if (isSmallScreen()) {
zone = isUpload ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
} else {
zone = ZONES.SIDEBAR;
}
}
// On small screens, anything targeting SIDEBAR gets lifted into the top cols
if (isSmallScreen() && zone === ZONES.SIDEBAR) {
zone = isUpload ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
}
return zone;
}
function getZoneHost(zoneId) {
switch (zoneId) {
case ZONES.SIDEBAR: return getSidebar();
case ZONES.TOP_LEFT: return getLeftCol();
case ZONES.TOP_RIGHT: return getRightCol();
default: return null;
}
}
// Animate cards "flying out" of header icons back into their zones.
function animateCardsOutOfHeaderThen(done) {
const header = getHeaderDropArea();
if (!header) { done(); return; }
const cards = getCards().filter(c => c && c.headerIconButton);
if (!cards.length) { done(); return; }
// Make sure target containers are visible so their rects are non-zero.
const sb = getSidebar();
const top = getTopZone();
if (sb) sb.style.display = '';
if (top) top.style.display = '';
const SAFE_TOP = 16; // minimum distance from top of viewport
const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost
const DEST_EXTRA_Y = 120; // how far down into the zone center we aim
const ghosts = [];
cards.forEach(card => {
const iconBtn = card.headerIconButton;
if (!iconBtn) return;
const zoneId = resolveTargetZoneForExpand(card.id);
if (!zoneId) return; // header-only card, stays as icon
const host = getZoneHost(zoneId);
if (!host) return;
const iconRect = iconBtn.getBoundingClientRect();
const zoneRect = host.getBoundingClientRect();
if (!zoneRect.width) return;
// Where the ghost "comes from" (near the icon)
const fromCx = iconRect.left + iconRect.width / 2;
const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point
// Where we want it to "land" (roughly center of the zone, a bit down)
let toCx = zoneRect.left + zoneRect.width / 2;
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
// 🔹 If both cards are going to the sidebar, offset them so they don't stack
if (zoneId === ZONES.SIDEBAR) {
if (card.id === 'uploadCard') {
toCy -= 48; // a bit higher
} else if (card.id === 'folderManagementCard') {
toCy += 60; // a bit lower
}
}
// Try to match the real card size we captured during collapse
const savedW = parseFloat(card.dataset.lastWidth || '');
const savedH = parseFloat(card.dataset.lastHeight || '');
const targetWidth = !Number.isNaN(savedW)
? savedW
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
// Make sure the top of the ghost never goes above SAFE_TOP
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
// Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow.
const ghostRect = {
left: fromCx - targetWidth / 2,
top: startTop,
width: targetWidth,
height: targetHeight
};
const ghost = createCardGhost(card, ghostRect, { scale: 0.7, opacity: 0 });
ghost.id = card.id + '-ghost-expand';
ghost.classList.add('card-expand-ghost');
// Override transform/transition for our flight animation
ghost.style.transform = 'translate(0,0) scale(0.7)';
ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
document.body.appendChild(ghost);
ghosts.push({
ghost,
from: { cx: fromCx, cy: fromCy },
to: { cx: toCx, cy: toCy },
zoneId
});
});
if (!ghosts.length) {
done();
return;
}
// Kick off the flight on the next frame
requestAnimationFrame(() => {
ghosts.forEach(({ ghost, from, to }) => {
const dx = to.cx - from.cx;
const dy = to.cy - from.cy;
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(1)`;
ghost.style.opacity = '1';
});
});
// Clean up ghosts and then do real layout restore
setTimeout(() => {
ghosts.forEach(({ ghost }) => {
try { ghost.remove(); } catch {}
});
done();
}, 280); // just over the 0.25s transition
}
// -------------------- zones toggle (collapse to header) --------------------
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
@@ -340,30 +603,73 @@ function applyCollapsedBodyClass() {
}
function setZonesCollapsed(collapsed) {
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
const currently = isZonesCollapsed();
if (collapsed === currently) return;
if (collapsed) {
// Move ALL cards to header icons (transient) regardless of where they were.
getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
// ---- COLLAPSE: immediately expand file area, then animate cards up into header ----
localStorage.setItem('zonesCollapsed', '1');
// File list area expands right away (no delay)
applyCollapsedBodyClass();
ensureZonesToggle();
updateZonesToggleUI();
document.dispatchEvent(
new CustomEvent('zones:collapsed-changed', { detail: { collapsed: true } })
);
try {
animateCardsIntoHeaderAndThen(() => {
const sb = getSidebar();
if (sb) sb.style.display = 'none';
updateSidebarVisibility();
updateTopZoneLayout();
showHeaderDockPersistent();
});
} catch (e) {
console.warn('[zones] collapse animation failed, collapsing instantly', e);
// Fallback: old instant behavior
getCards().forEach(insertCardInHeader);
showHeaderDockPersistent();
updateSidebarVisibility();
updateTopZoneLayout();
}
} else {
// Restore saved layout + rebuild header icons only for HEADER-assigned cards
// ---- EXPAND: immediately shrink file area, then animate cards out of header ----
localStorage.setItem('zonesCollapsed', '0');
// File list shrinks back right away
applyCollapsedBodyClass();
ensureZonesToggle();
updateZonesToggleUI();
document.dispatchEvent(
new CustomEvent('zones:collapsed-changed', { detail: { collapsed: false } })
);
try {
animateCardsOutOfHeaderThen(() => {
// After ghosts land, put the REAL cards back into their proper zones
applyUserLayoutOrDefault();
loadHeaderOrder();
hideHeaderDockPersistent();
}
updateSidebarVisibility();
updateTopZoneLayout();
ensureZonesToggle();
updateZonesToggleUI();
applyCollapsedBodyClass();
document.dispatchEvent(new CustomEvent('zones:collapsed-changed', { detail: { collapsed: isZonesCollapsed() } }));
});
} catch (e) {
console.warn('[zones] expand animation failed, expanding instantly', e);
// Fallback: just restore layout
applyUserLayoutOrDefault();
loadHeaderOrder();
hideHeaderDockPersistent();
updateSidebarVisibility();
updateTopZoneLayout();
}
}
}
function getHeaderHost() {
let host = document.querySelector('.header-container .header-left');
if (!host) host = document.querySelector('.header-container');
@@ -371,6 +677,36 @@ function getHeaderHost() {
return host || document.body;
}
function animateZonesCollapseAndThen(done) {
const sb = getSidebar();
const top = getTopZone();
const cards = [];
if (sb) cards.push(...sb.querySelectorAll('#uploadCard, #folderManagementCard'));
if (top) cards.push(...top.querySelectorAll('#uploadCard, #folderManagementCard'));
if (!cards.length) {
done();
return;
}
// quick "rise away" animation
cards.forEach(card => {
card.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
card.style.transform = 'translateY(-10px)';
card.style.opacity = '0';
});
setTimeout(() => {
cards.forEach(card => {
card.style.transition = '';
card.style.transform = '';
card.style.opacity = '';
});
done();
}, 190);
}
function ensureZonesToggle() {
const host = getHeaderHost();
if (!host) return;

View File

@@ -934,7 +934,7 @@ export async function loadFileList(folderParam) {
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
summaryElem.style.cssText = "float:right; margin:0 30px 0 auto; font-size:0.9em;";
actionsContainer.appendChild(summaryElem);
}
summaryElem.style.display = "block";

View File

@@ -39,6 +39,70 @@ function saveResumableDraftsAll(all) {
}
}
// --- Single file-picker trigger guard (prevents multiple OS dialogs) ---
let _lastFilePickerOpen = 0;
function triggerFilePickerOnce() {
const now = Date.now();
// ignore any extra calls within 400ms of the last open
if (now - _lastFilePickerOpen < 400) return;
_lastFilePickerOpen = now;
const fi = document.getElementById('file');
if (fi) {
fi.click();
}
}
// Wire the "Choose files" button so it always uses the guarded trigger
function wireChooseButton() {
const btn = document.getElementById('customChooseBtn');
if (!btn || btn.__uploadBound) return;
btn.__uploadBound = true;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // don't let it bubble to the drop-area click handler
triggerFilePickerOnce();
});
}
function wireFileInputChange(fileInput) {
if (!fileInput || fileInput.__uploadChangeBound) return;
fileInput.__uploadChangeBound = true;
// For file picker, remove directory attributes so only files can be chosen.
fileInput.removeAttribute("webkitdirectory");
fileInput.removeAttribute("mozdirectory");
fileInput.removeAttribute("directory");
fileInput.setAttribute("multiple", "");
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
_currentResumableIds.clear(); // <--- add this
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
}
});
}
function getUserDraftContext() {
const all = loadResumableDraftsAll();
const userKey = getCurrentUserKey();
@@ -253,7 +317,8 @@ function getFilesFromDataTransferItems(items) {
function setDropAreaDefault() {
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) {
if (!dropArea) return;
dropArea.innerHTML = `
<div id="uploadInstruction" class="upload-instruction">
${t("upload_instruction")}
@@ -267,9 +332,20 @@ function setDropAreaDefault() {
</div>
</div>
<!-- File input for file picker (files only) -->
<input type="file" id="file" name="file[]" class="form-control-file" multiple style="opacity:0; position:absolute; width:1px; height:1px;" />
<input
type="file"
id="file"
name="file[]"
class="form-control-file"
multiple
style="opacity:0; position:absolute; width:1px; height:1px;"
/>
`;
}
// After rebuilding markup, re-wire controls:
const fileInput = dropArea.querySelector('#file');
wireFileInputChange(fileInput);
wireChooseButton();
}
function adjustFolderHelpExpansion() {
@@ -608,6 +684,7 @@ const useResumable = true;
let resumableInstance = null;
let _pendingPickedFiles = []; // files picked before library/instance ready
let _resumableReady = false;
let _currentResumableIds = new Set();
// Make init async-safe; it resolves when Resumable is constructed
async function initResumableUpload() {
@@ -644,17 +721,19 @@ async function initResumableUpload() {
resumableInstance.opts.query.upload_token = window.csrfToken;
}
const fileInput = document.getElementById("file");
if (fileInput) {
fileInput.addEventListener("change", function () {
for (let i = 0; i < fileInput.files.length; i++) {
resumableInstance.addFile(fileInput.files[i]);
}
});
}
resumableInstance.on("fileAdded", function (file) {
// Build a stable per-file key
const id =
file.uniqueIdentifier ||
((file.fileName || file.name || '') + ':' + (file.size || 0));
// If we've already seen this id in the current batch, skip wiring it again
if (_currentResumableIds.has(id)) {
return;
}
_currentResumableIds.add(id);
// Initialize custom paused flag
file.paused = false;
@@ -1119,9 +1198,17 @@ function submitFiles(allFiles) {
Main initUpload: Sets up file input, drop area, and form submission.
----------------------------------------------------- */
function initUpload() {
const fileInput = document.getElementById("file");
const dropArea = document.getElementById("uploadDropArea");
window.__FR_FLAGS = window.__FR_FLAGS || { wired: {} };
window.__FR_FLAGS.wired = window.__FR_FLAGS.wired || {};
const uploadForm = document.getElementById("uploadFileForm");
const dropArea = document.getElementById("uploadDropArea");
// Always (re)build the inner markup and wire the Choose button
setDropAreaDefault();
wireChooseButton();
const fileInput = document.getElementById("file");
// For file picker, remove directory attributes so only files can be chosen.
if (fileInput) {
@@ -1131,67 +1218,50 @@ function initUpload() {
fileInput.setAttribute("multiple", "");
}
setDropAreaDefault();
// Draganddrop events (for folder uploads) use original processing.
if (dropArea) {
if (dropArea && !dropArea.__uploadBound) {
dropArea.__uploadBound = true;
dropArea.classList.add("upload-drop-area");
dropArea.addEventListener("dragover", function (e) {
e.preventDefault();
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
});
dropArea.addEventListener("dragleave", function (e) {
e.preventDefault();
dropArea.style.backgroundColor = "";
});
dropArea.addEventListener("drop", function (e) {
e.preventDefault();
dropArea.style.backgroundColor = "";
const dt = e.dataTransfer || window.__pendingDropData || null;
window.__pendingDropData = null;
if (dt.items && dt.items.length > 0) {
if (dt && dt.items && dt.items.length > 0) {
getFilesFromDataTransferItems(dt.items).then(files => {
if (files.length > 0) {
processFiles(files);
}
});
} else if (dt.files && dt.files.length > 0) {
} else if (dt && dt.files && dt.files.length > 0) {
processFiles(dt.files);
}
});
// Clicking drop area triggers file input.
dropArea.addEventListener("click", function () {
if (fileInput) fileInput.click();
// Only trigger file picker when clicking the *bare* drop area, not controls inside it
dropArea.addEventListener("click", function (e) {
// If the click originated from the "Choose files" button or the file input itself,
// let their handlers deal with it.
if (e.target.closest('#customChooseBtn') || e.target.closest('#file')) {
return;
}
triggerFilePickerOnce();
});
}
if (fileInput) {
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
}
});
}
if (uploadForm) {
if (uploadForm && !uploadForm.__uploadSubmitBound) {
uploadForm.__uploadSubmitBound = true;
uploadForm.addEventListener("submit", async function (e) {
e.preventDefault();
@@ -1205,7 +1275,6 @@ function initUpload() {
return;
}
// If we have any files queued in Resumable, treat this as a resumable upload.
const hasResumableFiles =
useResumable &&
resumableInstance &&
@@ -1215,7 +1284,6 @@ function initUpload() {
if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// Keep folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.opts.query.upload_token = window.csrfToken;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
@@ -1223,11 +1291,9 @@ function initUpload() {
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
// Hard fallback should basically never happen
submitFiles(files);
}
} else {
// No resumable queue → drag-and-drop / paste / simple input → XHR path
submitFiles(files);
}
});