Shift Key Multi‑Selection & Total Files and File Size

This commit is contained in:
Ryan
2025-04-10 00:45:35 -04:00
committed by GitHub
parent 98850a7c65
commit 4fa5faa2bf
7 changed files with 239 additions and 58 deletions

View File

@@ -1,6 +1,38 @@
# Changelog
## Folder Sharing Feature - Changelog 4/9/2025
## Shift Key MultiSelection Changes 4/10/2025
- **Implemented Range Selection:**
- Modified the `toggleRowSelection` function so that when the Shift key is held down, all rows between the last clicked (anchor) row (stored as `window.lastSelectedFileRow`) and the currently clicked row are selected.
- **Modifier Handling:**
- Regular clicks (or Ctrl/Cmd clicks) simply toggle the clicked row without clearing other selections.
- **Prevented Default Browser Behavior:**
- Added `event.preventDefault()` in the Shiftclick branch to avoid unwanted text selection.
- **Maintaining the Anchor:**
- The last clicked row is stored for future range selections.
## Total Files and File Size Summary
- **Size Calculation:**
- Created `parseSizeToBytes(sizeStr)` to convert file size strings (e.g. `"456.9KB"`, `"1.2 MB"`) into a numerical byte value.
- Created `formatSize(totalBytes)` to format a byte value into a humanreadable string (choosing between Bytes, KB, MB, or GB).
- Created `buildFolderSummary(filteredFiles)` to:
- Sum the sizes of all files (using `parseSizeToBytes`).
- Count the total number of files.
- **Dynamic Display in `loadFileList`:**
- Updated `loadFileList` to update a summary element (with `id="fileSummary"`) inside the `#fileListActions` container when files are present.
- When no files are found, the summary element is hidden (setting its `display` to `"none"` or clearing the container).
- **Responsive Styling:**
- Added CSS media queries to the `#fileSummary` element so that on small screens it is centered and any extra side margins are removed. Dark and light mode supported.
- **Other changes**
- `shareFolder.php` updated to display format size.
- Fix to prevent the filename text from overflowing its container in the gallery view.
---
## Folder Sharing Feature - Changelog 4/9/2025 v1.1.0
### New Endpoints

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 SeNS
Copyright (c) 2025 FileRise
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -12,7 +12,7 @@ Upload, organize, and share files through a sleek web interface. **FileRise** is
---
## Features at a Glance or [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with pause/resumable chunked uploads and shows real-time progress for each file. No more failed transfers FileRise will pick up where it left off if your connection drops.

View File

@@ -2134,4 +2134,27 @@ body.dark-mode .header-drop-zone.drag-active {
content: "Drop";
font-size: 10px;
color: #aaa;
}
}
/* Disable text selection on rows to prevent accidental copying when shift-clicking */
#fileList tbody tr.clickable-row {
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE10+/Edge */
user-select: none; /* Standard */
}
#fileSummary {
color: black;
}
@media only screen and (max-width: 600px) {
#fileSummary {
float: none !important;
margin: 0 auto !important;
text-align: center !important;
}
}
body.dark-mode #fileSummary {
color: white;
}

View File

