Add folder strip and “Create File” functionality (closes #36)

This commit is contained in:
Ryan
2025-05-19 00:39:10 -04:00
committed by GitHub
parent 20422cf5a7
commit 3fc526df7f
15 changed files with 586 additions and 192 deletions

View File

@@ -1,5 +1,29 @@
# Changelog # Changelog
## Changes 5/19/2025
### Added Folder strip & Create File
- **Folder strip in file list**
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
- Filters to only *direct* children of the current folder, hiding `profile_pics` and `trash`.
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
- Clicking a folder in the strip updates:
- the breadcrumb (via `updateBreadcrumbTitle`)
- the tree selection highlight
- reloads `loadFileList` for the chosen folder.
- **Create File feature**
- New “Create New File” button added to the file-actions toolbar and context menu.
- New endpoint `public/api/file/createFile.php` (handled by `FileController`/`FileModel`):
- Creates an empty file if it doesnt already exist.
- Appends an entry to `<folder>_metadata.json` with `uploaded` timestamp and `uploader`.
- `fileActions.js`:
- Implemented `handleCreateFile()` to show a modal, POST to the new endpoint, and refresh the list.
- Added translations for `create_new_file` and `newfile_placeholder`.
---
## Changees 5/15/2025 ## Changees 5/15/2025
### DragandDrop Upload extended to File List ### DragandDrop Upload extended to File List

View File

@@ -0,0 +1,15 @@
<?php
// public/api/file/createFile.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['success'=>false,'error'=>'Unauthorized']);
exit;
}
$fc = new FileController();
$fc->createFile();

View File

@@ -848,6 +848,11 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
background-color: #00796B; background-color: #00796B;
} }
#createFileBtn {
background-color: #007bff;
color: white;
}
#fileList button.edit-btn { #fileList button.edit-btn {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
@@ -2242,4 +2247,33 @@ body.dark-mode .user-dropdown .user-menu .item:hover {
font-weight: 500; font-weight: 500;
vertical-align: middle; vertical-align: middle;
white-space: nowrap; white-space: nowrap;
}
.folder-strip-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 8px 0;
}
.folder-strip-container .folder-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
width: 80px;
color: inherit; /* icon will pick up text color */
font-size: 0.85em;
}
.folder-strip-container .folder-item i.material-icons {
font-size: 28px;
margin-bottom: 4px;
}
.folder-strip-container .folder-item i.material-icons {
color: currentColor;
}
.folder-strip-container .folder-item:hover {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
} }

View File

