File Tagging and Global Tag Management added
This commit is contained in:
87
README.md
87
README.md
@@ -51,31 +51,45 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
|
|||||||
- **Extract Zip:** When one or more ZIP files are selected, users can extract the archive(s) directly into the current folder.
|
- **Extract Zip:** When one or more ZIP files are selected, users can extract the archive(s) directly into the current folder.
|
||||||
- **Drag & Drop (File Movement):** Easily move files by selecting them from the file list and dragging them onto your desired folder in the folder tree or breadcrumb. When you drop the files onto a folder, the system automatically moves them, updating your file organization in one seamless action.
|
- **Drag & Drop (File Movement):** Easily move files by selecting them from the file list and dragging them onto your desired folder in the folder tree or breadcrumb. When you drop the files onto a folder, the system automatically moves them, updating your file organization in one seamless action.
|
||||||
- **Enhanced Context Menu & Keyboard Shortcuts:**
|
- **Enhanced Context Menu & Keyboard Shortcuts:**
|
||||||
- **Right-Click Context Menu:**
|
- **Right-Click Context Menu:**
|
||||||
- A custom context menu appears on right-clicking within the file list.
|
- A custom context menu appears on right-clicking within the file list.
|
||||||
- For multiple selections, options include Delete Selected, Copy Selected, Move Selected, Download Zip, and (if applicable) Extract Zip.
|
- For multiple selections, options include Delete Selected, Copy Selected, Move Selected, Download Zip, and (if applicable) Extract Zip.
|
||||||
- When exactly one file is selected, additional options (Preview, Edit [if editable], and Rename) are available.
|
- When exactly one file is selected, additional options (Preview, Edit [if editable], Rename, and Tag File) are available.
|
||||||
- **Keyboard Shortcut for Deletion:**
|
- **Keyboard Shortcut for Deletion:**
|
||||||
- A global keydown listener detects Delete/Backspace key presses (when no input is focused) to trigger the delete operation.
|
- A global keydown listener detects Delete/Backspace key presses (when no input is focused) to trigger the delete operation.
|
||||||
|
|
||||||
|
- **File Tagging and Global Tag Management:**
|
||||||
|
- **Context Menu Tagging:**
|
||||||
|
- Single-file tagging: “Tag File” option in the right-click menu opens a modal to add a tag (with name and color) to the file.
|
||||||
|
- Multi-file tagging: When multiple files are selected, a “Tag Selected” option opens a multi‑file tagging modal to apply the same tag to all selected files.
|
||||||
|
- **Tagging Modals & Custom Dropdown:**
|
||||||
|
- Dedicated modals provide an interface for adding and updating tags.
|
||||||
|
- A custom dropdown in each modal displays available global tags with a colored preview and a remove icon.
|
||||||
|
- **Global Tag Store:**
|
||||||
|
- Tags are stored globally (persisted in a JSON file) for reuse across files and sessions.
|
||||||
|
- New tags added to any file are automatically added to the global store.
|
||||||
|
- Users can remove a global tag directly from the dropdown, which removes it from the available tag list for all files.
|
||||||
|
- **Unified Search Filtering:**
|
||||||
|
- The single search box now filters files based on both file names and tag names (case‑insensitive).
|
||||||
|
|
||||||
- **Folder Management:**
|
- **Folder Management:**
|
||||||
- Organize files into folders and subfolders with the ability to create, rename, and delete folders.
|
- Organize files into folders and subfolders with the ability to create, rename, and delete folders.
|
||||||
- A dynamic folder tree in the UI allows users to navigate directories easily, and any changes are immediately reflected in real time.
|
- A dynamic folder tree in the UI allows users to navigate directories easily, with real-time updates.
|
||||||
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), and operations (copy/move/rename) update these metadata files accordingly.
|
- **Per-Folder Metadata Storage:** Each folder has its own metadata JSON file (e.g., `root_metadata.json`, `FolderName_metadata.json`), updated with operations like copy/move/rename.
|
||||||
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder, streamlining navigation across subfolders. Supports drag & drop to move files.
|
- **Intuitive Breadcrumb Navigation:** Clickable breadcrumbs enable users to quickly jump to any parent folder; supports drag & drop for moving files.
|
||||||
- **Folder Manager Context Menu:**
|
- **Folder Manager Context Menu:**
|
||||||
- Right-clicking on a folder (in the folder tree or breadcrumb) brings up a custom context menu with options for creating, renaming, and deleting folders.
|
- Right-clicking on a folder brings up a custom context menu with options for creating, renaming, and deleting folders.
|
||||||
- **Keyboard Shortcut for Folder Deletion:**
|
- **Keyboard Shortcut for Folder Deletion:**
|
||||||
- A global key listener (Delete/Backspace) is provided to trigger folder deletion (with safeguards to prevent deleting the root folder).
|
- A global key listener (Delete/Backspace) triggers folder deletion with safeguards to prevent deletion of the root folder.
|
||||||
|
|
||||||
- **Sorting & Pagination:**
|
- **Sorting & Pagination:**
|
||||||
- The file list can be sorted by name, modified date, upload date, file size, or uploader.
|
- Files can be sorted by name, modified date, upload date, file size, or uploader.
|
||||||
- Pagination controls let users navigate through files with selectable page sizes (10, 20, 50, or 100 items per page) and “Prev”/“Next” navigation buttons.
|
- Pagination controls let users navigate through files with selectable page sizes (10, 20, 50, or 100 items per page) and “Prev”/“Next” buttons.
|
||||||
|
|
||||||
- **Share Link Functionality:**
|
- **Share Link Functionality:**
|
||||||
- Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and a 1-day option) and optional password protection.
|
- Generate shareable links for files with configurable expiration times (e.g., 30, 60, 120, 180, 240 minutes, and 1 day) and optional password protection.
|
||||||
- Share links are stored in a JSON file with details including the folder, file, expiration timestamp, and hashed password.
|
- Share links are stored in a JSON file with details including folder, file, expiration timestamp, and hashed password.
|
||||||
- The share endpoint (`share.php`) validates tokens, expiration, and password before serving files (or forcing downloads).
|
- The share endpoint validates tokens, expiration, and password before serving files (or forcing downloads).
|
||||||
- The share URL is configurable via environment variables or auto-detected from the server.
|
- The share URL is configurable via environment variables or auto-detected from the server.
|
||||||
|
|
||||||
- **User Authentication & Management:**
|
- **User Authentication & Management:**
|
||||||
@@ -83,28 +97,28 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
|
|||||||
- Admin users can add or remove users through the interface.
|
- Admin users can add or remove users through the interface.
|
||||||
- Passwords are hashed using PHP’s `password_hash()` for security.
|
- Passwords are hashed using PHP’s `password_hash()` for security.
|
||||||
- All state-changing endpoints include CSRF token validation.
|
- All state-changing endpoints include CSRF token validation.
|
||||||
- Change password supported for all users.
|
- Password change functionality is supported for all users.
|
||||||
- Basic Auth supported for login.
|
- Basic Auth is available for login.
|
||||||
- **Persistent Login (Remember Me) with Encrypted Tokens:**
|
- **Persistent Login (Remember Me) with Encrypted Tokens:**
|
||||||
- Users can remain logged in across sessions securely.
|
- Users can remain logged in across sessions securely.
|
||||||
- Persistent tokens are encrypted using AES‑256‑CBC before being stored in a JSON file.
|
- Persistent tokens are encrypted using AES‑256‑CBC before being stored in a JSON file.
|
||||||
- On auto-login, the tokens are decrypted on the server to re-establish user sessions without requiring re-authentication.
|
- On auto-login, tokens are decrypted on the server to re-establish user sessions without re-authentication.
|
||||||
|
|
||||||
- **Responsive, Dynamic & Persistent UI:**
|
- **Responsive, Dynamic & Persistent UI:**
|
||||||
- The interface is mobile-friendly and adapts to various screen sizes by hiding non-critical columns on small devices.
|
- The interface is mobile-friendly and adapts to various screen sizes by hiding non-critical columns on small devices.
|
||||||
- Asynchronous updates (via Fetch API and XMLHttpRequest) keep the UI responsive without full page reloads.
|
- Asynchronous updates (via Fetch API and XMLHttpRequest) keep the UI responsive without full page reloads.
|
||||||
- Persistent settings (such as items per page, dark/light mode preference, folder tree state, and the last open folder) ensure a smooth and customized user experience.
|
- Persistent settings (such as items per page, dark/light mode preference, folder tree state, and the last open folder) ensure a smooth, customized user experience.
|
||||||
|
|
||||||
- **Dark Mode/Light Mode:**
|
- **Dark Mode/Light Mode:**
|
||||||
- The application automatically adapts to the operating system’s theme preference by default and offers a manual toggle.
|
- The application automatically adapts to the operating system’s theme preference by default, with a manual toggle available.
|
||||||
- The dark mode provides a darker background with lighter text and adjusts UI elements (including the CodeMirror editor) for optimal readability in low-light conditions.
|
- Dark mode provides a darker background with lighter text, and UI elements (including the CodeMirror editor) are adjusted for optimal readability in low-light conditions.
|
||||||
- The light mode maintains a bright interface for well-lit environments.
|
- Light mode maintains a bright interface suitable for well-lit environments.
|
||||||
|
|
||||||
- **Server & Security Enhancements:**
|
- **Server & Security Enhancements:**
|
||||||
- The Apache configuration (or .htaccess files) is set to disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized users from viewing directory contents.
|
- Apache (or .htaccess) configurations disable directory indexing (e.g., using `Options -Indexes` in the uploads directory), preventing unauthorized file browsing.
|
||||||
- Direct access to sensitive files (e.g., `users.txt`) is restricted through .htaccess rules.
|
- Direct access to sensitive files (e.g., `users.txt`) is restricted via .htaccess rules.
|
||||||
- A proxy download mechanism has been implemented (via endpoints like `download.php` and `downloadZip.php`) so that every file download request goes through a PHP script. This script validates the session and CSRF token before streaming the file, ensuring that even if a file URL is guessed, only authenticated users can access it.
|
- A proxy download mechanism (via endpoints like `download.php` and `downloadZip.php`) routes all file downloads through PHP, ensuring session and CSRF token validation before file access.
|
||||||
- Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments to further protect file content.
|
- Administrators are advised to deploy the app on a secure internal network or use the proxy download mechanism for public deployments.
|
||||||
|
|
||||||
- **Trash Management with Restore & Delete:**
|
- **Trash Management with Restore & Delete:**
|
||||||
- **Trash Storage & Metadata:**
|
- **Trash Storage & Metadata:**
|
||||||
@@ -115,29 +129,28 @@ FileRise is a lightweight, secure, self-hosted web application for uploading, sy
|
|||||||
- Uploader information (and optionally who deleted it)
|
- Uploader information (and optionally who deleted it)
|
||||||
- Additional metadata (e.g., file type)
|
- Additional metadata (e.g., file type)
|
||||||
- **Restore Functionality:**
|
- **Restore Functionality:**
|
||||||
- Admins can view trashed files in a modal.
|
- Admins can view trashed files in a modal and restore individual or all files back to their original location (with conflict checks).
|
||||||
- They can restore individual files (with conflict checks) or restore all files back to their original location.
|
|
||||||
- **Delete Functionality:**
|
- **Delete Functionality:**
|
||||||
- Users can permanently delete trashed files via:
|
- Users can permanently delete trashed files via:
|
||||||
- **Delete Selected:** Remove specific files from the Trash and update `trash.json`.
|
- **Delete Selected:** Remove specific files from the Trash and update `trash.json`.
|
||||||
- **Delete All:** Permanently remove every file from the Trash after confirmation.
|
- **Delete All:** Permanently remove every file from the Trash after confirmation.
|
||||||
- **Auto-Purge Mechanism:**
|
- **Auto-Purge Mechanism:**
|
||||||
- The system automatically purges (permanently deletes) any files in the Trash older than three days, helping manage storage and prevent the accumulation of outdated files.
|
- The system automatically purges files in the Trash older than three days, managing storage and preventing accumulation of outdated files.
|
||||||
- **User Interface:**
|
- **Trash UI:**
|
||||||
- The trash modal displays details such as file name, uploader/deleter, and the trashed date/time.
|
- The trash modal displays file name, uploader/deleter, and trashed date/time.
|
||||||
- Material icons with tooltips visually represent the restore and delete actions.
|
- Material icons with tooltips represent restore and delete actions.
|
||||||
|
|
||||||
- **Drag & Drop Cards with Dedicated Drop Zones:**
|
- **Drag & Drop Cards with Dedicated Drop Zones:**
|
||||||
- **Sidebar Drop Zone:**
|
- **Sidebar Drop Zone:**
|
||||||
- Cards (such as the upload card or folder management card) can be dragged into a dedicated sidebar drop zone for quick access to frequently used operations.
|
- Cards (e.g., upload or folder management) can be dragged into a dedicated sidebar drop zone for quick access to frequently used operations.
|
||||||
- The sidebar drop zone expands dynamically to accept drops anywhere within its visual area.
|
- The sidebar drop zone expands dynamically to accept drops anywhere within its visual area.
|
||||||
- **Top Bar Drop Zone:**
|
- **Top Bar Drop Zone:**
|
||||||
- A top drop zone is available for reordering or managing cards quickly.
|
- A top drop zone is available for reordering or managing cards quickly.
|
||||||
- Dragging a card to the top drop zone provides immediate visual feedback, ensuring a fluid and customizable workflow.
|
- Dragging a card to the top drop zone provides immediate visual feedback, ensuring a fluid and customizable workflow.
|
||||||
- **Seamless Interaction:**
|
- **Seamless Interaction:**
|
||||||
- Both drop zones support smooth drag and drop interactions with animations and pointer event adjustments to prevent interference, ensuring that cards can be dropped reliably regardless of screen position.
|
- Both drop zones support smooth drag-and-drop interactions with animations and pointer event adjustments, ensuring reliable card placement regardless of screen position.
|
||||||
|
|
||||||
# 🔒 Admin Panel, TOTP & OpenID Connect (OIDC) Integration
|
## 🔒 Admin Panel, TOTP & OpenID Connect (OIDC) Integration
|
||||||
|
|
||||||
- **Flexible Authentication:**
|
- **Flexible Authentication:**
|
||||||
- Supports multiple authentication methods including Form-based Login, Basic Auth, OpenID Connect (OIDC), and TOTP-based Two-Factor Authentication.
|
- Supports multiple authentication methods including Form-based Login, Basic Auth, OpenID Connect (OIDC), and TOTP-based Two-Factor Authentication.
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
|||||||
<i class="material-icons">search</i>
|
<i class="material-icons">search</i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="searchInput" class="form-control" placeholder="Search files..." value="${safeSearchTerm}" aria-describedby="searchIcon">
|
<input type="text" id="searchInput" class="form-control" placeholder="Search files or tag..." value="${safeSearchTerm}" aria-describedby="searchIcon">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4 text-left">
|
<div class="col-12 col-md-4 text-left">
|
||||||
@@ -157,7 +157,7 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);">
|
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);">
|
||||||
</td>
|
</td>
|
||||||
<td>${safeFileName}</td>
|
<td class="file-name-cell">${safeFileName}</td>
|
||||||
<td class="hide-small nowrap">${safeModified}</td>
|
<td class="hide-small nowrap">${safeModified}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||||
<td class="hide-small nowrap">${safeSize}</td>
|
<td class="hide-small nowrap">${safeSize}</td>
|
||||||
|
|||||||
107
fileManager.js
107
fileManager.js
@@ -15,6 +15,8 @@ import {
|
|||||||
export let fileData = [];
|
export let fileData = [];
|
||||||
export let sortOrder = { column: "uploaded", ascending: true };
|
export let sortOrder = { column: "uploaded", ascending: true };
|
||||||
|
|
||||||
|
import { initTagSearch, openTagModal, openMultiTagModal } from './fileTags.js';
|
||||||
|
|
||||||
window.itemsPerPage = window.itemsPerPage || 10;
|
window.itemsPerPage = window.itemsPerPage || 10;
|
||||||
window.currentPage = window.currentPage || 1;
|
window.currentPage = window.currentPage || 1;
|
||||||
window.viewMode = localStorage.getItem("viewMode") || "table"; // "table" or "gallery"
|
window.viewMode = localStorage.getItem("viewMode") || "table"; // "table" or "gallery"
|
||||||
@@ -258,7 +260,7 @@ function previewFile(fileUrl, fileName) {
|
|||||||
embed.style.height = "80vh";
|
embed.style.height = "80vh";
|
||||||
embed.style.border = "none";
|
embed.style.border = "none";
|
||||||
container.appendChild(embed);
|
container.appendChild(embed);
|
||||||
} else if (/\.(mp4|webm|mov)$/i.test(fileName)) {
|
} else if (/\.(mp4|webm|mov|ogg)$/i.test(fileName)) {
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.src = fileUrl;
|
video.src = fileUrl;
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
@@ -419,17 +421,19 @@ function fileDragStartHandler(event) {
|
|||||||
//
|
//
|
||||||
export function renderFileTable(folder) {
|
export function renderFileTable(folder) {
|
||||||
const fileListContainer = document.getElementById("fileList");
|
const fileListContainer = document.getElementById("fileList");
|
||||||
const searchTerm = window.currentSearchTerm || "";
|
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||||
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
|
||||||
let currentPage = window.currentPage || 1;
|
let currentPage = window.currentPage || 1;
|
||||||
|
|
||||||
const filteredFiles = fileData.filter(file =>
|
// Filter files: include a file if its name OR any of its tags include the search term.
|
||||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
const filteredFiles = fileData.filter(file => {
|
||||||
);
|
const nameMatch = file.name.toLowerCase().includes(searchTerm);
|
||||||
|
const tagMatch = file.tags && file.tags.some(tag => tag.name.toLowerCase().includes(searchTerm));
|
||||||
|
return nameMatch || tagMatch;
|
||||||
|
});
|
||||||
|
|
||||||
const totalFiles = filteredFiles.length;
|
const totalFiles = filteredFiles.length;
|
||||||
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
|
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
|
||||||
|
|
||||||
if (currentPage > totalPages) {
|
if (currentPage > totalPages) {
|
||||||
currentPage = totalPages > 0 ? totalPages : 1;
|
currentPage = totalPages > 0 ? totalPages : 1;
|
||||||
window.currentPage = currentPage;
|
window.currentPage = currentPage;
|
||||||
@@ -442,19 +446,40 @@ export function renderFileTable(folder) {
|
|||||||
const topControlsHTML = buildSearchAndPaginationControls({
|
const topControlsHTML = buildSearchAndPaginationControls({
|
||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
searchTerm
|
searchTerm: window.currentSearchTerm || ""
|
||||||
});
|
});
|
||||||
let headerHTML = buildFileTableHeader(sortOrder);
|
let headerHTML = buildFileTableHeader(sortOrder);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPageSetting;
|
const startIndex = (currentPage - 1) * itemsPerPageSetting;
|
||||||
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
|
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
|
||||||
let rowsHTML = "<tbody>";
|
let rowsHTML = "<tbody>";
|
||||||
|
|
||||||
if (totalFiles > 0) {
|
if (totalFiles > 0) {
|
||||||
filteredFiles.slice(startIndex, endIndex).forEach(file => {
|
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||||
|
// Build the table row HTML.
|
||||||
let rowHTML = buildFileTableRow(file, folderPath);
|
let rowHTML = buildFileTableRow(file, folderPath);
|
||||||
|
// Add a unique id attribute so that tag updates can target this row.
|
||||||
|
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
|
||||||
|
|
||||||
|
// Build tag badges HTML.
|
||||||
|
let tagBadgesHTML = "";
|
||||||
|
if (file.tags && file.tags.length > 0) {
|
||||||
|
tagBadgesHTML = '<div class="tag-badges" style="display:inline-block; margin-left:5px;">';
|
||||||
|
file.tags.forEach(tag => {
|
||||||
|
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
|
||||||
|
});
|
||||||
|
tagBadgesHTML += "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert tag badges into the file name cell.
|
||||||
|
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
||||||
|
return p1 + p2 + tagBadgesHTML + p3;
|
||||||
|
});
|
||||||
|
|
||||||
// Insert share button into the actions cell.
|
// Insert share button into the actions cell.
|
||||||
rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `<button class="share-btn btn btn-sm btn-secondary" data-file="${escapeHTML(file.name)}" title="Share">
|
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>
|
<i class="material-icons">share</i>
|
||||||
</button>$1`);
|
</button>$1`);
|
||||||
|
|
||||||
rowsHTML += rowHTML;
|
rowsHTML += rowHTML;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -472,12 +497,10 @@ export function renderFileTable(folder) {
|
|||||||
window.currentSearchTerm = newSearchInput.value;
|
window.currentSearchTerm = newSearchInput.value;
|
||||||
window.currentPage = 1;
|
window.currentPage = 1;
|
||||||
renderFileTable(folder);
|
renderFileTable(folder);
|
||||||
// After re‑render, re-select the input element and set focus.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const freshInput = document.getElementById("searchInput");
|
const freshInput = document.getElementById("searchInput");
|
||||||
if (freshInput) {
|
if (freshInput) {
|
||||||
freshInput.focus();
|
freshInput.focus();
|
||||||
// Place the caret at the end of the text.
|
|
||||||
const len = freshInput.value.length;
|
const len = freshInput.value.length;
|
||||||
freshInput.setSelectionRange(len, len);
|
freshInput.setSelectionRange(len, len);
|
||||||
}
|
}
|
||||||
@@ -519,17 +542,22 @@ export function renderFileTable(folder) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// --- RENDER GALLERY VIEW ---
|
|
||||||
//
|
|
||||||
export function renderGalleryView(folder) {
|
export function renderGalleryView(folder) {
|
||||||
const fileListContainer = document.getElementById("fileList");
|
const fileListContainer = document.getElementById("fileList");
|
||||||
|
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||||
|
// Filter files using the same logic as table view.
|
||||||
|
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"
|
const folderPath = folder === "root"
|
||||||
? "uploads/"
|
? "uploads/"
|
||||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||||
const gridStyle = "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; padding: 10px;";
|
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}">`;
|
let galleryHTML = `<div class="gallery-container" style="${gridStyle}">`;
|
||||||
fileData.forEach((file) => {
|
|
||||||
|
filteredFiles.forEach((file) => {
|
||||||
let thumbnail;
|
let thumbnail;
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
||||||
thumbnail = `<img src="${folderPath + encodeURIComponent(file.name)}?t=${new Date().getTime()}" class="gallery-thumbnail" alt="${escapeHTML(file.name)}" style="max-width: 100%; max-height: 150px; display: block; margin: 0 auto;">`;
|
thumbnail = `<img src="${folderPath + encodeURIComponent(file.name)}?t=${new Date().getTime()}" class="gallery-thumbnail" alt="${escapeHTML(file.name)}" style="max-width: 100%; max-height: 150px; display: block; margin: 0 auto;">`;
|
||||||
@@ -538,12 +566,24 @@ export function renderGalleryView(folder) {
|
|||||||
} else {
|
} else {
|
||||||
thumbnail = `<span class="material-icons gallery-icon">insert_drive_file</span>`;
|
thumbnail = `<span class="material-icons gallery-icon">insert_drive_file</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build tag badges HTML for the gallery view.
|
||||||
|
let tagBadgesHTML = "";
|
||||||
|
if (file.tags && file.tags.length > 0) {
|
||||||
|
tagBadgesHTML = `<div class="tag-badges" style="margin-top:4px;">`;
|
||||||
|
file.tags.forEach(tag => {
|
||||||
|
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
|
||||||
|
});
|
||||||
|
tagBadgesHTML += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
galleryHTML += `<div class="gallery-card" style="border: 1px solid #ccc; padding: 5px; text-align: center;">
|
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}')">
|
<div class="gallery-preview" style="cursor: pointer;" onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t=' + new Date().getTime(), '${file.name}')">
|
||||||
${thumbnail}
|
${thumbnail}
|
||||||
</div>
|
</div>
|
||||||
<div class="gallery-info" style="margin-top: 5px;">
|
<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;">${escapeHTML(file.name)}</span>
|
||||||
|
${tagBadgesHTML}
|
||||||
<div class="button-wrap" style="display: flex; justify-content: center; gap: 5px;">
|
<div class="button-wrap" style="display: flex; justify-content: center; gap: 5px;">
|
||||||
<a class="btn btn-sm btn-success download-btn"
|
<a class="btn btn-sm btn-success download-btn"
|
||||||
href="download.php?folder=${encodeURIComponent(file.folder || 'root')}&file=${encodeURIComponent(file.name)}"
|
href="download.php?folder=${encodeURIComponent(file.folder || 'root')}&file=${encodeURIComponent(file.name)}"
|
||||||
@@ -551,7 +591,7 @@ export function renderGalleryView(folder) {
|
|||||||
<i class="material-icons">file_download</i>
|
<i class="material-icons">file_download</i>
|
||||||
</a>
|
</a>
|
||||||
${file.editable ? `
|
${file.editable ? `
|
||||||
<button class="btn btn-sm edit-btn" onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' title="Edit">
|
<button class="btn btn-sm edit-btn" onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' title="Edit">
|
||||||
<i class="material-icons">edit</i>
|
<i class="material-icons">edit</i>
|
||||||
</button>
|
</button>
|
||||||
` : ""}
|
` : ""}
|
||||||
@@ -565,22 +605,10 @@ export function renderGalleryView(folder) {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
galleryHTML += "</div>";
|
galleryHTML += "</div>";
|
||||||
fileListContainer.innerHTML = galleryHTML;
|
fileListContainer.innerHTML = galleryHTML;
|
||||||
|
|
||||||
// Re-bind share button events if necessary.
|
|
||||||
document.querySelectorAll(".gallery-share-btn").forEach(btn => {
|
|
||||||
btn.addEventListener("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const fileName = this.getAttribute("data-file");
|
|
||||||
const folder = this.getAttribute("data-folder");
|
|
||||||
const file = fileData.find(f => f.name === fileName);
|
|
||||||
if (file) {
|
|
||||||
openShareModal(file, folder);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
createViewToggleButton();
|
createViewToggleButton();
|
||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
}
|
}
|
||||||
@@ -1454,6 +1482,7 @@ function hideFileContextMenu() {
|
|||||||
// Context menu handler for the file list.
|
// Context menu handler for the file list.
|
||||||
function fileListContextMenuHandler(e) {
|
function fileListContextMenuHandler(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// If no file is selected, try to select the row that was right-clicked.
|
// If no file is selected, try to select the row that was right-clicked.
|
||||||
let row = e.target.closest("tr");
|
let row = e.target.closest("tr");
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -1483,11 +1512,20 @@ function fileListContextMenuHandler(e) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.length === 1) {
|
// If multiple files are selected, add a "Tag Selected" option.
|
||||||
// Look up the file object.
|
if (selected.length > 1) {
|
||||||
|
menuItems.push({
|
||||||
|
label: "Tag Selected",
|
||||||
|
action: () => {
|
||||||
|
const files = fileData.filter(f => selected.includes(f.name));
|
||||||
|
openMultiTagModal(files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If exactly one file is selected, add options specific to that file.
|
||||||
|
else if (selected.length === 1) {
|
||||||
const file = fileData.find(f => f.name === selected[0]);
|
const file = fileData.find(f => f.name === selected[0]);
|
||||||
|
|
||||||
// Add Preview option.
|
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
label: "Preview",
|
label: "Preview",
|
||||||
action: () => {
|
action: () => {
|
||||||
@@ -1499,7 +1537,6 @@ function fileListContextMenuHandler(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only show Edit option if file is editable.
|
|
||||||
if (canEditFile(file.name)) {
|
if (canEditFile(file.name)) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
label: "Edit",
|
label: "Edit",
|
||||||
@@ -1507,11 +1544,15 @@ function fileListContextMenuHandler(e) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Rename option.
|
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
label: "Rename",
|
label: "Rename",
|
||||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
action: () => { renameFile(selected[0], window.currentFolder); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
menuItems.push({
|
||||||
|
label: "Tag File",
|
||||||
|
action: () => { openTagModal(file); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
||||||
|
|||||||
460
fileTags.js
Normal file
460
fileTags.js
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
// fileTags.js
|
||||||
|
// This module provides functions for opening the tag modal,
|
||||||
|
// adding tags to files (with a global tag store for reuse),
|
||||||
|
// updating the file row display with tag badges,
|
||||||
|
// filtering the file list by tag, and persisting tag data.
|
||||||
|
import { escapeHTML } from './domUtils.js';
|
||||||
|
|
||||||
|
export function openTagModal(file) {
|
||||||
|
// Create the modal element.
|
||||||
|
let modal = document.createElement('div');
|
||||||
|
modal.id = 'tagModal';
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="width: 400px; max-width:90vw;">
|
||||||
|
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<h3 style="margin:0;">Tag File: ${file.name}</h3>
|
||||||
|
<span id="closeTagModal" style="cursor:pointer; font-size:24px;">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="margin-top:10px;">
|
||||||
|
<label for="tagNameInput">Tag Name:</label>
|
||||||
|
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<label for="tagColorInput">Tag Color:</label>
|
||||||
|
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
||||||
|
<!-- Custom tag options will be populated here -->
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<button id="saveTagBtn" class="btn btn-primary">Save Tag</button>
|
||||||
|
</div>
|
||||||
|
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
|
||||||
|
<!-- Existing tags will be listed here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
updateCustomTagDropdown();
|
||||||
|
|
||||||
|
document.getElementById('closeTagModal').addEventListener('click', () => {
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTagModalDisplay(file);
|
||||||
|
|
||||||
|
document.getElementById('tagNameInput').addEventListener('input', (e) => {
|
||||||
|
updateCustomTagDropdown(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveTagBtn').addEventListener('click', () => {
|
||||||
|
const tagName = document.getElementById('tagNameInput').value.trim();
|
||||||
|
const tagColor = document.getElementById('tagColorInput').value;
|
||||||
|
if (!tagName) {
|
||||||
|
alert('Please enter a tag name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addTagToFile(file, { name: tagName, color: tagColor });
|
||||||
|
updateTagModalDisplay(file);
|
||||||
|
updateFileRowTagDisplay(file);
|
||||||
|
saveFileTags(file);
|
||||||
|
document.getElementById('tagNameInput').value = '';
|
||||||
|
updateCustomTagDropdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a modal to tag multiple files.
|
||||||
|
* @param {Array} files - Array of file objects to tag.
|
||||||
|
*/
|
||||||
|
export function openMultiTagModal(files) {
|
||||||
|
let modal = document.createElement('div');
|
||||||
|
modal.id = 'multiTagModal';
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="width: 400px; max-width:90vw;">
|
||||||
|
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
|
||||||
|
<span id="closeMultiTagModal" style="cursor:pointer; font-size:24px;">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="margin-top:10px;">
|
||||||
|
<label for="multiTagNameInput">Tag Name:</label>
|
||||||
|
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<label for="multiTagColorInput">Tag Color:</label>
|
||||||
|
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
||||||
|
<!-- Custom tag options will be populated here -->
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
updateMultiCustomTagDropdown();
|
||||||
|
|
||||||
|
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
|
||||||
|
updateMultiCustomTagDropdown(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
|
||||||
|
const tagName = document.getElementById('multiTagNameInput').value.trim();
|
||||||
|
const tagColor = document.getElementById('multiTagColorInput').value;
|
||||||
|
if (!tagName) {
|
||||||
|
alert('Please enter a tag name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
files.forEach(file => {
|
||||||
|
addTagToFile(file, { name: tagName, color: tagColor });
|
||||||
|
updateFileRowTagDisplay(file);
|
||||||
|
saveFileTags(file);
|
||||||
|
});
|
||||||
|
modal.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the custom dropdown for multi-tag modal.
|
||||||
|
* Similar to updateCustomTagDropdown but includes a remove icon.
|
||||||
|
*/
|
||||||
|
function updateMultiCustomTagDropdown(filterText = "") {
|
||||||
|
const dropdown = document.getElementById("multiCustomTagDropdown");
|
||||||
|
if (!dropdown) return;
|
||||||
|
dropdown.innerHTML = "";
|
||||||
|
let tags = window.globalTags || [];
|
||||||
|
if (filterText) {
|
||||||
|
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (tags.length > 0) {
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.style.cursor = "pointer";
|
||||||
|
item.style.padding = "5px";
|
||||||
|
item.style.borderBottom = "1px solid #eee";
|
||||||
|
// Display colored square and tag name with remove icon.
|
||||||
|
item.innerHTML = `
|
||||||
|
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
|
||||||
|
${escapeHTML(tag.name)}
|
||||||
|
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
|
||||||
|
`;
|
||||||
|
item.addEventListener("click", function(e) {
|
||||||
|
if (e.target.classList.contains("global-remove")) return;
|
||||||
|
document.getElementById("multiTagNameInput").value = tag.name;
|
||||||
|
document.getElementById("multiTagColorInput").value = tag.color;
|
||||||
|
});
|
||||||
|
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||||
|
e.stopPropagation();
|
||||||
|
removeGlobalTag(tag.name);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCustomTagDropdown(filterText = "") {
|
||||||
|
const dropdown = document.getElementById("customTagDropdown");
|
||||||
|
if (!dropdown) return;
|
||||||
|
dropdown.innerHTML = "";
|
||||||
|
let tags = window.globalTags || [];
|
||||||
|
if (filterText) {
|
||||||
|
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (tags.length > 0) {
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.style.cursor = "pointer";
|
||||||
|
item.style.padding = "5px";
|
||||||
|
item.style.borderBottom = "1px solid #eee";
|
||||||
|
item.innerHTML = `
|
||||||
|
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
|
||||||
|
${escapeHTML(tag.name)}
|
||||||
|
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
|
||||||
|
`;
|
||||||
|
item.addEventListener("click", function(e){
|
||||||
|
if (e.target.classList.contains('global-remove')) return;
|
||||||
|
document.getElementById("tagNameInput").value = tag.name;
|
||||||
|
document.getElementById("tagColorInput").value = tag.color;
|
||||||
|
});
|
||||||
|
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||||
|
e.stopPropagation();
|
||||||
|
removeGlobalTag(tag.name);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the modal display to show current tags on the file.
|
||||||
|
function updateTagModalDisplay(file) {
|
||||||
|
const container = document.getElementById('currentTags');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<strong>Current Tags:</strong> ';
|
||||||
|
if (file.tags && file.tags.length > 0) {
|
||||||
|
file.tags.forEach(tag => {
|
||||||
|
const tagElem = document.createElement('span');
|
||||||
|
tagElem.textContent = tag.name;
|
||||||
|
tagElem.style.backgroundColor = tag.color;
|
||||||
|
tagElem.style.color = '#fff';
|
||||||
|
tagElem.style.padding = '2px 6px';
|
||||||
|
tagElem.style.marginRight = '5px';
|
||||||
|
tagElem.style.borderRadius = '3px';
|
||||||
|
tagElem.style.display = 'inline-block';
|
||||||
|
tagElem.style.position = 'relative';
|
||||||
|
|
||||||
|
const removeIcon = document.createElement('span');
|
||||||
|
removeIcon.textContent = ' ✕';
|
||||||
|
removeIcon.style.fontWeight = 'bold';
|
||||||
|
removeIcon.style.marginLeft = '3px';
|
||||||
|
removeIcon.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
removeIcon.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTagFromFile(file, tag.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
tagElem.appendChild(removeIcon);
|
||||||
|
container.appendChild(tagElem);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
container.innerHTML += 'None';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTagFromFile(file, tagName) {
|
||||||
|
file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
|
||||||
|
updateTagModalDisplay(file);
|
||||||
|
updateFileRowTagDisplay(file);
|
||||||
|
saveFileTags(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a tag from the global tag store.
|
||||||
|
* This function updates window.globalTags and calls the backend endpoint
|
||||||
|
* to remove the tag from the persistent store.
|
||||||
|
*/
|
||||||
|
function removeGlobalTag(tagName) {
|
||||||
|
window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
|
||||||
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
|
updateCustomTagDropdown();
|
||||||
|
updateMultiCustomTagDropdown();
|
||||||
|
saveGlobalTagRemoval(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Save global tag removal to the server.
|
||||||
|
function saveGlobalTagRemoval(tagName) {
|
||||||
|
fetch("saveFileTag.php", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder: "root",
|
||||||
|
file: "global",
|
||||||
|
deleteGlobal: true,
|
||||||
|
tagToDelete: tagName,
|
||||||
|
tags: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log("Global tag removed:", tagName);
|
||||||
|
if (data.globalTags) {
|
||||||
|
window.globalTags = data.globalTags;
|
||||||
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
|
updateCustomTagDropdown();
|
||||||
|
updateMultiCustomTagDropdown();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Error removing global tag:", data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error removing global tag:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global store for reusable tags.
|
||||||
|
window.globalTags = window.globalTags || [];
|
||||||
|
if (localStorage.getItem('globalTags')) {
|
||||||
|
try {
|
||||||
|
window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New function to load global tags from the server's persistent JSON.
|
||||||
|
export function loadGlobalTags() {
|
||||||
|
fetch("metadata/createdTags.json", { credentials: "include" })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
window.globalTags = data;
|
||||||
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
|
updateCustomTagDropdown();
|
||||||
|
updateMultiCustomTagDropdown();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error loading global tags:", err);
|
||||||
|
window.globalTags = [];
|
||||||
|
updateCustomTagDropdown();
|
||||||
|
updateMultiCustomTagDropdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadGlobalTags();
|
||||||
|
|
||||||
|
// Add (or update) a tag in the file object.
|
||||||
|
export function addTagToFile(file, tag) {
|
||||||
|
if (!file.tags) {
|
||||||
|
file.tags = [];
|
||||||
|
}
|
||||||
|
const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
||||||
|
if (exists) {
|
||||||
|
exists.color = tag.color;
|
||||||
|
} else {
|
||||||
|
file.tags.push(tag);
|
||||||
|
}
|
||||||
|
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
||||||
|
if (!globalExists) {
|
||||||
|
window.globalTags.push(tag);
|
||||||
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the file row (in table view) to show tag badges.
|
||||||
|
export function updateFileRowTagDisplay(file) {
|
||||||
|
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
|
||||||
|
console.log('Updating tags for rows:', rows);
|
||||||
|
rows.forEach(row => {
|
||||||
|
let cell = row.querySelector('.file-name-cell');
|
||||||
|
if (cell) {
|
||||||
|
let badgeContainer = cell.querySelector('.tag-badges');
|
||||||
|
if (!badgeContainer) {
|
||||||
|
badgeContainer = document.createElement('div');
|
||||||
|
badgeContainer.className = 'tag-badges';
|
||||||
|
badgeContainer.style.display = 'inline-block';
|
||||||
|
badgeContainer.style.marginLeft = '5px';
|
||||||
|
cell.appendChild(badgeContainer);
|
||||||
|
}
|
||||||
|
badgeContainer.innerHTML = '';
|
||||||
|
if (file.tags && file.tags.length > 0) {
|
||||||
|
file.tags.forEach(tag => {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.textContent = tag.name;
|
||||||
|
badge.style.backgroundColor = tag.color;
|
||||||
|
badge.style.color = '#fff';
|
||||||
|
badge.style.padding = '2px 4px';
|
||||||
|
badge.style.marginRight = '2px';
|
||||||
|
badge.style.borderRadius = '3px';
|
||||||
|
badge.style.fontSize = '0.8em';
|
||||||
|
badge.style.verticalAlign = 'middle';
|
||||||
|
badgeContainer.appendChild(badge);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTagSearch() {
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
let tagSearchInput = document.getElementById('tagSearchInput');
|
||||||
|
if (!tagSearchInput) {
|
||||||
|
tagSearchInput = document.createElement('input');
|
||||||
|
tagSearchInput.id = 'tagSearchInput';
|
||||||
|
tagSearchInput.placeholder = 'Filter by tag';
|
||||||
|
tagSearchInput.style.marginLeft = '10px';
|
||||||
|
tagSearchInput.style.padding = '5px';
|
||||||
|
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
|
||||||
|
tagSearchInput.addEventListener('input', () => {
|
||||||
|
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
|
||||||
|
if (window.currentFolder) {
|
||||||
|
renderFileTable(window.currentFolder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterFilesByTag(files) {
|
||||||
|
if (window.currentTagFilter && window.currentTagFilter !== '') {
|
||||||
|
return files.filter(file => {
|
||||||
|
if (file.tags && file.tags.length > 0) {
|
||||||
|
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobalTagList() {
|
||||||
|
const dataList = document.getElementById("globalTagList");
|
||||||
|
if (dataList) {
|
||||||
|
dataList.innerHTML = "";
|
||||||
|
window.globalTags.forEach(tag => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = tag.name;
|
||||||
|
dataList.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
|
||||||
|
const folder = file.folder || "root";
|
||||||
|
const payload = {
|
||||||
|
folder: folder,
|
||||||
|
file: file.name,
|
||||||
|
tags: file.tags
|
||||||
|
};
|
||||||
|
if (deleteGlobal && tagToDelete) {
|
||||||
|
payload.file = "global";
|
||||||
|
payload.deleteGlobal = true;
|
||||||
|
payload.tagToDelete = tagToDelete;
|
||||||
|
}
|
||||||
|
fetch("saveFileTag.php", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log("Tags saved:", data);
|
||||||
|
if (data.globalTags) {
|
||||||
|
window.globalTags = data.globalTags;
|
||||||
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
|
updateCustomTagDropdown();
|
||||||
|
updateMultiCustomTagDropdown();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Error saving tags:", data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error saving tags:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -93,9 +93,14 @@ foreach ($files as $file) {
|
|||||||
'modified' => $fileDateModified,
|
'modified' => $fileDateModified,
|
||||||
'uploaded' => $fileUploadedDate,
|
'uploaded' => $fileUploadedDate,
|
||||||
'size' => $fileSizeFormatted,
|
'size' => $fileSizeFormatted,
|
||||||
'uploader' => $fileUploader
|
'uploader' => $fileUploader,
|
||||||
|
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(["files" => $fileList]);
|
// Load global tags from createdTags.json.
|
||||||
|
$globalTagsFile = META_DIR . "createdTags.json";
|
||||||
|
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
|
||||||
|
|
||||||
|
echo json_encode(["files" => $fileList, "globalTags" => $globalTags]);
|
||||||
?>
|
?>
|
||||||
2
main.js
2
main.js
@@ -18,6 +18,7 @@ import { initUpload } from './upload.js';
|
|||||||
import { initAuth, checkAuthentication } from './auth.js';
|
import { initAuth, checkAuthentication } from './auth.js';
|
||||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
import { setupTrashRestoreDelete } from './trashRestoreDelete.js';
|
||||||
import { initDragAndDrop, loadSidebarOrder } from './dragAndDrop.js'
|
import { initDragAndDrop, loadSidebarOrder } from './dragAndDrop.js'
|
||||||
|
import { initTagSearch, openTagModal, filterFilesByTag } from './fileTags.js';
|
||||||
|
|
||||||
function loadCsrfToken() {
|
function loadCsrfToken() {
|
||||||
fetch('token.php', { credentials: 'include' })
|
fetch('token.php', { credentials: 'include' })
|
||||||
@@ -129,6 +130,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
checkAuthentication().then(authenticated => {
|
checkAuthentication().then(authenticated => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
window.currentFolder = "root";
|
window.currentFolder = "root";
|
||||||
|
initTagSearch();
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
initDragAndDrop();
|
initDragAndDrop();
|
||||||
loadSidebarOrder();
|
loadSidebarOrder();
|
||||||
|
|||||||
138
saveFileTag.php
Normal file
138
saveFileTag.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'config.php';
|
||||||
|
header("Cache-Control: no-cache, no-store, must-revalidate");
|
||||||
|
header("Pragma: no-cache");
|
||||||
|
header("Expires: 0");
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication.
|
||||||
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
|
echo json_encode(["error" => "Unauthorized"]);
|
||||||
|
http_response_code(401);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection: validate token from header.
|
||||||
|
$headers = getallheaders();
|
||||||
|
if (!isset($headers['X-CSRF-Token']) || $headers['X-CSRF-Token'] !== $_SESSION['csrf_token']) {
|
||||||
|
echo json_encode(["error" => "Invalid CSRF token."]);
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve and sanitize input.
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$file = isset($data['file']) ? trim($data['file']) : '';
|
||||||
|
$folder = isset($data['folder']) ? trim($data['folder']) : 'root';
|
||||||
|
$tags = isset($data['tags']) ? $data['tags'] : [];
|
||||||
|
|
||||||
|
// Basic validation.
|
||||||
|
if ($file === '') {
|
||||||
|
echo json_encode(["error" => "No file specified."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$globalTagsFile = META_DIR . "createdTags.json";
|
||||||
|
|
||||||
|
// If file is "global", update the global tags only.
|
||||||
|
if ($file === "global") {
|
||||||
|
if (!file_exists($globalTagsFile)) {
|
||||||
|
if (file_put_contents($globalTagsFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
|
||||||
|
echo json_encode(["error" => "Failed to create global tags file."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$globalTags = json_decode(file_get_contents($globalTagsFile), true);
|
||||||
|
if (!is_array($globalTags)) {
|
||||||
|
$globalTags = [];
|
||||||
|
}
|
||||||
|
// If deleteGlobal flag is set and tagToDelete is provided, remove it.
|
||||||
|
if (isset($data['deleteGlobal']) && $data['deleteGlobal'] === true && isset($data['tagToDelete'])) {
|
||||||
|
$tagToDelete = strtolower($data['tagToDelete']);
|
||||||
|
$globalTags = array_values(array_filter($globalTags, function($globalTag) use ($tagToDelete) {
|
||||||
|
return strtolower($globalTag['name']) !== $tagToDelete;
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Otherwise, merge new tags.
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$found = false;
|
||||||
|
foreach ($globalTags as &$globalTag) {
|
||||||
|
if (strtolower($globalTag['name']) === strtolower($tag['name'])) {
|
||||||
|
$globalTag['color'] = $tag['color'];
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
$globalTags[] = $tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT)) === false) {
|
||||||
|
echo json_encode(["error" => "Failed to save global tags."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
echo json_encode(["success" => "Global tags updated successfully.", "globalTags" => $globalTags]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate folder name.
|
||||||
|
if ($folder !== 'root' && !preg_match('/^[A-Za-z0-9_\- \/]+$/', $folder)) {
|
||||||
|
echo json_encode(["error" => "Invalid folder name."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadataFilePath($folder) {
|
||||||
|
if (strtolower($folder) === 'root' || $folder === '') {
|
||||||
|
return META_DIR . "root_metadata.json";
|
||||||
|
}
|
||||||
|
return META_DIR . str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadataFile = getMetadataFilePath($folder);
|
||||||
|
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
||||||
|
|
||||||
|
if (!isset($metadata[$file])) {
|
||||||
|
$metadata[$file] = [];
|
||||||
|
}
|
||||||
|
$metadata[$file]['tags'] = $tags;
|
||||||
|
|
||||||
|
if (file_put_contents($metadataFile, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
|
||||||
|
echo json_encode(["error" => "Failed to save tag data."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now update the global tags file as well.
|
||||||
|
if (!file_exists($globalTagsFile)) {
|
||||||
|
if (file_put_contents($globalTagsFile, json_encode([], JSON_PRETTY_PRINT)) === false) {
|
||||||
|
echo json_encode(["error" => "Failed to create global tags file."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$globalTags = json_decode(file_get_contents($globalTagsFile), true);
|
||||||
|
if (!is_array($globalTags)) {
|
||||||
|
$globalTags = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$found = false;
|
||||||
|
foreach ($globalTags as &$globalTag) {
|
||||||
|
if (strtolower($globalTag['name']) === strtolower($tag['name'])) {
|
||||||
|
$globalTag['color'] = $tag['color'];
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
$globalTags[] = $tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($globalTagsFile, json_encode($globalTags, JSON_PRETTY_PRINT)) === false) {
|
||||||
|
echo json_encode(["error" => "Failed to save global tags."]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(["success" => "Tag data saved successfully.", "tags" => $tags, "globalTags" => $globalTags]);
|
||||||
|
?>
|
||||||
Reference in New Issue
Block a user