(function () {
window.AppItems = window.AppItems || [];
const section = {
title: "File Manager",
html: `
<div class="fm-container">
<div class="fm-toolbar">
<div class="fm-left">
<button id="fmUp" class="fm-btn">⬅️ Up</button>
<button id="fmRefresh" class="fm-btn">🔄 Refresh</button>
<button id="fmNewFolder" class="fm-btn">📁 New Folder</button>
<button id="fmNewFile" class="fm-btn">📄 New File</button>
<button id="fmUpload" class="fm-btn">⬆️ Upload</button>
<input type="file" id="fmFileInput" style="display:none" />
</div>
<span id="fmStatus" class="fm-status">Not connected</span>
</div>
<div id="fmList" class="fm-list">
<p style="color:#94a3b8;">Connect to your SFTP server to view files.</p>
</div>
</div>
<!-- Toasts -->
<div class="fm-toast-container" id="fmToastContainer" aria-live="polite"></div>
<!-- Confirm Modal -->
<div class="fm-modal" id="fmModal" aria-hidden="true" role="dialog" aria-modal="true">
<div class="fm-modal__backdrop"></div>
<div class="fm-modal__dialog" role="document">
<div class="fm-modal__title" id="fmModalTitle">Confirm</div>
<div class="fm-modal__body" id="fmModalBody">Are you sure?</div>
<div class="fm-modal__actions">
<button class="fm-btn fm-btn--ghost" id="fmModalCancel">Cancel</button>
<button class="fm-btn fm-btn--danger" id="fmModalConfirm">Delete</button>
</div>
</div>
</div>
<style>
.fm-container { display:flex; flex-direction:column; height:100%; }
.fm-toolbar {
display:flex; justify-content:space-between; align-items:center;
background:rgba(30,41,59,0.8);
border-bottom:1px solid rgba(71,85,105,0.3);
padding:0.5rem 1rem; border-radius:0.5rem 0.5rem 0 0;
flex-wrap:wrap; gap:0.5rem;
}
.fm-left { display:flex; flex-wrap:wrap; gap:0.5rem; }
.fm-btn {
background:linear-gradient(135deg,#3b82f6,#9333ea);
border:none; border-radius:6px; color:white;
padding:0.4rem 0.75rem; cursor:pointer;
font-weight:600; font-size:0.85rem;
}
.fm-btn:hover { opacity:0.9; }
.fm-btn--ghost {
background:transparent; border:1px solid rgba(71,85,105,0.6); color:#e2e8f0;
}
.fm-btn--primary {
background:linear-gradient(135deg,#3b82f6,#9333ea);
}
.fm-btn--danger {
background:linear-gradient(135deg,#ef4444,#dc2626);
}
.fm-status { font-size:0.9rem; color:#94a3b8; }
.fm-list {
flex:1; overflow-y:auto; padding:1rem;
display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
gap:0.75rem;
}
.fm-item {
position:relative;
background:rgba(30,41,59,0.7);
border:1px solid rgba(71,85,105,0.4);
border-radius:8px; padding:0.75rem;
display:flex; flex-direction:column; justify-content:space-between;
transition:all 0.15s ease;
z-index:1;
}
.fm-item:hover { background:rgba(51,65,85,0.9); transform:translateY(-2px); }
.fm-item.menu-open {
z-index:100000;
}
.fm-name { font-weight:600; color:#e2e8f0; word-break:break-all; cursor:pointer; }
.fm-meta { font-size:0.8rem; color:#94a3b8; margin-top:0.25rem; }
.fm-actions {
position:absolute; top:6px; right:6px;
background:transparent; border:none; color:#9aa4b2;
font-size:1rem; cursor:pointer;
}
.fm-menu {
display:none; position:absolute; top:28px; right:6px;
background:rgba(15,23,42,0.98);
border:1px solid rgba(71,85,105,0.4);
border-radius:8px; padding:4px 0;
min-width:160px; z-index:99999;
box-shadow:0 8px 16px rgba(0,0,0,0.4);
}
.fm-menu.open { display:block; }
.fm-menu.menu-above {
top: auto;
bottom: 28px;
}
.fm-menu button {
display:block; width:100%; background:none; border:none;
color:#e2e8f0; text-align:left; padding:8px 12px; font-size:0.85rem;
cursor:pointer;
}
.fm-menu button:hover { background:rgba(51,65,85,0.8); }
/* Toasts */
.fm-toast-container {
position: fixed; right: 16px; bottom: 16px; z-index: 99999;
display: flex; flex-direction: column; gap: 8px;
}
.fm-toast {
background: rgba(15,23,42,0.98);
border:1px solid rgba(71,85,105,0.5);
color:#e2e8f0; padding:10px 14px; border-radius:10px;
box-shadow:0 8px 20px rgba(0,0,0,0.35);
font-size: 0.9rem; opacity: 0; transform: translateY(8px);
animation: fm-toast-in 200ms ease forwards;
}
.fm-toast--success { border-color: rgba(16,185,129,0.6); }
.fm-toast--error { border-color: rgba(239,68,68,0.6); }
@keyframes fm-toast-in {
to { opacity:1; transform: translateY(0); }
}
@keyframes fm-toast-out {
to { opacity:0; transform: translateY(8px); }
}
/* Modal */
.fm-modal { position: fixed; inset: 0; display: none; z-index: 99998; }
.fm-modal[aria-hidden="false"] { display: block; }
.fm-modal__backdrop {
position:absolute; inset:0; background:rgba(0,0,0,0.5);
backdrop-filter: blur(2px);
}
.fm-modal__dialog {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%,-50%);
width: min(92vw, 420px);
background: rgba(15,23,42, 0.98);
color:#e2e8f0;
border:1px solid rgba(71,85,105,0.5);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
padding: 16px;
}
.fm-modal__title { font-weight: 800; margin-bottom: 8px; }
.fm-modal__body { color:#cbd5e1; margin-bottom: 12px; }
.fm-modal__actions { display:flex; justify-content:flex-end; gap: 8px; }
</style>
`
};
window.AppItems.push(section);
let currentPath = "/";
let modalResolve = null;
// When opening File Manager
document.addEventListener("click", async (e) => {
const btn = e.target.closest(".chip");
if (btn && btn.textContent.includes("File Manager")) {
currentPath = "/";
await loadFiles(currentPath);
}
});
// Toast helpers
function showToast(message, type = "success", timeout = 2200) {
const cont = document.getElementById("fmToastContainer");
if (!cont) return;
const t = document.createElement("div");
t.className = `fm-toast ${type === "error" ? "fm-toast--error" : "fm-toast--success"}`;
t.textContent = message;
cont.appendChild(t);
setTimeout(() => {
t.style.animation = "fm-toast-out 160ms ease forwards";
setTimeout(() => cont.removeChild(t), 170);
}, timeout);
}
// Modal helpers
function confirmModal({ title = "Confirm", body = "Are you sure?", confirmText = "Delete" } = {}) {
const modal = document.getElementById("fmModal");
const titleEl = document.getElementById("fmModalTitle");
const bodyEl = document.getElementById("fmModalBody");
const btnCancel = document.getElementById("fmModalCancel");
const btnConfirm = document.getElementById("fmModalConfirm");
titleEl.textContent = title;
bodyEl.textContent = body;
btnConfirm.textContent = confirmText;
modal.setAttribute("aria-hidden", "false");
return new Promise((resolve) => {
const cleanup = () => {
modal.setAttribute("aria-hidden", "true");
btnCancel.removeEventListener("click", onCancel);
btnConfirm.removeEventListener("click", onConfirm);
document.removeEventListener("keydown", onEsc);
};
const onCancel = () => { cleanup(); resolve(false); };
const onConfirm = () => { cleanup(); resolve(true); };
const onEsc = (e) => { if (e.key === "Escape") onCancel(); };
btnCancel.addEventListener("click", onCancel);
btnConfirm.addEventListener("click", onConfirm);
document.addEventListener("keydown", onEsc);
});
}
// Load directory
async function loadFiles(path = "/") {
const fmList = document.getElementById("fmList");
const fmStatus = document.getElementById("fmStatus");
if (!fmList) return;
fmList.textContent = "Loading...";
fmStatus.textContent = `Loading ${path}...`;
try {
const res = await fetch("SFTPconnector.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "list", path })
});
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await res.text();
console.error('Non-JSON response:', text);
throw new Error('Server returned invalid response. Check console.');
}
const data = await res.json();
if (!data.success) {
fmList.innerHTML = `<p style="color:#ef4444;">${data.message}</p>`;
fmStatus.textContent = "Not connected or access denied";
return;
}
renderFiles(data.data);
fmStatus.textContent = `Path: ${path} (${data.data.length} items)`;
currentPath = path;
} catch (err) {
fmList.innerHTML = `<p style="color:#ef4444;">Error: ${err.message}</p>`;
fmStatus.textContent = "Error";
}
// Toolbar handlers
document.getElementById("fmRefresh").onclick = () => loadFiles(currentPath);
document.getElementById("fmUp").onclick = () => goUpDirectory();
document.getElementById("fmNewFolder").onclick = () => createFolderPrompt();
document.getElementById("fmNewFile").onclick = () => createFilePrompt();
document.getElementById("fmUpload").onclick = () =>
document.getElementById("fmFileInput").click();
const fileInput = document.getElementById("fmFileInput");
fileInput.onchange = async () => {
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append("action", "upload");
formData.append("path", currentPath);
formData.append("file", file);
try {
const res = await fetch("SFTPupload.php", { method: "POST", body: formData });
const data = await res.json();
if (data.success) {
showToast("Uploaded successfully");
loadFiles(currentPath);
} else {
showToast(data.message || "Upload failed", "error");
}
} catch (err) {
showToast("Upload error: " + err.message, "error");
} finally {
fileInput.value = "";
}
};
}
function renderFiles(files) {
const fmList = document.getElementById("fmList");
if (!fmList) return;
fmList.innerHTML = "";
if (!files || files.length === 0) {
fmList.innerHTML = "<p>No files found.</p>";
return;
}
for (const f of files) {
const div = document.createElement("div");
div.className = "fm-item";
div.innerHTML = `
<button class="fm-actions" aria-label="More">⋮</button>
<div class="fm-menu">
${!f.is_dir ? '<button class="fm-copy">📋 Copy to Clipboard</button>' : ''}
${!f.is_dir ? '<button class="fm-instafile">📝 InstaFile (Replace)</button>' : ''}
<button class="fm-delete">🗑️ ${f.name === "_wastebasket" ? "Delete Permanently" : "Move to Trash"}</button>
</div>
<div class="fm-name">${f.is_dir ? "📁" : "📄"} ${f.name}</div>
<div class="fm-meta">${f.is_dir ? "Folder" : formatBytes(f.size)} • ${f.modified}</div>
`;
// Navigate folders by clicking the name
if (f.is_dir && f.name !== "_wastebasket") {
div.querySelector(".fm-name").onclick = () => {
const newPath = currentPath.endsWith("/") ? currentPath + f.name : currentPath + "/" + f.name;
loadFiles(newPath);
};
} else if (f.is_dir && f.name === "_wastebasket") {
// You can still click to peek inside trash if desired:
div.querySelector(".fm-name").onclick = () => {
const newPath = currentPath.endsWith("/") ? currentPath + f.name : currentPath + "/" + f.name;
loadFiles(newPath);
};
}
// Menu toggle with smart positioning
const menu = div.querySelector(".fm-menu");
const toggle = div.querySelector(".fm-actions");
toggle.onclick = (e) => {
e.stopPropagation();
// Close all other menus and remove z-index from all items
document.querySelectorAll(".fm-menu.open").forEach(m => {
m.classList.remove("open");
m.closest(".fm-item").classList.remove("menu-open");
});
// Add z-index to this item
div.classList.add("menu-open");
// Open menu first (so we can measure it)
menu.classList.add("open");
// Force a reflow to ensure menu is rendered
menu.offsetHeight;
// Now calculate positioning
const toggleRect = toggle.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate if menu would overflow bottom of viewport
const wouldOverflowBottom = menuRect.bottom > viewportHeight - 10;
const hasSpaceAbove = toggleRect.top > menuRect.height;
// Position menu above if it would overflow and there's space above
if (wouldOverflowBottom && hasSpaceAbove) {
menu.classList.add("menu-above");
} else {
menu.classList.remove("menu-above");
}
};
// Copy to Clipboard (files only)
const copyBtn = div.querySelector(".fm-copy");
if (copyBtn) {
copyBtn.onclick = async (e) => {
e.stopPropagation();
menu.classList.remove("open");
const fullPath = `${currentPath.replace(/\/$/,"")}/${f.name}`;
try {
showToast("Reading file...", "success");
const res = await fetch("SFTPdownload.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: fullPath })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || 'File not found');
}
const content = await res.text();
// Copy to clipboard
await navigator.clipboard.writeText(content);
showToast(`✅ Copied ${formatBytes(content.length)} to clipboard!`, "success");
} catch (err) {
showToast("Copy failed: " + err.message, "error");
}
};
}
// InstaFile - Replace file content (files only)
const instaBtn = div.querySelector(".fm-instafile");
if (instaBtn) {
instaBtn.onclick = async (e) => {
e.stopPropagation();
menu.classList.remove("open");
const fullPath = `${currentPath.replace(/\/$/,"")}/${f.name}`;
// Open modal to replace file content
const modal = document.getElementById("fmModal");
const titleEl = document.getElementById("fmModalTitle");
const bodyEl = document.getElementById("fmModalBody");
const btnCancel = document.getElementById("fmModalCancel");
const btnConfirm = document.getElementById("fmModalConfirm");
titleEl.textContent = `📝 Replace: ${f.name}`;
btnConfirm.textContent = "Replace File";
btnConfirm.className = "fm-btn fm-btn--danger";
bodyEl.innerHTML = `
<p style="color: #94a3b8; margin-bottom: 12px;">
Paste new content to replace <strong>${f.name}</strong>
</p>
<textarea
id="instaReplaceContent"
placeholder="Tap here and paste your content..."
style="width: 100%; min-height: 250px; padding: 8px; background: #0f1725; border: 1px solid #2a3648; border-radius: 6px; color: #e6edf3; font-family: monospace; font-size: 13px; resize: vertical;"
></textarea>
`;
modal.setAttribute("aria-hidden", "false");
// Focus textarea
setTimeout(() => {
const textarea = document.getElementById("instaReplaceContent");
if (textarea) textarea.focus();
}, 100);
const cleanup = () => {
modal.setAttribute("aria-hidden", "true");
btnCancel.removeEventListener("click", onCancel);
btnConfirm.removeEventListener("click", onConfirm);
};
const onCancel = () => cleanup();
const onConfirm = async () => {
const content = document.getElementById("instaReplaceContent").value;
if (!content) {
showToast("Please paste some content", "error");
return;
}
try {
btnConfirm.disabled = true;
btnConfirm.textContent = "Replacing...";
const res = await fetch("instafile.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: fullPath,
content: content
})
});
const data = await res.json();
if (data.success) {
showToast(`✅ ${f.name} replaced successfully!`, "success");
loadFiles(currentPath);
cleanup();
} else {
throw new Error(data.message || 'Replace failed');
}
} catch (err) {
showToast("Error: " + err.message, "error");
btnConfirm.disabled = false;
btnConfirm.textContent = "Replace File";
}
};
btnCancel.addEventListener("click", onCancel);
btnConfirm.addEventListener("click", onConfirm);
};
}
// Delete / Move to Trash
div.querySelector(".fm-delete").onclick = async (e) => {
e.stopPropagation();
menu.classList.remove("open");
const fullPath = `${currentPath.replace(/\/$/,"")}/${f.name}`;
const isTrashFolder = (f.name === "_wastebasket");
if (isTrashFolder) {
// permanent delete confirm
const ok = await confirmModal({
title: "Permanently delete Trash?",
body: "This will remove _wastebasket and all its contents. This cannot be undone.",
confirmText: "Delete Trash"
});
if (!ok) return;
}
try {
const res = await fetch("SFTPtrash.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: fullPath })
});
const data = await res.json();
if (data.success) {
showToast(isTrashFolder ? "Trash deleted" : "Moved to Trash");
loadFiles(currentPath);
} else {
showToast(data.message || "Operation failed", "error");
}
} catch (err) {
showToast("Error: " + err.message, "error");
}
};
fmList.appendChild(div);
}
// Close any menu if clicking elsewhere
document.addEventListener("click", (e) => {
if (!e.target.closest(".fm-menu") && !e.target.closest(".fm-actions")) {
document.querySelectorAll(".fm-menu.open").forEach(m => {
m.classList.remove("open");
m.closest(".fm-item").classList.remove("menu-open");
});
}
});
}
function goUpDirectory() {
if (currentPath === "/" || currentPath === "") return;
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
const newPath = "/" + parts.join("/");
loadFiles(newPath === "/" ? "/" : newPath);
}
async function createFolderPrompt() {
const name = prompt("Enter new folder name:");
if (!name) return;
try {
const res = await fetch("SFTPconnector.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "create_folder", path: `${currentPath}/${name}` })
});
const data = await res.json();
if (data.success) {
showToast("Folder created");
loadFiles(currentPath);
} else {
showToast(data.message || "Folder create failed", "error");
}
} catch (err) {
showToast("Error: " + err.message, "error");
}
}
async function createFilePrompt() {
const name = prompt("Enter new file name (e.g. newfile.txt):");
if (!name) return;
try {
const res = await fetch("SFTPnewfile.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: `${currentPath}/${name}` })
});
const data = await res.json();
if (data.success) {
showToast("File created");
loadFiles(currentPath);
} else {
showToast(data.message || "File create failed", "error");
}
} catch (err) {
showToast("Error: " + err.message, "error");
}
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + " B";
const units = ["KB", "MB", "GB", "TB"];
let u = -1;
do { bytes /= 1024; ++u; } while (bytes >= 1024 && u < units.length - 1);
return bytes.toFixed(1) + " " + units[u];
}
console.log("[filemanager.js] Loaded - File browser component");
})();