@@ -223,15 +223,63 @@ export function updateRowHighlight(checkbox) {
}
export function toggleRowSelection(event, fileName) {
// Prevent default text selection when shift is held.
if (event.shiftKey) {
event.preventDefault();
}
// Ignore clicks on interactive elements.
const targetTag = event.target.tagName.toLowerCase();
if (targetTag === 'a' || targetTag === 'button' || targetTag === 'input') {
if (["a", "button", "input"].includes(targetTag)) {
return;
}
// Get the clicked row and its checkbox.
const row = event.currentTarget;
const checkbox = row.querySelector('.file-checkbox');
const checkbox = row.querySelector(".file-checkbox");
if (!checkbox) return;
checkbox.checked = !checkbox.checked;
updateRowHighlight(checkbox);
// Get all rows in the current file list view.
const allRows = Array.from(document.querySelectorAll("#fileList tbody tr"));
// Helper: clear all selections (not used in this updated version).
const clearAllSelections = () => {
allRows.forEach(r => {
const cb = r.querySelector(".file-checkbox");
if (cb) {
cb.checked = false;
updateRowHighlight(cb);
}
});
};
// If the user is holding the Shift key, perform range selection.
if (event.shiftKey) {
// Use the last clicked row as the anchor.
const lastRow = window.lastSelectedFileRow || row;
const currentIndex = allRows.indexOf(row);
const lastIndex = allRows.indexOf(lastRow);
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
// If neither CTRL nor Meta is pressed, you might choose
// to clear existing selections. For this example we leave existing selections intact.
for (let i = start; i <= end; i++) {
const cb = allRows[i].querySelector(".file-checkbox");
if (cb) {
cb.checked = true;
updateRowHighlight(cb);
}
}
}
// Otherwise, for all non-shift clicks simply toggle the selected state.
else {
checkbox.checked = !checkbox.checked;
updateRowHighlight(checkbox);
}
// Update the anchor row to the row that was clicked.
window.lastSelectedFileRow = row;
updateFileActionButtons();
}
@@ -241,7 +289,7 @@ export function attachEnterKeyListener(modalId, buttonId) {
// Make the modal focusable
modal.setAttribute("tabindex", "-1");
modal.focus();
modal.addEventListener("keydown", function(e) {
modal.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
const btn = document.getElementById(buttonId);

View File

@@ -15,6 +15,7 @@ import {
import { t } from './i18n.js';
import { bindFileListContextMenu } from './fileMenu.js';
import { openDownloadModal } from './fileActions.js';
import { openTagModal, openMultiTagModal } from './fileTags.js';
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
@@ -23,9 +24,63 @@ window.itemsPerPage = window.itemsPerPage || 10;
window.currentPage = window.currentPage || 1;
window.viewMode = localStorage.getItem("viewMode") || "table"; // "table" or "gallery"
// -----------------------------
// VIEW MODE TOGGLE BUTTON & Helpers
// -----------------------------
/**
* --- Helper Functions ---
*/
/**
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
*/
function parseSizeToBytes(sizeStr) {
if (!sizeStr) return 0;
// Remove any whitespace
let s = sizeStr.trim();
// Extract the numerical part.
let value = parseFloat(s);
// Determine if there is a unit. Convert the unit to uppercase for easier matching.
let upper = s.toUpperCase();
if (upper.includes("KB")) {
value *= 1024;
} else if (upper.includes("MB")) {
value *= 1024 * 1024;
} else if (upper.includes("GB")) {
value *= 1024 * 1024 * 1024;
}
return value;
}
/**
* Format the total bytes as a human-readable string, choosing an appropriate unit.
*/
function formatSize(totalBytes) {
if (totalBytes < 1024) {
return totalBytes + " Bytes";
} else if (totalBytes < 1024 * 1024) {
return (totalBytes / 1024).toFixed(2) + " KB";
} else if (totalBytes < 1024 * 1024 * 1024) {
return (totalBytes / (1024 * 1024)).toFixed(2) + " MB";
} else {
return (totalBytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}
}
/**
* Build the folder summary HTML using the filtered file list.
* This function sums the file sizes in bytes correctly, then formats the total.
*/
function buildFolderSummary(filteredFiles) {
const totalFiles = filteredFiles.length;
const totalBytes = filteredFiles.reduce((sum, file) => {
// file.size might be something like "456.9KB" or just "1024".
return sum + parseSizeToBytes(file.size);
}, 0);
const sizeStr = formatSize(totalBytes);
return `<strong>Total Files:</strong> ${totalFiles} &nbsp;|&nbsp; <strong>Total Size:</strong> ${sizeStr}`;
}
/**
* --- VIEW MODE TOGGLE BUTTON & Helpers ---
*/
export function createViewToggleButton() {
let toggleBtn = document.getElementById("toggleViewBtn");
if (!toggleBtn) {
@@ -58,11 +113,9 @@ export function formatFolderName(folder) {
window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;
import { openTagModal, openMultiTagModal } from './fileTags.js';
// -----------------------------
// FILE LIST & VIEW RENDERING
// -----------------------------
/**
* --- FILE LIST & VIEW RENDERING ---
*/
export function loadFileList(folderParam) {
const folder = folderParam || "root";
const fileListContainer = document.getElementById("fileList");
@@ -80,7 +133,7 @@ export function loadFileList(folderParam) {
return response.json();
})
.then(data => {
fileListContainer.innerHTML = "";
fileListContainer.innerHTML = ""; // Clear loading message.
if (data.files && data.files.length > 0) {
data.files = data.files.map(file => {
file.fullName = (file.path || file.name).trim().toLowerCase();
@@ -92,6 +145,26 @@ export function loadFileList(folderParam) {
return file;
});
fileData = data.files;
// Update the file list actions area without removing existing buttons.
const actionsContainer = document.getElementById("fileListActions");
if (actionsContainer) {
let summaryElem = document.getElementById("fileSummary");
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.float = "right";
summaryElem.style.marginLeft = "auto";
summaryElem.style.marginRight = "60px";
summaryElem.style.fontSize = "0.9em";
actionsContainer.appendChild(summaryElem);
} else {
summaryElem.style.display = "block";
}
summaryElem.innerHTML = buildFolderSummary(fileData);
}
// Render the view normally.
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
@@ -99,6 +172,10 @@ export function loadFileList(folderParam) {
}
} else {
fileListContainer.textContent = t("no_files_found");
const summaryElem = document.getElementById("fileSummary");
if (summaryElem) {
summaryElem.style.display = "none";
}
updateFileActionButtons();
}
return data.files || [];
@@ -115,8 +192,12 @@ export function loadFileList(folderParam) {
});
}
export function renderFileTable(folder) {
const fileListContainer = document.getElementById("fileList");
/**
* Update renderFileTable so that it writes its content into the provided container.
* If no container is provided, it defaults to the element with id "fileList".
*/
export function renderFileTable(folder, container) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
let currentPage = window.currentPage || 1;
@@ -126,14 +207,12 @@ export function renderFileTable(folder) {
const tagMatch = file.tags && file.tags.some(tag => tag.name.toLowerCase().includes(searchTerm));
return nameMatch || tagMatch;
});
const totalFiles = filteredFiles.length;
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
if (currentPage > totalPages) {
currentPage = totalPages > 0 ? totalPages : 1;
window.currentPage = currentPage;
}
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
@@ -147,7 +226,6 @@ export function renderFileTable(folder) {
const startIndex = (currentPage - 1) * itemsPerPageSetting;
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
let rowsHTML = "<tbody>";
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
let rowHTML = buildFileTableRow(file, folderPath);
@@ -161,15 +239,12 @@ export function renderFileTable(folder) {
});
tagBadgesHTML += "</div>";
}
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
return p1 + p2 + tagBadgesHTML + p3;
});
rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `<button class="share-btn btn btn-sm btn-secondary" data-file="${escapeHTML(file.name)}" title="Share">
<i class="material-icons">share</i>
</button>$1`);
<i class="material-icons">share</i>
</button>$1`);
rowsHTML += rowHTML;
});
} else {
@@ -177,16 +252,18 @@ export function renderFileTable(folder) {
}
rowsHTML += "</tbody></table>";
const bottomControlsHTML = buildBottomControls(itemsPerPageSetting);
fileListContainer.innerHTML = topControlsHTML + headerHTML + rowsHTML + bottomControlsHTML;
fileListContent.innerHTML = topControlsHTML + headerHTML + rowsHTML + bottomControlsHTML;
createViewToggleButton();
// Setup event listeners as before...
const newSearchInput = document.getElementById("searchInput");
if (newSearchInput) {
newSearchInput.addEventListener("input", debounce(function () {
window.currentSearchTerm = newSearchInput.value;
window.currentPage = 1;
renderFileTable(folder);
renderFileTable(folder, container);
setTimeout(() => {
const freshInput = document.getElementById("searchInput");
if (freshInput) {
@@ -197,21 +274,18 @@ export function renderFileTable(folder) {
}, 0);
}, 300));
}
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
cell.addEventListener("click", function () {
const column = this.getAttribute("data-column");
sortFiles(column, folder);
});
});
document.querySelectorAll("#fileList .file-checkbox").forEach(checkbox => {
checkbox.addEventListener("change", function (e) {
updateRowHighlight(e.target);
updateFileActionButtons();
});
});
document.querySelectorAll(".share-btn").forEach(btn => {
btn.addEventListener("click", function (e) {
e.stopPropagation();
@@ -224,40 +298,34 @@ export function renderFileTable(folder) {
}
});
});
updateFileActionButtons();
// Add drag-and-drop support for each table row.
document.querySelectorAll("#fileList tbody tr").forEach(row => {
document.querySelectorAll("#fileListContent tbody tr").forEach(row => {
row.setAttribute("draggable", "true");
import('./fileDragDrop.js').then(module => {
row.addEventListener("dragstart", module.fileDragStartHandler);
});
});
// Prevent clicks on these buttons from selecting the row
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
btn.addEventListener("click", e => e.stopPropagation());
});
// rebind context menu
bindFileListContextMenu();
}
export function renderGalleryView(folder) {
const fileListContainer = document.getElementById("fileList");
/**
* Similarly, update renderGalleryView to accept an optional container.
*/
export function renderGalleryView(folder, container) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const filteredFiles = fileData.filter(file => {
return file.name.toLowerCase().includes(searchTerm) ||
(file.tags && file.tags.some(tag => tag.name.toLowerCase().includes(searchTerm)));
});
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
const gridStyle = "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; padding: 10px;";
let galleryHTML = `<div class="gallery-container" style="${gridStyle}">`;
filteredFiles.forEach((file) => {
let thumbnail;
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
@@ -267,7 +335,6 @@ export function renderGalleryView(folder) {
} else {
thumbnail = `<span class="material-icons gallery-icon">insert_drive_file</span>`;
}
let tagBadgesHTML = "";
if (file.tags && file.tags.length > 0) {
tagBadgesHTML = `<div class="tag-badges" style="margin-top:4px;">`;
@@ -276,13 +343,12 @@ export function renderGalleryView(folder) {
});
tagBadgesHTML += `</div>`;
}
galleryHTML += `<div class="gallery-card" style="border: 1px solid #ccc; padding: 5px; text-align: center;">
<div class="gallery-preview" style="cursor: pointer;" onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t=' + new Date().getTime(), '${file.name}')">
${thumbnail}
</div>
<div class="gallery-info" style="margin-top: 5px;">
<span class="gallery-file-name" style="display: block;">${escapeHTML(file.name)}</span>
<span class="gallery-file-name" style="display: block; white-space: normal; overflow-wrap: break-word; word-wrap: break-word;">${escapeHTML(file.name)}</span>
${tagBadgesHTML}
<div class="button-wrap" style="display: flex; justify-content: center; gap: 5px;">
<button type="button" class="btn btn-sm btn-success download-btn"
@@ -305,15 +371,10 @@ export function renderGalleryView(folder) {
</div>
</div>`;
});
galleryHTML += "</div>";
fileListContainer.innerHTML = galleryHTML;
fileListContent.innerHTML = galleryHTML;
createViewToggleButton();
updateFileActionButtons();
// Bind share button clicks
document.querySelectorAll(".share-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
@@ -413,7 +474,6 @@ window.changeItemsPerPage = function (newCount) {
};
// fileListView.js (bottom)
window.loadFileList = loadFileList;
window.renderFileTable = renderFileTable;
window.renderGalleryView = renderGalleryView;

View File

@@ -146,6 +146,24 @@ $totalPages = max(1, ceil($totalFiles / $itemsPerPage));
$currentPage = min($page, $totalPages);
$startIndex = ($currentPage - 1) * $itemsPerPage;
$filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
/**
* Convert file size in bytes into a human-readable string.
*
* @param int $bytes The file size in bytes.
* @return string The formatted size string.
*/
function formatBytes($bytes) {
if ($bytes < 1024) {
return $bytes . " B";
} elseif ($bytes < 1024 * 1024) {
return round($bytes / 1024, 2) . " KB";
} elseif ($bytes < 1024 * 1024 * 1024) {
return round($bytes / (1024 * 1024), 2) . " MB";
} else {
return round($bytes / (1024 * 1024 * 1024), 2) . " GB";
}
}
?>
<!DOCTYPE html>
<html lang="en">
@@ -268,13 +286,13 @@ $filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
<thead>
<tr>
<th>Filename</th>
<th>Size (MB)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<?php foreach ($filesOnPage as $file):
$filePath = $realFolderPath . DIRECTORY_SEPARATOR . $file;
$sizeMB = round(filesize($filePath) / (1024 * 1024), 2);
$fileSize = formatBytes(filesize($filePath));
// Build download link using share token and file name.
$downloadLink = "downloadSharedFile.php?token=" . urlencode($token) . "&file=" . urlencode($file);
?>
@@ -285,7 +303,7 @@ $filesOnPage = array_slice($allFiles, $startIndex, $itemsPerPage);
<span class="download-icon">&#x21E9;</span>
</a>
</td>
<td><?php echo $sizeMB; ?></td>
<td><?php echo $fileSize; ?></td>
</tr>
<?php endforeach; ?>
</tbody>