@@ -11,13 +11,18 @@
<meta name="share-url" content=""> <meta name="share-url" content="">
<style> <style>
/* hide the app shell until JS says otherwise */ /* hide the app shell until JS says otherwise */
.main-wrapper { display: none; } .main-wrapper {
display: none;
}
/* full-screen white overlay while we check auth */ /* full-screen white overlay while we check auth */
#loadingOverlay { #loadingOverlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0;
background: var(--bg-color,#fff); left: 0;
right: 0;
bottom: 0;
background: var(--bg-color, #fff);
z-index: 9999; z-index: 9999;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -386,6 +391,26 @@
data-i18n-key="download_zip">Download ZIP</button> data-i18n-key="download_zip">Download ZIP</button>
<button id="extractZipBtn" class="btn btn-sm btn-info" data-i18n-title="extract_zip" <button id="extractZipBtn" class="btn btn-sm btn-info" data-i18n-title="extract_zip"
data-i18n-key="extract_zip_button">Extract Zip</button> data-i18n-key="extract_zip_button">Extract Zip</button>
<button id="createFileBtn" class="btn action-btn" data-i18n-key="create_file">
${t('create_file')}
</button>
<!-- Create File Modal -->
<div id="createFileModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="create_new_file">Create New File</h4>
<input
type="text"
id="createFileNameInput"
class="form-control"
placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder"
/>
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
</div>
</div>
</div>
<div id="downloadZipModal" class="modal" style="display:none;"> <div id="downloadZipModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4> <h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
@@ -440,8 +465,7 @@
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) --> <!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
<div id="changePasswordModal" class="modal" style="display:none;"> <div id="changePasswordModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:400px; margin:auto;"> <div class="modal-content" style="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal" <span id="closeChangePasswordModal" class="editor-close-btn">&times;</span>
class="editor-close-btn">&times;</span>
<h3 data-i18n-key="change_password_title">Change Password</h3> <h3 data-i18n-key="change_password_title">Change Password</h3>
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password" <input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
placeholder="Old Password" style="width:100%; margin: 5px 0;" /> placeholder="Old Password" style="width:100%; margin: 5px 0;" />
@@ -459,15 +483,15 @@
<form id="addUserForm"> <form id="addUserForm">
<label for="newUsername" data-i18n-key="username">Username:</label> <label for="newUsername" data-i18n-key="username">Username:</label>
<input type="text" id="newUsername" class="form-control" required /> <input type="text" id="newUsername" class="form-control" required />
<label for="addUserPassword" data-i18n-key="password">Password:</label> <label for="addUserPassword" data-i18n-key="password">Password:</label>
<input type="password" id="addUserPassword" class="form-control" required /> <input type="password" id="addUserPassword" class="form-control" required />
<div id="adminCheckboxContainer"> <div id="adminCheckboxContainer">
<input type="checkbox" id="isAdmin" /> <input type="checkbox" id="isAdmin" />
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label> <label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
</div> </div>
<div class="button-container"> <div class="button-container">
<!-- Cancel stays type="button" --> <!-- Cancel stays type="button" -->
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel"> <button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">

View File

@@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
const version = "v1.3.4"; const version = "v1.3.5";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// ————— Inject updated styles ————— // ————— Inject updated styles —————

View File

@@ -184,11 +184,11 @@ function normalizePicUrl(raw) {
export async function openUserPanel() { export async function openUserPanel() {
// 1) load data // 1) load data
const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser(); const { username = 'User', profile_picture = '', totp_enabled = false } = await fetchCurrentUser();
const raw = profile_picture; const raw = profile_picture;
const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png'; const picUrl = normalizePicUrl(raw) || '/assets/default-avatar.png';
// 2) darkmode helpers // 2) darkmode helpers
const isDark = document.body.classList.contains('dark-mode'); const isDark = document.body.classList.contains('dark-mode');
const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)';
const contentStyle = ` const contentStyle = `
background: ${isDark ? '#2c2c2c' : '#fff'}; background: ${isDark ? '#2c2c2c' : '#fff'};
@@ -196,7 +196,7 @@ export async function openUserPanel() {
padding: 20px; padding: 20px;
max-width: 600px; width:90%; max-width: 600px; width:90%;
border-radius: 8px; border-radius: 8px;
overflow-y: auto; max-height: 415px; overflow-y: auto; max-height: 500px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'}; border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box; box-sizing: border-box;
scrollbar-width: none; scrollbar-width: none;
@@ -210,16 +210,16 @@ export async function openUserPanel() {
modal = document.createElement('div'); modal = document.createElement('div');
modal.id = 'userPanelModal'; modal.id = 'userPanelModal';
Object.assign(modal.style, { Object.assign(modal.style, {
position: 'fixed', position: 'fixed',
top: '0', top: '0',
left: '0', left: '0',
right: '0', right: '0',
bottom: '0', bottom: '0',
background: overlayBg, background: overlayBg,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
zIndex: '1000', zIndex: '1000',
}); });
// content container // content container
@@ -264,7 +264,7 @@ export async function openUserPanel() {
avatarInner.appendChild(label); avatarInner.appendChild(label);
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
fileInput.id = 'profilePicInput'; fileInput.id = 'profilePicInput';
fileInput.accept = 'image/*'; fileInput.accept = 'image/*';
fileInput.style.display = 'none'; fileInput.style.display = 'none';
avatarInner.appendChild(fileInput); avatarInner.appendChild(fileInput);
@@ -301,11 +301,11 @@ export async function openUserPanel() {
totpCb.id = 'userTOTPEnabled'; totpCb.id = 'userTOTPEnabled';
totpCb.style.verticalAlign = 'middle'; totpCb.style.verticalAlign = 'middle';
totpCb.checked = totp_enabled; totpCb.checked = totp_enabled;
totpCb.addEventListener('change', async function() { totpCb.addEventListener('change', async function () {
const resp = await fetch('/api/updateUserPanel.php', { const resp = await fetch('/api/updateUserPanel.php', {
method: 'POST', credentials: 'include', method: 'POST', credentials: 'include',
headers: { headers: {
'Content-Type':'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken 'X-CSRF-Token': window.csrfToken
}, },
body: JSON.stringify({ totp_enabled: this.checked }) body: JSON.stringify({ totp_enabled: this.checked })
@@ -328,14 +328,14 @@ export async function openUserPanel() {
const langSel = document.createElement('select'); const langSel = document.createElement('select');
langSel.id = 'languageSelector'; langSel.id = 'languageSelector';
langSel.className = 'form-select'; langSel.className = 'form-select';
['en','es','fr','de'].forEach(code => { ['en', 'es', 'fr', 'de'].forEach(code => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = code; opt.value = code;
opt.textContent = t(code === 'en'? 'english' : code === 'es'? 'spanish' : code === 'fr'? 'french' : 'german'); opt.textContent = t(code === 'en' ? 'english' : code === 'es' ? 'spanish' : code === 'fr' ? 'french' : 'german');
langSel.appendChild(opt); langSel.appendChild(opt);
}); });
langSel.value = localStorage.getItem('language') || 'en'; langSel.value = localStorage.getItem('language') || 'en';
langSel.addEventListener('change', function() { langSel.addEventListener('change', function () {
localStorage.setItem('language', this.value); localStorage.setItem('language', this.value);
setLocale(this.value); setLocale(this.value);
applyTranslations(); applyTranslations();
@@ -343,8 +343,34 @@ export async function openUserPanel() {
langFs.appendChild(langSel); langFs.appendChild(langSel);
content.appendChild(langFs); content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” ---
const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend);
const dispLabel = document.createElement('label');
dispLabel.style.cursor = 'pointer';
const dispCb = document.createElement('input');
dispCb.type = 'checkbox';
dispCb.id = 'showFoldersInList';
dispCb.style.verticalAlign = 'middle';
const stored = localStorage.getItem('showFoldersInList');
dispCb.checked = stored === null ? true : stored === 'true';
dispLabel.appendChild(dispCb);
dispLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(dispLabel);
content.appendChild(dispFs);
dispCb.addEventListener('change', () => {
window.showFoldersInList = dispCb.checked;
localStorage.setItem('showFoldersInList', dispCb.checked);
// reload the entire file list (and strip) in one go:
loadFileList(window.currentFolder);
});
// wire up imageinput change // wire up imageinput change
fileInput.addEventListener('change', async function() { fileInput.addEventListener('change', async function () {
const f = this.files[0]; const f = this.files[0];
if (!f) return; if (!f) return;
// preview immediately // preview immediately
@@ -357,13 +383,13 @@ export async function openUserPanel() {
const fd = new FormData(); const fd = new FormData();
fd.append('profile_picture', f); fd.append('profile_picture', f);
try { try {
const res = await fetch('/api/profile/uploadPicture.php', { const res = await fetch('/api/profile/uploadPicture.php', {
method: 'POST', credentials: 'include', method: 'POST', credentials: 'include',
headers: { 'X-CSRF-Token': window.csrfToken }, headers: { 'X-CSRF-Token': window.csrfToken },
body: fd body: fd
}); });
const text = await res.text(); const text = await res.text();
const js = JSON.parse(text || '{}'); const js = JSON.parse(text || '{}');
if (!res.ok) { if (!res.ok) {
showToast(js.error || t('error_updating_picture')); showToast(js.error || t('error_updating_picture'));
return; return;
@@ -387,9 +413,9 @@ export async function openUserPanel() {
Object.assign(modal.style, { background: overlayBg }); Object.assign(modal.style, { background: overlayBg });
const content = modal.querySelector('.modal-content'); const content = modal.querySelector('.modal-content');
content.style.cssText = contentStyle; content.style.cssText = contentStyle;
modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png'; modal.querySelector('#profilePicPreview').src = picUrl || '/assets/default-avatar.png';
modal.querySelector('#userTOTPEnabled').checked = totp_enabled; modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en'; modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`; modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
} }
@@ -479,7 +505,7 @@ export function openTOTPModal() {
else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error"))); else showToast(t("error_generating_recovery_code") + ": " + (data.message || t("unknown_error")));
closeTOTPModal(false); closeTOTPModal(false);
}); });
// Focus the input and attach enter key listener // Focus the input and attach enter key listener
const totpConfirmInput = document.getElementById("totpConfirmInput"); const totpConfirmInput = document.getElementById("totpConfirmInput");
if (totpConfirmInput) { if (totpConfirmInput) {

View File

@@ -76,6 +76,72 @@ export function handleDownloadZipSelected(e) {
}, 100); }, 100);
}; };
export function handleCreateFileSelected(e) {
e.preventDefault(); e.stopImmediatePropagation();
const modal = document.getElementById('createFileModal');
modal.style.display = 'block';
setTimeout(() => {
const inp = document.getElementById('newFileCreateName');
if (inp) inp.focus();
}, 100);
}
/**
* Open the “New File” modal
*/
export function openCreateFileModal() {
const modal = document.getElementById('createFileModal');
const input = document.getElementById('createFileNameInput');
if (!modal || !input) {
console.error('Create-file modal or input not found');
return;
}
input.value = '';
modal.style.display = 'block';
setTimeout(() => input.focus(), 0);
}
export async function handleCreateFile(e) {
e.preventDefault();
const input = document.getElementById('createFileNameInput');
if (!input) return console.error('Create-file input missing');
const name = input.value.trim();
if (!name) {
showToast(t('newfile_placeholder')); // or a more explicit error
return;
}
const folder = window.currentFolder || 'root';
try {
const res = await fetch('/api/file/createFile.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type':'application/json',
'X-CSRF-Token': window.csrfToken
},
// ⚠️ must send `name`, not `filename`
body: JSON.stringify({ folder, name })
});
const js = await res.json();
if (!js.success) throw new Error(js.error);
showToast(t('file_created'));
loadFileList(folder);
} catch (err) {
showToast(err.message || t('error_creating_file'));
} finally {
document.getElementById('createFileModal').style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
const cancel = document.getElementById('cancelCreateFile');
const confirm = document.getElementById('confirmCreateFile');
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
if (confirm) confirm.addEventListener('click', handleCreateFile);
});
export function openDownloadModal(fileName, folder) { export function openDownloadModal(fileName, folder) {
// Store file details globally for the download confirmation function. // Store file details globally for the download confirmation function.
window.singleFileToDownload = fileName; window.singleFileToDownload = fileName;
@@ -197,6 +263,49 @@ document.addEventListener("DOMContentLoaded", () => {
const progressModal = document.getElementById("downloadProgressModal"); const progressModal = document.getElementById("downloadProgressModal");
const cancelZipBtn = document.getElementById("cancelDownloadZip"); const cancelZipBtn = document.getElementById("cancelDownloadZip");
const confirmZipBtn = document.getElementById("confirmDownloadZip"); const confirmZipBtn = document.getElementById("confirmDownloadZip");
const cancelCreate = document.getElementById('cancelCreateFile');
if (cancelCreate) {
cancelCreate.addEventListener('click', () => {
document.getElementById('createFileModal').style.display = 'none';
});
}
const confirmCreate = document.getElementById('confirmCreateFile');
if (confirmCreate) {
confirmCreate.addEventListener('click', async () => {
const name = document.getElementById('newFileCreateName').value.trim();
if (!name) {
showToast(t('please_enter_filename'));
return;
}
document.getElementById('createFileModal').style.display = 'none';
try {
const res = await fetch('/api/file/createFile.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({
folder: window.currentFolder || 'root',
filename: name
})
});
const js = await res.json();
if (!res.ok || !js.success) {
throw new Error(js.error || t('error_creating_file'));
}
showToast(t('file_created_successfully'));
loadFileList(window.currentFolder);
} catch (err) {
console.error(err);
showToast(err.message || t('error_creating_file'));
}
});
attachEnterKeyListener('createFileModal','confirmCreateFile');
}
// 1) Cancel button hides the name modal // 1) Cancel button hides the name modal
if (cancelZipBtn) { if (cancelZipBtn) {
@@ -553,8 +662,14 @@ export function initFileActions() {
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true)); extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected); document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
} }
const createBtn = document.getElementById('createFileBtn');
if (createBtn) {
createBtn.replaceWith(createBtn.cloneNode(true));
document.getElementById('createFileBtn').addEventListener('click', openCreateFileModal);
}
} }
// Hook up the singlefile download modal buttons // Hook up the singlefile download modal buttons
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile"); const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");

View File

@@ -16,6 +16,7 @@ import { t } from './i18n.js';
import { bindFileListContextMenu } from './fileMenu.js'; import { bindFileListContextMenu } from './fileMenu.js';
import { openDownloadModal } from './fileActions.js'; import { openDownloadModal } from './fileActions.js';
import { openTagModal, openMultiTagModal } from './fileTags.js'; import { openTagModal, openMultiTagModal } from './fileTags.js';
import { getParentFolder, updateBreadcrumbTitle, setupBreadcrumbDelegation } from './folderManager.js';
export let fileData = []; export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true }; export let sortOrder = { column: "uploaded", ascending: true };
@@ -186,171 +187,226 @@ export function formatFolderName(folder) {
window.toggleRowSelection = toggleRowSelection; window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight; window.updateRowHighlight = updateRowHighlight;
export function loadFileList(folderParam) { export async function loadFileList(folderParam) {
const folder = folderParam || "root"; const folder = folderParam || "root";
const fileListContainer = document.getElementById("fileList"); const fileListContainer = document.getElementById("fileList");
const actionsContainer = document.getElementById("fileListActions");
// 1) show loader
fileListContainer.style.visibility = "hidden"; fileListContainer.style.visibility = "hidden";
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>"; fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
return fetch( try {
"/api/file/getFileList.php?folder=" + // 2) fetch files + folders in parallel
encodeURIComponent(folder) + const [filesRes, foldersRes] = await Promise.all([
"&recursive=1&t=" + fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`),
Date.now() fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`)
) ]);
.then((res) =>
res.status === 401
? (window.location.href = "/api/auth/logout.php" && Promise.reject("Unauthorized"))
: res.json()
)
.then((data) => {
fileListContainer.innerHTML = "";
// No files case if (filesRes.status === 401) {
if (!data.files || Object.keys(data.files).length === 0) { window.location.href = "/api/auth/logout.php";
fileListContainer.textContent = t("no_files_found"); throw new Error("Unauthorized");
}
const data = await filesRes.json();
const folderRaw = await foldersRes.json();
// hide summary // --- build ONLY the *direct* children of current folder ---
const summaryElem = document.getElementById("fileSummary"); let subfolders = [];
if (summaryElem) summaryElem.style.display = "none"; const hidden = new Set([ "profile_pics", "trash" ]);
if (Array.isArray(folderRaw)) {
const allPaths = folderRaw.map(item => item.folder ?? item);
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
subfolders = allPaths
.filter(p => {
if (folder === "root") {
return p.indexOf("/") === -1;
}
if (!p.startsWith(folder + "/")) return false;
return p.split("/").length === depth;
})
.map(p => ({ name: p.split("/").pop(), full: p }));
}
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
// hide slider // 3) clear loader
const sliderContainer = document.getElementById("viewSliderContainer"); fileListContainer.innerHTML = "";
if (sliderContainer) sliderContainer.style.display = "none";
updateFileActionButtons(); // 4) handle “no files” case
return []; if (!data.files || Object.keys(data.files).length === 0) {
} fileListContainer.textContent = t("no_files_found");
// Normalize to array // hide summary
if (!Array.isArray(data.files)) { const summaryElem = document.getElementById("fileSummary");
data.files = Object.entries(data.files).map(([name, meta]) => { if (summaryElem) summaryElem.style.display = "none";
meta.name = name;
return meta;
});
}
// Enrich each file
data.files = data.files.map((f) => {
f.fullName = (f.path || f.name).trim().toLowerCase();
f.editable = canEditFile(f.name);
f.folder = folder;
return f;
});
fileData = data.files;
// --- folder summary + slider injection --- // hide slider
const actionsContainer = document.getElementById("fileListActions"); const sliderContainer = document.getElementById("viewSliderContainer");
if (actionsContainer) { if (sliderContainer) sliderContainer.style.display = "none";
// 1) summary
let summaryElem = document.getElementById("fileSummary");
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
actionsContainer.appendChild(summaryElem);
}
summaryElem.style.display = "block";
summaryElem.innerHTML = buildFolderSummary(fileData);
// 2) viewmode slider // hide folder strip
const viewMode = window.viewMode || "table"; const strip = document.getElementById("folderStripContainer");
let sliderContainer = document.getElementById("viewSliderContainer"); if (strip) strip.style.display = "none";
if (!sliderContainer) {
sliderContainer = document.createElement("div");
sliderContainer.id = "viewSliderContainer";
sliderContainer.style.cssText = "display: inline-flex; align-items: center; vertical-align: middle; margin-right: auto; font-size: 0.9em;";
actionsContainer.insertBefore(sliderContainer, summaryElem);
} else {
sliderContainer.style.display = "inline-flex";
}
if (viewMode === "gallery") {
// determine responsive caps:
const w = window.innerWidth;
let maxCols;
if (w < 600) maxCols = 1;
else if (w < 900) maxCols = 2;
else if (w < 1200) maxCols = 4;
else maxCols = 6;
const currentCols = Math.min(
parseInt(localStorage.getItem("galleryColumns") || "3", 10),
maxCols
);
sliderContainer.innerHTML = `
<label for="galleryColumnsSlider" style="margin-right:8px; white-space:nowrap; line-height:1;">
${t("columns")}:
</label>
<input
type="range"
id="galleryColumnsSlider"
min="1"
max="${maxCols}"
value="${currentCols}"
style="vertical-align:middle;"
>
<span id="galleryColumnsValue" style="margin-left:6px; line-height:1;">${currentCols}</span>
`;
// hookup gallery slider
const gallerySlider = document.getElementById("galleryColumnsSlider");
const galleryValue = document.getElementById("galleryColumnsValue");
gallerySlider.oninput = (e) => {
const v = +e.target.value;
localStorage.setItem("galleryColumns", v);
galleryValue.textContent = v;
// update grid if already rendered
const grid = document.querySelector(".gallery-container");
if (grid) grid.style.gridTemplateColumns = `repeat(${v},1fr)`;
};
} else {
const currentHeight = parseInt(localStorage.getItem("rowHeight") ?? "48", 10);
sliderContainer.innerHTML = `
<label for="rowHeightSlider" style="margin-right:8px; white-space:nowrap; line-height:1;">
${t("row_height")}:
</label>
<input type="range" id="rowHeightSlider" min="31" max="60" value="${currentHeight}" style="vertical-align:middle;">
<span id="rowHeightValue" style="margin-left:6px; line-height:1;">${currentHeight}px</span>
`;
// hookup rowheight slider
const rowSlider = document.getElementById("rowHeightSlider");
const rowValue = document.getElementById("rowHeightValue");
rowSlider.oninput = (e) => {
const v = e.target.value;
document.documentElement.style.setProperty("--file-row-height", v + "px");
localStorage.setItem("rowHeight", v);
rowValue.textContent = v + "px";
};
}
}
// 3) Render based on viewMode
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
renderFileTable(folder);
}
updateFileActionButtons(); updateFileActionButtons();
return data.files;
})
.catch((err) => {
console.error("Error loading file list:", err);
if (err !== "Unauthorized") {
fileListContainer.textContent = "Error loading files.";
}
return []; return [];
}) }
.finally(() => {
fileListContainer.style.visibility = "visible"; // 5) normalize files array
if (!Array.isArray(data.files)) {
data.files = Object.entries(data.files).map(([name, meta]) => {
meta.name = name;
return meta;
});
}
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
f.editable = canEditFile(f.name);
f.folder = folder;
return f;
}); });
fileData = data.files;
// 6) inject summary + slider
if (actionsContainer) {
// a) summary
let summaryElem = document.getElementById("fileSummary");
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
actionsContainer.appendChild(summaryElem);
}
summaryElem.style.display = "block";
summaryElem.innerHTML = buildFolderSummary(fileData);
// b) slider
const viewMode = window.viewMode || "table";
let sliderContainer = document.getElementById("viewSliderContainer");
if (!sliderContainer) {
sliderContainer = document.createElement("div");
sliderContainer.id = "viewSliderContainer";
sliderContainer.style.cssText = "display:inline-flex; align-items:center; margin-right:auto; font-size:0.9em;";
actionsContainer.insertBefore(sliderContainer, summaryElem);
} else {
sliderContainer.style.display = "inline-flex";
}
if (viewMode === "gallery") {
const w = window.innerWidth;
let maxCols;
if (w < 600) maxCols = 1;
else if (w < 900) maxCols = 2;
else if (w < 1200) maxCols = 4;
else maxCols = 6;
const currentCols = Math.min(
parseInt(localStorage.getItem("galleryColumns")||"3",10),
maxCols
);
sliderContainer.innerHTML = `
<label for="galleryColumnsSlider" style="margin-right:8px;line-height:1;">
${t("columns")}:
</label>
<input
type="range"
id="galleryColumnsSlider"
min="1"
max="${maxCols}"
value="${currentCols}"
style="vertical-align:middle;"
>
<span id="galleryColumnsValue" style="margin-left:6px;line-height:1;">${currentCols}</span>
`;
const gallerySlider = document.getElementById("galleryColumnsSlider");
const galleryValue = document.getElementById("galleryColumnsValue");
gallerySlider.oninput = e => {
const v = +e.target.value;
localStorage.setItem("galleryColumns", v);
galleryValue.textContent = v;
document.querySelector(".gallery-container")
?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`);
};
} else {
const currentHeight = parseInt(localStorage.getItem("rowHeight")||"48",10);
sliderContainer.innerHTML = `
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
${t("row_height")}:
</label>
<input type="range" id="rowHeightSlider" min="31" max="60" value="${currentHeight}" style="vertical-align:middle;">
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
`;
const rowSlider = document.getElementById("rowHeightSlider");
const rowValue = document.getElementById("rowHeightValue");
rowSlider.oninput = e => {
const v = e.target.value;
document.documentElement.style.setProperty("--file-row-height", v + "px");
localStorage.setItem("rowHeight", v);
rowValue.textContent = v + "px";
};
}
}
// 7) inject folder strip below actions, above file list
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
strip.id = "folderStripContainer";
strip.className = "folder-strip-container";
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
}
if (window.showFoldersInList && subfolders.length) {
strip.innerHTML = subfolders.map(sf => `
<div class="folder-item" data-folder="${sf.full}">
<i class="material-icons">folder</i>
<div class="folder-name">${escapeHTML(sf.name)}</div>
</div>
`).join("");
strip.style.display = "flex";
strip.querySelectorAll(".folder-item").forEach(el => {
el.addEventListener("click", () => {
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
// sync breadcrumb & tree
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected")
.forEach(o => o.classList.remove("selected"));
document.querySelector(`.folder-option[data-folder="${dest}"]`)
?.classList.add("selected");
// reload
loadFileList(dest);
});
});
} else {
strip.style.display = "none";
}
// 8) render files
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
renderFileTable(folder);
}
updateFileActionButtons();
return data.files;
} catch (err) {
console.error("Error loading file list:", err);
if (err.message !== "Unauthorized") {
fileListContainer.textContent = "Error loading files.";
}
return [];
} finally {
fileListContainer.style.visibility = "visible";
}
} }
/** /**
* Update renderFileTable so it writes its content into the provided container. * Update renderFileTable so it writes its content into the provided container.
*/ */
export function renderFileTable(folder, container) { export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList"); const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase(); const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10); const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
@@ -408,6 +464,10 @@ export function renderFileTable(folder, container) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
fileListContent.querySelectorAll('.folder-item').forEach(el => {
el.addEventListener('click', () => loadFileList(el.dataset.folder));
});
// pagination clicks // pagination clicks
const prevBtn = document.getElementById("prevPageBtn"); const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => { if (prevBtn) prevBtn.addEventListener("click", () => {

View File

@@ -1,6 +1,6 @@
// fileMenu.js // fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js'; import { updateRowHighlight, showToast } from './domUtils.js';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile } from './fileActions.js'; import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
import { previewFile } from './filePreview.js'; import { previewFile } from './filePreview.js';
import { editFile } from './fileEditor.js'; import { editFile } from './fileEditor.js';
import { canEditFile, fileData } from './fileListView.js'; import { canEditFile, fileData } from './fileListView.js';
@@ -75,6 +75,7 @@ export function fileListContextMenuHandler(e) {
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value); const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
let menuItems = [ let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } }, { label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } }, { label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } }, { label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },

View File

@@ -56,7 +56,7 @@ function saveFolderTreeState(state) {
} }
// Helper for getting the parent folder. // Helper for getting the parent folder.
function getParentFolder(folder) { export function getParentFolder(folder) {
if (folder === "root") return "root"; if (folder === "root") return "root";
const lastSlash = folder.lastIndexOf("/"); const lastSlash = folder.lastIndexOf("/");
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash); return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
@@ -361,7 +361,7 @@ function renderBreadcrumbFragment(folderPath) {
return frag; return frag;
} }
function updateBreadcrumbTitle(folder) { export function updateBreadcrumbTitle(folder) {
const titleEl = document.getElementById("fileListTitle"); const titleEl = document.getElementById("fileListTitle");
titleEl.textContent = ""; titleEl.textContent = "";
titleEl.appendChild(document.createTextNode(t("files_in") + " (")); titleEl.appendChild(document.createTextNode(t("files_in") + " ("));

View File

@@ -266,7 +266,16 @@ const translations = {
"items_per_page": "items per page", "items_per_page": "items per page",
"columns": "Columns", "columns": "Columns",
"row_height": "Row Height", "row_height": "Row Height",
"api_docs": "API Docs" "api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
"enter_file_name": "Enter file name",
"newfile_placeholder": "New file name",
"file_created_successfully": "File created successfully!",
"error_creating_file": "Error creating file",
"file_created": "File created successfully!"
}, },
es: { es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.", "please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -20,7 +20,8 @@ export function initializeApp() {
window.currentFolder = "root"; window.currentFolder = "root";
initTagSearch(); initTagSearch();
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
const fileListArea = document.getElementById('fileListContainer'); const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea'); const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) { if (fileListArea && uploadArea) {
@@ -42,7 +43,7 @@ export function initializeApp() {
})); }));
}); });
} }
initDragAndDrop(); initDragAndDrop();
loadSidebarOrder(); loadSidebarOrder();
loadHeaderOrder(); loadHeaderOrder();

View File

@@ -1626,4 +1626,31 @@ class FileController
echo json_encode(['success' => false, 'error' => 'Not found']); echo json_encode(['success' => false, 'error' => 'Not found']);
} }
} }
/**
* POST /api/file/createFile.php
*/
public function createFile(): void
{
// Check user permissions (assuming loadUserPermissions() is available).
$username = $_SESSION['username'] ?? '';
$userPermissions = loadUserPermissions($username);
if (!empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to create files."]);
exit;
}
$body = json_decode(file_get_contents('php://input'), true);
$folder = $body['folder'] ?? 'root';
$filename = $body['name'] ?? '';
$result = FileModel::createFile($folder, $filename, $_SESSION['username'] ?? 'Unknown');
if (!$result['success']) {
http_response_code($result['code'] ?? 400);
echo json_encode(['success'=>false,'error'=>$result['error']]);
} else {
echo json_encode(['success'=>true]);
}
}
} }

View File

@@ -340,16 +340,14 @@ class FolderController
public function getFolderList(): void public function getFolderList(): void
{ {
header('Content-Type: application/json'); header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401); http_response_code(401);
echo json_encode(["error" => "Unauthorized"]); echo json_encode(["error" => "Unauthorized"]);
exit; exit;
} }
// Optionally, you might add further input validation if necessary. $parent = $_GET['folder'] ?? null;
$folderList = FolderModel::getFolderList(); $folderList = FolderModel::getFolderList($parent);
echo json_encode($folderList); echo json_encode($folderList);
exit; exit;
} }
@@ -1087,11 +1085,11 @@ class FolderController
header('Content-Type: application/json'); header('Content-Type: application/json');
$shareFile = META_DIR . 'share_folder_links.json'; $shareFile = META_DIR . 'share_folder_links.json';
$links = file_exists($shareFile) $links = file_exists($shareFile)
? json_decode(file_get_contents($shareFile), true) ?? [] ? json_decode(file_get_contents($shareFile), true) ?? []
: []; : [];
$now = time(); $now = time();
$cleaned = []; $cleaned = [];
// 1) Remove expired // 1) Remove expired
foreach ($links as $token => $record) { foreach ($links as $token => $record) {
if (!empty($record['expires']) && $record['expires'] < $now) { if (!empty($record['expires']) && $record['expires'] < $now) {
@@ -1099,12 +1097,12 @@ class FolderController
} }
$cleaned[$token] = $record; $cleaned[$token] = $record;
} }
// 2) Persist back if anything was pruned // 2) Persist back if anything was pruned
if (count($cleaned) !== count($links)) { if (count($cleaned) !== count($links)) {
file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT)); file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
} }
echo json_encode($cleaned); echo json_encode($cleaned);
} }

View File

@@ -1278,4 +1278,64 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT)); file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT));
return true; return true;
} }
/**
* Create an empty file plus metadata entry.
*
* @param string $folder
* @param string $filename
* @param string $uploader
* @return array ['success'=>bool, 'error'=>string, 'code'=>int]
*/
public static function createFile(string $folder, string $filename, string $uploader): array
{
// 1) basic validation
if (!preg_match('/^[\w\-. ]+$/', $filename)) {
return ['success'=>false,'error'=>'Invalid filename','code'=>400];
}
// 2) build target path
$base = UPLOAD_DIR;
if ($folder !== 'root') {
$base = rtrim(UPLOAD_DIR, '/\\')
. DIRECTORY_SEPARATOR . $folder
. DIRECTORY_SEPARATOR;
}
if (!is_dir($base) && !mkdir($base, 0775, true)) {
return ['success'=>false,'error'=>'Cannot create folder','code'=>500];
}
$path = $base . $filename;
// 3) no overwrite
if (file_exists($path)) {
return ['success'=>false,'error'=>'File already exists','code'=>400];
}
// 4) touch the file
if (false === @file_put_contents($path, '')) {
return ['success'=>false,'error'=>'Could not create file','code'=>500];
}
// 5) write metadata
$metaKey = ($folder === 'root') ? 'root' : $folder;
$metaName = str_replace(['/', '\\', ' '], '-', $metaKey) . '_metadata.json';
$metaPath = META_DIR . $metaName;
$collection = [];
if (file_exists($metaPath)) {
$json = file_get_contents($metaPath);
$collection = json_decode($json, true) ?: [];
}
$collection[$filename] = [
'uploaded' => date(DATE_TIME_FORMAT),
'uploader' => $uploader
];
if (false === file_put_contents($metaPath, json_encode($collection, JSON_PRETTY_PRINT))) {
return ['success'=>false,'error'=>'Failed to update metadata','code'=>500];
}
return ['success'=>true];
}
} }