botui/ui/suite/drive/drive.js

1247 lines
42 KiB
JavaScript
Raw Normal View History

/* Drive Module v1.0 - Full API Integration */
(function () {
"use strict";
const API_BASE = "/api/files";
let currentBucket = "";
let currentPath = "";
let availableBuckets = [];
let selectedFiles = new Set();
let viewMode = "list";
let clipboardFiles = [];
let clipboardOperation = null;
let retryCount = 0;
const MAX_RETRIES = 3;
const RETRY_DELAYS = [1000, 3000, 10000]; // Exponential backoff: 1s, 3s, 10s
function escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeJs(str) {
if (!str) return "";
return String(str)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
}
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + " " + units[i];
}
function formatDate(dateStr) {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function getFileTypeClass(filename) {
const ext = (filename || "").split(".").pop().toLowerCase();
const types = {
document: ["doc", "docx", "pdf", "txt", "rtf", "odt"],
image: ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"],
video: ["mp4", "avi", "mov", "mkv", "webm"],
audio: ["mp3", "wav", "ogg", "flac", "aac"],
archive: ["zip", "rar", "7z", "tar", "gz"],
code: [
"js",
"ts",
"py",
"rs",
"go",
"java",
"c",
"cpp",
"h",
"html",
"css",
"json",
"xml",
],
};
for (const [type, exts] of Object.entries(types)) {
if (exts.includes(ext)) return type;
}
return "file";
}
function getFolderIcon() {
return '<svg width="20" height="20" viewBox="0 0 24 24" fill="#5f6368" stroke="none"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
}
function getFileIcon(filename) {
const ext = (filename || "").split(".").pop().toLowerCase();
const colors = {
pdf: "#ea4335",
doc: "#4285f4",
docx: "#4285f4",
xls: "#0f9d58",
xlsx: "#0f9d58",
ppt: "#fbbc04",
pptx: "#fbbc04",
};
const color = colors[ext] || "#5f6368";
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="${color}" stroke="none"><path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/></svg>`;
}
function showNotification(message, type) {
const existing = document.querySelector(".drive-notification");
if (existing) existing.remove();
const notification = document.createElement("div");
notification.className = `drive-notification notification-${type || "info"}`;
notification.textContent = message;
notification.style.cssText =
"position:fixed;bottom:20px;right:20px;padding:12px 20px;border-radius:8px;background:#333;color:#fff;z-index:9999;animation:slideIn 0.3s ease;";
if (type === "error") notification.style.background = "#ef4444";
if (type === "success") notification.style.background = "#22c55e";
if (type === "warning") notification.style.background = "#f59e0b";
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 4000);
}
async function init() {
bindNavigation();
bindViewToggle();
bindDragAndDrop();
bindContextMenu();
bindKeyboardShortcuts();
bindUploadButton();
bindNewFolderButton();
bindSearchInput();
await discoverBuckets();
loadStorageInfo();
loadFiles();
}
async function discoverBuckets() {
try {
const buckets = await apiRequest("/buckets");
availableBuckets = buckets || [];
retryCount = 0; // Reset on success
const gbai = availableBuckets.find((b) => b.is_gbai);
if (gbai) {
currentBucket = gbai.name;
} else if (availableBuckets.length > 0) {
currentBucket = availableBuckets[0].name;
}
updateBucketSelector();
if (!currentBucket) {
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (content) {
content.innerHTML = `<div class="empty-state"><svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><h3>No drive storage found</h3><p>Please contact your administrator to set up storage.</p></div>`;
}
}
} catch (err) {
console.error("Failed to discover buckets:", err);
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (content) {
const canRetry = retryCount < MAX_RETRIES;
const retryMsg = canRetry
? `<button class="btn-primary" onclick="DriveModule.retryWithBackoff()">Retry</button>`
: `<p class="text-muted">Max retries reached. Please refresh the page.</p>`;
content.innerHTML = `<div class="empty-state"><svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><h3>Drive connection error</h3><p>${escapeHtml(err.message)}</p>${retryMsg}</div>`;
}
}
}
async function retryWithBackoff() {
if (retryCount >= MAX_RETRIES) {
showNotification(
"Max retries reached. Please refresh the page.",
"error",
);
return;
}
const delay =
RETRY_DELAYS[retryCount] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
retryCount++;
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (content) {
content.innerHTML = `<div class="empty-state"><div class="spinner"></div><p>Retrying in ${delay / 1000}s... (attempt ${retryCount}/${MAX_RETRIES})</p></div>`;
}
await new Promise((resolve) => setTimeout(resolve, delay));
await init();
}
function updateBucketSelector() {
const selector = document.getElementById("bucket-selector");
if (!selector) return;
if (availableBuckets.length <= 1) {
selector.style.display = "none";
return;
}
selector.style.display = "block";
selector.innerHTML = availableBuckets
.map(
(b) =>
`<option value="${escapeHtml(b.name)}" ${b.name === currentBucket ? "selected" : ""}>${escapeHtml(b.is_gbai ? b.name.replace(".gbai", "") : b.name)}</option>`,
)
.join("");
selector.removeEventListener("change", handleBucketChange);
selector.addEventListener("change", handleBucketChange);
}
function handleBucketChange(e) {
currentBucket = e.target.value;
currentPath = "";
loadFiles();
}
async function apiRequest(endpoint, options = {}) {
const url = `${API_BASE}${endpoint}`;
// Use global ApiClient if available for automatic auth token injection
if (window.ApiClient) {
try {
return await window.ApiClient.request(url, options);
} catch (err) {
console.error(`API Error [${endpoint}]:`, err);
throw err;
}
}
// Fallback if ApiClient not loaded
const defaultHeaders = { "Content-Type": "application/json" };
// Try to get auth token from storage
const token =
localStorage.getItem("gb-access-token") ||
sessionStorage.getItem("gb-access-token");
if (token) {
defaultHeaders["Authorization"] = `Bearer ${token}`;
}
try {
const response = await fetch(url, {
headers: { ...defaultHeaders, ...options.headers },
...options,
});
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ error: response.statusText }));
throw new Error(error.error || "Request failed");
}
return response.json();
} catch (err) {
console.error(`API Error [${endpoint}]:`, err);
throw err;
}
}
async function loadFiles(path, bucket) {
if (path !== undefined) currentPath = path;
if (bucket !== undefined) currentBucket = bucket;
if (!currentBucket) {
await discoverBuckets();
if (!currentBucket) return;
}
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (!content) return;
content.innerHTML =
'<div class="loading-state"><div class="spinner"></div><p>Loading files...</p></div>';
updateBreadcrumb();
try {
const params = new URLSearchParams();
if (currentBucket) params.set("bucket", currentBucket);
if (currentPath) params.set("path", currentPath);
const files = await apiRequest(`/list?${params.toString()}`);
renderFiles(files);
} catch (err) {
content.innerHTML = `<div class="empty-state"><h3>Failed to load files</h3><p>${escapeHtml(err.message)}</p><button class="btn-primary" onclick="DriveModule.loadFiles()">Retry</button></div>`;
}
}
async function loadStorageInfo() {
try {
const quota = await apiRequest("/quota");
const usedEl = document.getElementById("storage-used");
const fillEl = document.getElementById("storage-fill");
const detailEl = document.getElementById("storage-detail");
if (usedEl)
usedEl.textContent = `${formatFileSize(quota.used_bytes)} of ${formatFileSize(quota.total_bytes)}`;
if (fillEl) fillEl.style.width = `${quota.percentage_used || 0}%`;
if (detailEl)
detailEl.textContent = `${formatFileSize(quota.available_bytes)} available`;
} catch (err) {
// Silently fail - don't retry storage info, it's not critical
console.error("Failed to load storage info:", err);
}
}
function renderFiles(files) {
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (!content) return;
if (!files || files.length === 0) {
content.innerHTML = `<div class="empty-state"><svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><h3>This folder is empty</h3><p>Upload files or create a new folder to get started</p></div>`;
return;
}
const folders = files
.filter((f) => f.is_dir)
.sort((a, b) => a.name.localeCompare(b.name));
const regularFiles = files
.filter((f) => !f.is_dir)
.sort((a, b) => a.name.localeCompare(b.name));
const sorted = [...folders, ...regularFiles];
if (viewMode === "grid") {
content.innerHTML = `<div class="file-grid">${sorted.map((f) => renderFileCard(f)).join("")}</div>`;
} else {
content.innerHTML = `<div class="file-list"><div class="file-list-header"><div class="file-col file-name-col">Name</div><div class="file-col file-modified-col">Modified</div><div class="file-col file-size-col">Size</div><div class="file-col file-actions-col"></div></div>${sorted.map((f) => renderFileRow(f)).join("")}</div>`;
}
bindFileEvents();
updateSelectionBar();
}
function renderFileCard(file) {
const iconClass = file.is_dir ? "folder" : getFileTypeClass(file.name);
const iconSvg = file.is_dir ? getFolderIcon() : getFileIcon(file.name);
const sizeText = file.is_dir ? "" : formatFileSize(file.size);
const checked = selectedFiles.has(file.path) ? "checked" : "";
const selected = selectedFiles.has(file.path) ? "selected" : "";
return `<div class="file-card ${selected}" data-path="${escapeHtml(file.path)}" data-name="${escapeHtml(file.name)}" data-type="${file.is_dir ? "folder" : "file"}" data-size="${file.size || 0}"><input type="checkbox" class="file-checkbox" ${checked} onchange="DriveModule.toggleSelection('${escapeJs(file.path)}')"><div class="file-card-preview ${iconClass}">${iconSvg}</div><div class="file-card-info"><div class="file-card-name" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</div><div class="file-card-meta">${sizeText}</div></div></div>`;
}
function renderFileRow(file) {
const iconSvg = file.is_dir ? getFolderIcon() : getFileIcon(file.name);
const sizeText = file.is_dir ? "—" : formatFileSize(file.size);
const modifiedText = file.modified ? formatDate(file.modified) : "—";
const checked = selectedFiles.has(file.path) ? "checked" : "";
const selected = selectedFiles.has(file.path) ? "selected" : "";
const downloadIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`;
const moreIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>`;
const downloadBtn = !file.is_dir
? `<button class="btn-icon-sm" title="Download" onclick="event.stopPropagation(); DriveModule.downloadFile('${escapeJs(file.path)}')">${downloadIcon}</button>`
: "";
return `<div class="drive-file-item ${file.is_dir ? "folder" : ""} ${selected}" data-path="${escapeHtml(file.path)}" data-name="${escapeHtml(file.name)}" data-type="${file.is_dir ? "folder" : "file"}" data-size="${file.size || 0}"><div class="file-col file-name-col"><input type="checkbox" class="file-checkbox" ${checked} onclick="event.stopPropagation()" onchange="DriveModule.toggleSelection('${escapeJs(file.path)}')">${iconSvg}<span>${escapeHtml(file.name)}</span></div><div class="file-col file-modified-col">${modifiedText}</div><div class="file-col file-size-col">${sizeText}</div><div class="file-col file-actions-col">${downloadBtn}<button class="btn-icon-sm" title="More" onclick="event.stopPropagation(); DriveModule.showContextMenuFor(event, '${escapeJs(file.path)}')">${moreIcon}</button></div></div>`;
}
function bindFileEvents() {
document.querySelectorAll(".file-card, .drive-file-item").forEach((el) => {
el.addEventListener("click", function (e) {
if (
e.target.closest(".file-checkbox") ||
e.target.closest(".btn-icon-sm")
)
return;
const path = this.dataset.path;
const type = this.dataset.type;
if (e.ctrlKey || e.metaKey) {
toggleSelection(path);
} else {
// Single click just selects, doesn't open
toggleSelection(path);
}
});
el.addEventListener("dblclick", function (e) {
if (e.target.closest(".file-checkbox")) return;
const path = this.dataset.path;
const type = this.dataset.type;
if (type === "folder") {
loadFiles(path, currentBucket);
} else {
openFile(path);
}
});
});
}
function toggleSelection(path) {
if (selectedFiles.has(path)) selectedFiles.delete(path);
else selectedFiles.add(path);
const el = document.querySelector(`[data-path="${CSS.escape(path)}"]`);
if (el) {
el.classList.toggle("selected", selectedFiles.has(path));
const checkbox = el.querySelector(".file-checkbox");
if (checkbox) checkbox.checked = selectedFiles.has(path);
}
updateSelectionBar();
}
function selectAll() {
document.querySelectorAll(".file-card, .drive-file-item").forEach((el) => {
selectedFiles.add(el.dataset.path);
el.classList.add("selected");
const checkbox = el.querySelector(".file-checkbox");
if (checkbox) checkbox.checked = true;
});
updateSelectionBar();
}
function clearSelection() {
selectedFiles.clear();
document
.querySelectorAll(".file-card.selected, .drive-file-item.selected")
.forEach((el) => {
el.classList.remove("selected");
const checkbox = el.querySelector(".file-checkbox");
if (checkbox) checkbox.checked = false;
});
updateSelectionBar();
}
function updateSelectionBar() {
const bar = document.getElementById("selection-bar");
const countEl = document.getElementById("selected-count");
if (bar) bar.style.display = selectedFiles.size > 0 ? "flex" : "none";
if (countEl) countEl.textContent = selectedFiles.size;
}
function updateBreadcrumb() {
const breadcrumb = document.querySelector(".breadcrumb, .drive-breadcrumb");
if (!breadcrumb) return;
const parts = currentPath ? currentPath.split("/").filter(Boolean) : [];
let html = `<button class="breadcrumb-item" onclick="DriveModule.loadFiles('', '${currentBucket}')">My Drive</button>`;
let cumulativePath = "";
parts.forEach((part, idx) => {
cumulativePath += (cumulativePath ? "/" : "") + part;
const isLast = idx === parts.length - 1;
html += `<span class="breadcrumb-sep">/</span>`;
html += isLast
? `<span class="breadcrumb-current">${escapeHtml(part)}</span>`
: `<button class="breadcrumb-item" onclick="DriveModule.loadFiles('${escapeJs(cumulativePath)}', '${currentBucket}')">${escapeHtml(part)}</button>`;
});
breadcrumb.innerHTML = html;
}
function bindNavigation() {
document.querySelectorAll(".drive-nav-item, .nav-item").forEach((item) => {
item.addEventListener("click", function () {
document
.querySelectorAll(".drive-nav-item, .nav-item")
.forEach((i) => i.classList.remove("active"));
this.classList.add("active");
const view = this.dataset.view || this.dataset.filter;
if (view === "my-drive" || !view) loadFiles("", currentBucket);
else if (view === "recent") loadRecentFiles();
else if (view === "starred" || view === "favorite") loadStarredFiles();
else if (view === "shared") loadSharedFiles();
else if (view === "trash") loadTrashFiles();
});
});
}
async function loadRecentFiles() {
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (!content) return;
content.innerHTML =
'<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>';
try {
const files = await apiRequest("/recent");
renderFiles(files);
} catch (err) {
content.innerHTML = `<div class="empty-state"><h3>No recent files</h3></div>`;
}
}
async function loadStarredFiles() {
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (!content) return;
content.innerHTML =
'<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>';
try {
const files = await apiRequest("/favorite");
renderFiles(files);
} catch (err) {
content.innerHTML = `<div class="empty-state"><h3>No starred files</h3></div>`;
}
}
async function loadSharedFiles() {
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (!content) return;
content.innerHTML =
'<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>';
try {
const files = await apiRequest("/shared");
renderFiles(files);
} catch (err) {
content.innerHTML = `<div class="empty-state"><h3>No shared files</h3></div>`;
}
}
async function loadTrashFiles() {
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (!content) return;
content.innerHTML = `<div class="empty-state"><h3>Trash is empty</h3></div>`;
}
function bindViewToggle() {
document.querySelectorAll(".view-toggle, .view-btn").forEach((btn) => {
btn.addEventListener("click", function () {
const view = this.dataset.view;
if (view) {
viewMode = view;
document
.querySelectorAll(".view-toggle, .view-btn")
.forEach((b) => b.classList.remove("active"));
this.classList.add("active");
loadFiles(currentPath, currentBucket);
}
});
});
}
function bindUploadButton() {
const uploadBtn = document.getElementById("upload-btn");
if (uploadBtn) uploadBtn.addEventListener("click", triggerUpload);
window.uploadFile = triggerUpload;
let fileInput = document.getElementById("file-input");
if (!fileInput) {
fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.id = "file-input";
fileInput.multiple = true;
fileInput.style.display = "none";
document.body.appendChild(fileInput);
}
fileInput.addEventListener("change", handleFileInputChange);
}
function triggerUpload() {
const input = document.getElementById("file-input");
if (input) input.click();
}
function handleFileInputChange(e) {
const files = e.target.files;
if (files && files.length > 0) uploadFiles(Array.from(files));
e.target.value = "";
}
async function uploadFiles(files) {
showNotification(`Uploading ${files.length} file(s)...`, "info");
let uploaded = 0;
let failed = 0;
for (const file of files) {
try {
const content = await readFileAsBase64(file);
const filePath = currentPath
? `${currentPath}/${file.name}`
: file.name;
await apiRequest("/write", {
method: "POST",
body: JSON.stringify({
bucket: currentBucket,
path: filePath,
content: content,
}),
});
uploaded++;
} catch (err) {
console.error("Upload error:", err);
failed++;
}
}
if (failed === 0)
showNotification(`Uploaded ${uploaded} file(s)`, "success");
else showNotification(`Uploaded ${uploaded}, ${failed} failed`, "warning");
loadFiles(currentPath, currentBucket);
loadStorageInfo();
}
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(",")[1] || reader.result;
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function bindDragAndDrop() {
const container = document.querySelector(".drive-container, .drive-main");
if (!container) return;
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
container.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
container.addEventListener("dragenter", () => {
container.classList.add("drag-active");
const overlay = document.getElementById("drop-overlay");
if (overlay) overlay.classList.add("visible");
});
container.addEventListener("dragleave", (e) => {
if (!container.contains(e.relatedTarget)) {
container.classList.remove("drag-active");
const overlay = document.getElementById("drop-overlay");
if (overlay) overlay.classList.remove("visible");
}
});
container.addEventListener("drop", (e) => {
container.classList.remove("drag-active");
const overlay = document.getElementById("drop-overlay");
if (overlay) overlay.classList.remove("visible");
const files = e.dataTransfer.files;
if (files && files.length > 0) uploadFiles(Array.from(files));
});
}
function bindContextMenu() {
document.addEventListener("contextmenu", (e) => {
const fileEl = e.target.closest(".file-card, .drive-file-item");
if (fileEl) {
e.preventDefault();
showContextMenu(
e.clientX,
e.clientY,
fileEl.dataset.path,
fileEl.dataset.type,
);
}
});
document.addEventListener("click", (e) => {
const menu = document.getElementById("context-menu");
if (menu && !menu.contains(e.target)) {
menu.classList.add("hidden");
menu.style.display = "none";
}
});
}
function showContextMenu(x, y, path, type) {
let menu = document.getElementById("context-menu");
if (!menu) {
menu = document.createElement("div");
menu.id = "context-menu";
menu.className = "context-menu";
document.body.appendChild(menu);
}
const isFolder = type === "folder";
const ep = escapeJs(path);
const icons = {
open: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>`,
download: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
copy: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
cut: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><line x1="20" y1="4" x2="8.12" y2="15.88"></line><line x1="14.47" y1="14.48" x2="20" y2="20"></line><line x1="8.12" y1="8.12" x2="12" y2="12"></line></svg>`,
rename: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>`,
delete: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>`,
};
const hideMenu = `document.getElementById('context-menu').style.display='none';`;
menu.innerHTML = `
${
isFolder
? `<div class="context-menu-item" onclick="${hideMenu}DriveModule.loadFiles('${ep}', '${currentBucket}')">${icons.open}<span>Open</span></div>`
: `<div class="context-menu-item" onclick="${hideMenu}DriveModule.openFile('${ep}')">${icons.open}<span>Open</span></div>
<div class="context-menu-item" onclick="${hideMenu}DriveModule.downloadFile('${ep}')">${icons.download}<span>Download</span></div>`
}
<div class="context-menu-divider"></div>
<div class="context-menu-item" onclick="${hideMenu}DriveModule.copyToClipboard('${ep}')">${icons.copy}<span>Copy</span></div>
<div class="context-menu-item" onclick="${hideMenu}DriveModule.cutToClipboard('${ep}')">${icons.cut}<span>Cut</span></div>
<div class="context-menu-item" onclick="${hideMenu}DriveModule.renameItem('${ep}')">${icons.rename}<span>Rename</span></div>
<div class="context-menu-divider"></div>
<div class="context-menu-item danger" onclick="${hideMenu}DriveModule.deleteItem('${ep}')">${icons.delete}<span>Delete</span></div>
`;
menu.style.display = "block";
menu.classList.remove("hidden");
const rect = menu.getBoundingClientRect();
menu.style.left =
(x + rect.width > window.innerWidth ? x - rect.width : x) + "px";
menu.style.top =
(y + rect.height > window.innerHeight ? y - rect.height : y) + "px";
}
function showContextMenuFor(event, path) {
const el = document.querySelector(`[data-path="${CSS.escape(path)}"]`);
const type = el ? el.dataset.type : "file";
showContextMenu(event.clientX, event.clientY, path, type);
}
function bindKeyboardShortcuts() {
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
return;
if (e.key === "Delete" && selectedFiles.size > 0) {
e.preventDefault();
deleteSelected();
}
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
selectAll();
}
if ((e.ctrlKey || e.metaKey) && e.key === "c" && selectedFiles.size > 0) {
e.preventDefault();
copySelected();
}
if ((e.ctrlKey || e.metaKey) && e.key === "x" && selectedFiles.size > 0) {
e.preventDefault();
cutSelected();
}
if (
(e.ctrlKey || e.metaKey) &&
e.key === "v" &&
clipboardFiles.length > 0
) {
e.preventDefault();
pasteFiles();
}
if (e.key === "Escape") {
clearSelection();
const menu = document.getElementById("context-menu");
if (menu) menu.style.display = "none";
}
if (e.key === "Backspace" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
navigateUp();
}
if (e.key === "F2" && selectedFiles.size === 1) {
e.preventDefault();
renameItem(Array.from(selectedFiles)[0]);
}
});
}
function navigateUp() {
if (!currentPath) return;
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
loadFiles(parts.join("/"), currentBucket);
}
function bindNewFolderButton() {
const btn = document.getElementById("new-folder-btn");
if (btn) btn.addEventListener("click", createFolder);
window.createFolder = createFolder;
}
async function createFolder() {
const name = prompt("Enter folder name:");
if (!name || !name.trim()) return;
try {
await apiRequest("/createFolder", {
method: "POST",
body: JSON.stringify({
bucket: currentBucket,
path: currentPath,
name: name.trim(),
}),
});
showNotification(`Folder "${name}" created`, "success");
loadFiles(currentPath, currentBucket);
} catch (err) {
showNotification(`Failed to create folder: ${err.message}`, "error");
}
}
function bindSearchInput() {
const searchInput = document.querySelector(
".search-box input, #search-input",
);
if (!searchInput) return;
let debounceTimer;
searchInput.addEventListener("input", (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const query = e.target.value.trim();
if (query) searchFiles(query);
else loadFiles(currentPath, currentBucket);
}, 300);
});
}
async function searchFiles(query) {
const content =
document.getElementById("drive-content") ||
document.getElementById("file-grid");
if (!content) return;
content.innerHTML =
'<div class="loading-state"><div class="spinner"></div><p>Searching...</p></div>';
try {
const params = new URLSearchParams();
params.set("query", query);
if (currentBucket) params.set("bucket", currentBucket);
const files = await apiRequest(`/search?${params.toString()}`);
renderFiles(files);
} catch (err) {
content.innerHTML = `<div class="empty-state"><h3>Search failed</h3></div>`;
}
}
async function downloadFile(path) {
try {
const response = await apiRequest("/download", {
method: "POST",
body: JSON.stringify({ bucket: currentBucket, path: path }),
});
const content = response.content;
const fileName = path.split("/").pop() || "download";
let blob;
const isBase64 =
/^[A-Za-z0-9+/=]+$/.test(content) && content.length > 100;
if (isBase64) {
try {
const byteCharacters = atob(content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++)
byteNumbers[i] = byteCharacters.charCodeAt(i);
blob = new Blob([new Uint8Array(byteNumbers)]);
} catch (e) {
blob = new Blob([content], { type: "text/plain" });
}
} else {
blob = new Blob([content], { type: "text/plain" });
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`Downloaded ${fileName}`, "success");
} catch (err) {
showNotification(`Download failed: ${err.message}`, "error");
}
}
async function openFile(path) {
try {
const response = await apiRequest("/open", {
method: "POST",
body: JSON.stringify({ bucket: currentBucket, path: path }),
});
const { app, url } = response;
if (window.htmx) {
htmx.ajax("GET", url, {
target: "#main-content",
swap: "innerHTML",
});
window.history.pushState(
{},
"",
`/#${app}?bucket=${encodeURIComponent(currentBucket)}&path=${encodeURIComponent(path)}`,
);
} else {
window.location.href = url;
}
} catch (err) {
console.error("Failed to open file:", err);
showNotification(`Failed to open file: ${err.message}`, "error");
}
}
function showEditorModal(path, fileName, content) {
console.log("showEditorModal called:", {
path,
fileName,
contentLength: content?.length,
});
let modal = document.getElementById("editor-modal");
if (modal) {
console.log("Removing existing modal");
modal.remove();
}
const ext = (fileName.split(".").pop() || "txt").toLowerCase();
modal = document.createElement("div");
modal.id = "editor-modal";
modal.className = "modal-overlay";
// Build modal HTML
const headerHtml = `
<div class="editor-header">
<div class="editor-title">
<span class="editor-icon">📝</span>
<span class="editor-filename">${escapeHtml(fileName)}</span>
<span class="editor-status" id="editor-status"></span>
</div>
<div class="editor-actions">
<button class="btn-secondary" onclick="DriveModule.closeEditor()">Cancel</button>
<button class="btn-primary" onclick="DriveModule.saveEditorContent()">
<span>💾</span> Save
</button>
</div>
</div>
`;
const bodyHtml = `
<div class="editor-body">
<textarea
id="editor-textarea"
class="editor-textarea"
spellcheck="false"
data-path="${escapeHtml(path)}"
data-ext="${ext}"
></textarea>
</div>
`;
const footerHtml = `
<div class="editor-footer">
<span class="editor-info">Line: <span id="editor-line">1</span>, Col: <span id="editor-col">1</span></span>
<span class="editor-info">${ext.toUpperCase()}</span>
</div>
`;
modal.innerHTML = `<div class="modal-content editor-modal-content">${headerHtml}${bodyHtml}${footerHtml}</div>`;
document.body.appendChild(modal);
console.log("Modal appended to body");
// Set content via value property to avoid HTML escaping issues
const textarea = document.getElementById("editor-textarea");
if (textarea) {
textarea.value = content || "";
console.log("Textarea content set, length:", textarea.value.length);
textarea.focus();
} else {
console.error("Textarea not found!");
return;
}
textarea.addEventListener("input", () => {
document.getElementById("editor-status").textContent = "● Modified";
});
textarea.addEventListener("click", updateEditorCursor);
textarea.addEventListener("keyup", updateEditorCursor);
textarea.addEventListener("keydown", (e) => {
if (e.key === "s" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
saveEditorContent();
}
if (e.key === "Escape") {
closeEditor();
}
if (e.key === "Tab") {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value =
textarea.value.substring(0, start) +
" " +
textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
}
});
modal.addEventListener("click", (e) => {
if (e.target === modal) closeEditor();
});
}
function updateEditorCursor() {
const textarea = document.getElementById("editor-textarea");
if (!textarea) return;
const text = textarea.value.substring(0, textarea.selectionStart);
const lines = text.split("\n");
const line = lines.length;
const col = lines[lines.length - 1].length + 1;
document.getElementById("editor-line").textContent = line;
document.getElementById("editor-col").textContent = col;
}
async function saveEditorContent() {
const textarea = document.getElementById("editor-textarea");
if (!textarea) return;
const path = textarea.dataset.path;
const content = textarea.value;
const statusEl = document.getElementById("editor-status");
statusEl.textContent = "Saving...";
try {
await apiRequest("/write", {
method: "POST",
body: JSON.stringify({
bucket: currentBucket,
path: path,
content: content,
}),
});
statusEl.textContent = "✓ Saved";
showNotification("File saved successfully", "success");
setTimeout(() => {
if (statusEl) statusEl.textContent = "";
}, 2000);
} catch (err) {
statusEl.textContent = "✗ Save failed";
showNotification(`Failed to save: ${err.message}`, "error");
}
}
function closeEditor() {
const modal = document.getElementById("editor-modal");
const statusEl = document.getElementById("editor-status");
if (statusEl && statusEl.textContent.includes("Modified")) {
if (!confirm("You have unsaved changes. Close anyway?")) {
return;
}
}
if (modal) modal.remove();
}
async function deleteItem(path) {
const fileName = path.split("/").pop();
if (!confirm(`Delete "${fileName}"?`)) return;
try {
await apiRequest("/delete", {
method: "POST",
body: JSON.stringify({ bucket: currentBucket, path: path }),
});
showNotification("Item deleted", "success");
selectedFiles.delete(path);
loadFiles(currentPath, currentBucket);
loadStorageInfo();
} catch (err) {
showNotification(`Delete failed: ${err.message}`, "error");
}
}
async function deleteSelected() {
if (selectedFiles.size === 0) return;
const count = selectedFiles.size;
if (!confirm(`Delete ${count} item(s)?`)) return;
let deleted = 0;
for (const path of selectedFiles) {
try {
await apiRequest("/delete", {
method: "POST",
body: JSON.stringify({ bucket: currentBucket, path: path }),
});
deleted++;
} catch (err) {
console.error(`Failed to delete ${path}:`, err);
}
}
showNotification(
`Deleted ${deleted} of ${count} item(s)`,
deleted === count ? "success" : "warning",
);
clearSelection();
loadFiles(currentPath, currentBucket);
loadStorageInfo();
}
async function renameItem(path) {
const oldName = path.split("/").pop();
const newName = prompt("Enter new name:", oldName);
if (!newName || newName === oldName || !newName.trim()) return;
const parentPath = path.substring(0, path.lastIndexOf("/"));
const newPath = parentPath
? `${parentPath}/${newName.trim()}`
: newName.trim();
try {
await apiRequest("/move", {
method: "POST",
body: JSON.stringify({
source_bucket: currentBucket,
source_path: path,
dest_bucket: currentBucket,
dest_path: newPath,
}),
});
showNotification(`Renamed to "${newName}"`, "success");
loadFiles(currentPath, currentBucket);
} catch (err) {
showNotification(`Rename failed: ${err.message}`, "error");
}
}
function copyToClipboard(path) {
clipboardFiles = [path];
clipboardOperation = "copy";
showNotification("Copied to clipboard", "info");
}
function cutToClipboard(path) {
clipboardFiles = [path];
clipboardOperation = "cut";
showNotification("Cut to clipboard", "info");
}
function copySelected() {
clipboardFiles = Array.from(selectedFiles);
clipboardOperation = "copy";
showNotification(`${clipboardFiles.length} item(s) copied`, "info");
}
function cutSelected() {
clipboardFiles = Array.from(selectedFiles);
clipboardOperation = "cut";
showNotification(`${clipboardFiles.length} item(s) cut`, "info");
}
async function pasteFiles() {
if (clipboardFiles.length === 0) return;
const operation = clipboardOperation;
let processed = 0;
for (const sourcePath of clipboardFiles) {
const fileName = sourcePath.split("/").pop();
const destPath = currentPath ? `${currentPath}/${fileName}` : fileName;
try {
const endpoint = operation === "copy" ? "/copy" : "/move";
await apiRequest(endpoint, {
method: "POST",
body: JSON.stringify({
source_bucket: currentBucket,
source_path: sourcePath,
dest_bucket: currentBucket,
dest_path: destPath,
}),
});
processed++;
} catch (err) {
console.error(`Failed to ${operation} ${sourcePath}:`, err);
}
}
if (operation === "cut") {
clipboardFiles = [];
clipboardOperation = null;
}
showNotification(
`${operation === "copy" ? "Copied" : "Moved"} ${processed} item(s)`,
"success",
);
loadFiles(currentPath, currentBucket);
}
window.DriveModule = {
init,
loadFiles,
loadStorageInfo,
discoverBuckets,
retryWithBackoff,
toggleSelection,
selectAll,
clearSelection,
downloadFile,
openFile,
deleteItem,
deleteSelected,
renameItem,
createFolder,
copyToClipboard,
cutToClipboard,
copySelected,
cutSelected,
pasteFiles,
showContextMenuFor,
navigateUp,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();