2026-05-25 15:36:49 +03:00
|
|
|
(function () {
|
2026-05-25 16:26:47 +03:00
|
|
|
const form = document.querySelector("#upload-form");
|
2026-05-25 15:36:49 +03:00
|
|
|
const dropZone = document.querySelector(".drop-zone");
|
|
|
|
|
const fileInput = document.querySelector("#file-input");
|
2026-05-25 16:26:47 +03:00
|
|
|
const fileSummary = document.querySelector("#file-summary");
|
|
|
|
|
const progress = document.querySelector("#upload-progress");
|
|
|
|
|
const uploadStatus = document.querySelector("#upload-status");
|
|
|
|
|
const result = document.querySelector("#upload-result");
|
|
|
|
|
const resultMeta = document.querySelector("#result-meta");
|
|
|
|
|
const resultList = document.querySelector("#result-list");
|
2026-05-25 16:52:57 +03:00
|
|
|
const uploadQueue = document.querySelector("#upload-queue");
|
|
|
|
|
const totalProgressBar = document.querySelector("#total-progress-bar");
|
|
|
|
|
const copyURL = document.querySelector("#copy-url");
|
2026-05-25 16:26:47 +03:00
|
|
|
const openBox = document.querySelector("#open-box");
|
2026-05-29 23:44:05 +03:00
|
|
|
const manageLink = document.querySelector("#manage-link");
|
2026-05-25 16:52:57 +03:00
|
|
|
const fileBrowser = document.querySelector("[data-file-browser]");
|
|
|
|
|
const viewButtons = document.querySelectorAll("[data-view-button]");
|
|
|
|
|
const previewImages = document.querySelector("[data-preview-images]");
|
2026-05-25 17:05:59 +03:00
|
|
|
const previewActions = document.querySelectorAll("[data-preview-action]");
|
|
|
|
|
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
2026-05-31 02:14:10 +03:00
|
|
|
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
|
2026-05-25 17:05:59 +03:00
|
|
|
let ctrlCopyMode = false;
|
|
|
|
|
let contextFile = null;
|
2026-05-25 17:37:06 +03:00
|
|
|
const contextMenuCloseDistance = 80;
|
2026-05-25 16:52:57 +03:00
|
|
|
|
|
|
|
|
if (fileBrowser) {
|
|
|
|
|
viewButtons.forEach((button) => {
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
const view = button.getAttribute("data-view-button");
|
|
|
|
|
fileBrowser.classList.toggle("is-list", view === "list");
|
|
|
|
|
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
|
|
|
|
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (previewImages) {
|
|
|
|
|
previewImages.addEventListener("click", () => {
|
|
|
|
|
fileBrowser.classList.toggle("images-only");
|
|
|
|
|
previewImages.classList.toggle("is-active");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-25 15:36:49 +03:00
|
|
|
|
2026-05-25 17:05:59 +03:00
|
|
|
if (fileBrowser && fileContextMenu) {
|
|
|
|
|
fileBrowser.addEventListener("contextmenu", (event) => {
|
|
|
|
|
const card = event.target.closest("[data-file-context]");
|
|
|
|
|
if (!card) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
contextFile = {
|
|
|
|
|
previewURL: card.dataset.previewUrl,
|
|
|
|
|
viewURL: card.dataset.viewUrl,
|
|
|
|
|
downloadURL: card.dataset.downloadUrl,
|
|
|
|
|
fileName: card.dataset.fileName,
|
|
|
|
|
};
|
|
|
|
|
showContextMenu(event.clientX, event.clientY);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fileContextMenu.addEventListener("click", async (event) => {
|
|
|
|
|
const button = event.target.closest("[data-context-action]");
|
|
|
|
|
if (!button || !contextFile) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
|
|
|
|
if (shouldHide !== false) {
|
|
|
|
|
hideContextMenu();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener("click", (event) => {
|
|
|
|
|
if (!fileContextMenu.contains(event.target)) {
|
|
|
|
|
hideContextMenu();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
|
|
|
if (event.key === "Escape") {
|
|
|
|
|
hideContextMenu();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-25 17:37:06 +03:00
|
|
|
document.addEventListener("mousemove", (event) => {
|
|
|
|
|
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
hideContextMenu();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-25 17:05:59 +03:00
|
|
|
window.addEventListener("resize", hideContextMenu);
|
|
|
|
|
window.addEventListener("scroll", hideContextMenu, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (previewActions.length > 0) {
|
|
|
|
|
previewActions.forEach((button) => {
|
|
|
|
|
button.addEventListener("click", async (event) => {
|
|
|
|
|
if (!event.ctrlKey && !ctrlCopyMode) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
await copyPreviewLink(button);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
|
|
|
if (event.key === "Control") {
|
|
|
|
|
setPreviewCopyMode(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keyup", (event) => {
|
|
|
|
|
if (event.key === "Control") {
|
|
|
|
|
setPreviewCopyMode(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener("blur", () => {
|
|
|
|
|
setPreviewCopyMode(false);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat(admin): redesign storage backend management UI
Implement a new card-based UI for managing storage backends in the admin panel. This update improves the visual presentation and usability of the storage configuration page.
Key changes:
- Added comprehensive CSS styles for storage cards, including status indicators, metadata layouts, and action buttons.
- Updated the storage admin template to render storage configurations as cards with type-specific details (Local, S3, SFTP, SMB, WebDAV).
- Integrated inline actions for testing, editing, disabling, and deleting storage backends.
- Enhanced sidebar link alignment with flexbox.
2026-05-31 04:54:27 +03:00
|
|
|
function syncStorageProvider(select) {
|
|
|
|
|
const formScope = select.closest("form");
|
|
|
|
|
if (!formScope) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const provider = select.value;
|
|
|
|
|
const isContabo = provider === "contabo";
|
|
|
|
|
formScope.querySelectorAll("[data-provider-fields]").forEach((group) => {
|
|
|
|
|
const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/);
|
|
|
|
|
const active = providers.includes(provider);
|
|
|
|
|
group.hidden = !active;
|
|
|
|
|
group.querySelectorAll("input, select, textarea").forEach((input) => {
|
|
|
|
|
input.disabled = !active;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const tls = formScope.querySelector('input[name="use_ssl"]');
|
|
|
|
|
const pathStyle = formScope.querySelector('input[name="path_style"]');
|
|
|
|
|
if (tls) {
|
|
|
|
|
tls.checked = isContabo || tls.checked;
|
|
|
|
|
tls.disabled = isContabo;
|
|
|
|
|
}
|
|
|
|
|
if (pathStyle) {
|
|
|
|
|
pathStyle.checked = isContabo || pathStyle.checked;
|
|
|
|
|
pathStyle.disabled = isContabo;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
if (storageProviderSelects.length > 0) {
|
|
|
|
|
storageProviderSelects.forEach((select) => {
|
feat(admin): redesign storage backend management UI
Implement a new card-based UI for managing storage backends in the admin panel. This update improves the visual presentation and usability of the storage configuration page.
Key changes:
- Added comprehensive CSS styles for storage cards, including status indicators, metadata layouts, and action buttons.
- Updated the storage admin template to render storage configurations as cards with type-specific details (Local, S3, SFTP, SMB, WebDAV).
- Integrated inline actions for testing, editing, disabling, and deleting storage backends.
- Enhanced sidebar link alignment with flexbox.
2026-05-31 04:54:27 +03:00
|
|
|
select.addEventListener("change", () => syncStorageProvider(select));
|
|
|
|
|
syncStorageProvider(select);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Storage card edit / cancel toggles */
|
|
|
|
|
document.querySelectorAll(".storage-edit-trigger").forEach((btn) => {
|
|
|
|
|
btn.addEventListener("click", () => {
|
|
|
|
|
const card = btn.closest(".storage-card");
|
|
|
|
|
if (!card) return;
|
|
|
|
|
card.classList.add("is-editing");
|
|
|
|
|
const providerSelect = card.querySelector("[data-storage-provider]");
|
|
|
|
|
if (providerSelect) {
|
|
|
|
|
syncStorageProvider(providerSelect);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll(".storage-cancel-trigger").forEach((btn) => {
|
|
|
|
|
btn.addEventListener("click", () => {
|
|
|
|
|
const card = btn.closest(".storage-card");
|
|
|
|
|
if (!card) return;
|
|
|
|
|
const form = card.querySelector("form");
|
|
|
|
|
if (form) form.reset();
|
|
|
|
|
card.classList.remove("is-editing");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* Add storage: type picker */
|
|
|
|
|
const storageAddTrigger = document.querySelector(".storage-add-trigger");
|
|
|
|
|
const storageTypePicker = document.querySelector(".storage-type-picker");
|
|
|
|
|
const storageNewCard = document.querySelector(".storage-new-card");
|
|
|
|
|
|
|
|
|
|
const providerLabels = {
|
|
|
|
|
s3: "S3 bucket",
|
|
|
|
|
contabo: "Contabo Object Storage",
|
|
|
|
|
sftp: "SFTP",
|
|
|
|
|
smb: "Samba",
|
|
|
|
|
webdav: "WebDAV",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const providerIconSVGs = {
|
|
|
|
|
s3: storageNewCard && storageNewCard.querySelector(".storage-new-icon") ? storageNewCard.querySelector(".storage-new-icon").innerHTML : "",
|
|
|
|
|
contabo: "",
|
|
|
|
|
sftp: "",
|
|
|
|
|
smb: "",
|
|
|
|
|
webdav: "",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (storageAddTrigger && storageTypePicker) {
|
|
|
|
|
storageAddTrigger.addEventListener("click", () => {
|
|
|
|
|
storageTypePicker.hidden = !storageTypePicker.hidden;
|
|
|
|
|
if (storageNewCard && !storageTypePicker.hidden) {
|
|
|
|
|
storageNewCard.hidden = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
storageTypePicker.querySelectorAll(".storage-type-option").forEach((opt) => {
|
|
|
|
|
opt.addEventListener("click", () => {
|
|
|
|
|
const provider = opt.dataset.provider;
|
|
|
|
|
if (!storageNewCard) return;
|
|
|
|
|
|
|
|
|
|
const providerSelect = storageNewCard.querySelector("[data-storage-provider]");
|
|
|
|
|
if (providerSelect) {
|
|
|
|
|
providerSelect.value = provider;
|
|
|
|
|
syncStorageProvider(providerSelect);
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
feat(admin): redesign storage backend management UI
Implement a new card-based UI for managing storage backends in the admin panel. This update improves the visual presentation and usability of the storage configuration page.
Key changes:
- Added comprehensive CSS styles for storage cards, including status indicators, metadata layouts, and action buttons.
- Updated the storage admin template to render storage configurations as cards with type-specific details (Local, S3, SFTP, SMB, WebDAV).
- Integrated inline actions for testing, editing, disabling, and deleting storage backends.
- Enhanced sidebar link alignment with flexbox.
2026-05-31 04:54:27 +03:00
|
|
|
|
|
|
|
|
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge");
|
|
|
|
|
if (typeBadge) typeBadge.textContent = providerLabels[provider] || provider;
|
|
|
|
|
|
|
|
|
|
const iconEl = storageNewCard.querySelector(".storage-new-icon");
|
|
|
|
|
const optIcon = opt.querySelector("svg");
|
|
|
|
|
if (iconEl && optIcon) {
|
|
|
|
|
iconEl.innerHTML = optIcon.outerHTML;
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
feat(admin): redesign storage backend management UI
Implement a new card-based UI for managing storage backends in the admin panel. This update improves the visual presentation and usability of the storage configuration page.
Key changes:
- Added comprehensive CSS styles for storage cards, including status indicators, metadata layouts, and action buttons.
- Updated the storage admin template to render storage configurations as cards with type-specific details (Local, S3, SFTP, SMB, WebDAV).
- Integrated inline actions for testing, editing, disabling, and deleting storage backends.
- Enhanced sidebar link alignment with flexbox.
2026-05-31 04:54:27 +03:00
|
|
|
|
|
|
|
|
storageTypePicker.hidden = true;
|
|
|
|
|
storageNewCard.hidden = false;
|
|
|
|
|
});
|
2026-05-31 02:14:10 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat(admin): redesign storage backend management UI
Implement a new card-based UI for managing storage backends in the admin panel. This update improves the visual presentation and usability of the storage configuration page.
Key changes:
- Added comprehensive CSS styles for storage cards, including status indicators, metadata layouts, and action buttons.
- Updated the storage admin template to render storage configurations as cards with type-specific details (Local, S3, SFTP, SMB, WebDAV).
- Integrated inline actions for testing, editing, disabling, and deleting storage backends.
- Enhanced sidebar link alignment with flexbox.
2026-05-31 04:54:27 +03:00
|
|
|
if (storageNewCard) {
|
|
|
|
|
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel");
|
|
|
|
|
if (cancelBtn) {
|
|
|
|
|
cancelBtn.addEventListener("click", () => {
|
|
|
|
|
storageNewCard.hidden = true;
|
|
|
|
|
if (storageTypePicker) storageTypePicker.hidden = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
if (!form || !dropZone || !fileInput) {
|
2026-05-25 15:36:49 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
let latestBoxURL = "";
|
|
|
|
|
let selectedFiles = [];
|
2026-05-25 16:26:47 +03:00
|
|
|
|
2026-05-25 15:36:49 +03:00
|
|
|
["dragenter", "dragover"].forEach((eventName) => {
|
|
|
|
|
dropZone.addEventListener(eventName, (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
dropZone.classList.add("is-dragging");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
["dragleave", "drop"].forEach((eventName) => {
|
|
|
|
|
dropZone.addEventListener(eventName, (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
dropZone.classList.remove("is-dragging");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
dropZone.addEventListener("drop", (event) => {
|
|
|
|
|
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
|
|
|
|
fileInput.files = event.dataTransfer.files;
|
2026-05-25 16:26:47 +03:00
|
|
|
updateSelectedState(event.dataTransfer.files);
|
2026-05-25 15:36:49 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fileInput.addEventListener("change", () => {
|
2026-05-25 16:26:47 +03:00
|
|
|
updateSelectedState(fileInput.files);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
form.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (!fileInput.files || fileInput.files.length === 0) {
|
|
|
|
|
updateStatus("Choose at least one file first.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const submit = form.querySelector("button[type='submit']");
|
|
|
|
|
const formData = new FormData(form);
|
2026-05-25 16:52:57 +03:00
|
|
|
selectedFiles = Array.from(fileInput.files);
|
|
|
|
|
renderQueue(selectedFiles, "queued");
|
2026-05-25 16:26:47 +03:00
|
|
|
setLoading(true, submit);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-25 16:52:57 +03:00
|
|
|
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
2026-05-25 16:26:47 +03:00
|
|
|
renderResult(payload);
|
|
|
|
|
form.reset();
|
|
|
|
|
updateSelectedState([]);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
updateStatus(error.message || "Upload failed");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false, submit);
|
|
|
|
|
}
|
2026-05-25 15:36:49 +03:00
|
|
|
});
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
if (copyURL) {
|
|
|
|
|
copyURL.addEventListener("click", () => {
|
|
|
|
|
copyText(latestBoxURL, copyURL, "Copied");
|
2026-05-25 16:26:47 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateSelectedState(files) {
|
2026-05-25 16:52:57 +03:00
|
|
|
selectedFiles = Array.from(files || []);
|
|
|
|
|
const count = selectedFiles.length || 0;
|
2026-05-25 15:36:49 +03:00
|
|
|
const title = dropZone.querySelector(".drop-title");
|
2026-05-25 16:26:47 +03:00
|
|
|
if (title) {
|
|
|
|
|
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
|
|
|
|
}
|
|
|
|
|
if (fileSummary) {
|
|
|
|
|
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
|
|
|
|
}
|
2026-05-25 16:52:57 +03:00
|
|
|
if (count > 0) {
|
|
|
|
|
renderQueue(selectedFiles, "queued");
|
|
|
|
|
} else if (uploadQueue) {
|
|
|
|
|
uploadQueue.hidden = true;
|
|
|
|
|
uploadQueue.replaceChildren();
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setLoading(isLoading, submit) {
|
|
|
|
|
if (progress) {
|
|
|
|
|
progress.hidden = !isLoading;
|
|
|
|
|
}
|
|
|
|
|
if (submit) {
|
|
|
|
|
submit.disabled = isLoading;
|
|
|
|
|
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
|
|
|
|
}
|
|
|
|
|
updateStatus(isLoading ? "Transferring files..." : "");
|
2026-05-25 16:52:57 +03:00
|
|
|
setTotalProgress(isLoading ? 0 : 100);
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateStatus(message) {
|
|
|
|
|
if (uploadStatus) {
|
|
|
|
|
uploadStatus.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderResult(payload) {
|
|
|
|
|
if (!result || !resultList || !resultMeta || !openBox) {
|
2026-05-25 15:36:49 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
latestBoxURL = payload.boxUrl;
|
2026-05-25 16:26:47 +03:00
|
|
|
result.hidden = false;
|
|
|
|
|
openBox.href = payload.boxUrl;
|
|
|
|
|
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
|
2026-05-29 23:44:05 +03:00
|
|
|
if (manageLink) {
|
|
|
|
|
const anchor = manageLink.querySelector("a");
|
|
|
|
|
manageLink.hidden = !payload.manageUrl;
|
|
|
|
|
if (anchor && payload.manageUrl) {
|
|
|
|
|
anchor.href = payload.manageUrl;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-25 16:26:47 +03:00
|
|
|
|
|
|
|
|
resultList.replaceChildren();
|
|
|
|
|
payload.files.forEach((file) => {
|
2026-05-25 16:52:57 +03:00
|
|
|
resultList.append(createFileRow({
|
|
|
|
|
name: file.name,
|
|
|
|
|
meta: `${file.size} · ${file.url}`,
|
|
|
|
|
progress: 100,
|
|
|
|
|
status: "complete",
|
|
|
|
|
}));
|
2026-05-25 16:26:47 +03:00
|
|
|
});
|
2026-05-25 16:52:57 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function uploadWithProgress(url, formData, files) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const request = new XMLHttpRequest();
|
|
|
|
|
request.open("POST", url);
|
|
|
|
|
request.setRequestHeader("Accept", "application/json");
|
2026-05-25 16:26:47 +03:00
|
|
|
|
2026-05-25 16:52:57 +03:00
|
|
|
request.upload.addEventListener("progress", (event) => {
|
|
|
|
|
if (!event.lengthComputable) {
|
|
|
|
|
updateStatus("Uploading...");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const percent = Math.round((event.loaded / event.total) * 100);
|
|
|
|
|
updateStatus(`${percent}%`);
|
|
|
|
|
setTotalProgress(percent);
|
|
|
|
|
setFileProgress(files, percent);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
request.addEventListener("load", () => {
|
|
|
|
|
let payload = {};
|
|
|
|
|
try {
|
|
|
|
|
payload = JSON.parse(request.responseText || "{}");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
reject(new Error("Upload response could not be read"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (request.status < 200 || request.status >= 300) {
|
|
|
|
|
reject(new Error(payload.error || "Upload failed"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setTotalProgress(100);
|
|
|
|
|
setFileProgress(files, 100);
|
|
|
|
|
resolve(payload);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
|
|
|
|
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
|
|
|
|
request.send(formData);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderQueue(files, status) {
|
|
|
|
|
if (!uploadQueue) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
uploadQueue.hidden = files.length === 0;
|
|
|
|
|
uploadQueue.replaceChildren();
|
|
|
|
|
files.forEach((file) => {
|
|
|
|
|
uploadQueue.append(createFileRow({
|
|
|
|
|
name: file.name,
|
|
|
|
|
meta: formatBytes(file.size),
|
|
|
|
|
progress: status === "queued" ? 0 : 100,
|
|
|
|
|
status,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createFileRow(file) {
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className = "result-item upload-file-row";
|
|
|
|
|
row.dataset.fileName = file.name;
|
|
|
|
|
|
|
|
|
|
const body = document.createElement("span");
|
|
|
|
|
const name = document.createElement("strong");
|
2026-05-25 17:37:06 +03:00
|
|
|
name.className = "file-name";
|
2026-05-25 16:52:57 +03:00
|
|
|
name.textContent = file.name;
|
2026-05-25 17:37:06 +03:00
|
|
|
name.title = file.name;
|
2026-05-25 16:52:57 +03:00
|
|
|
const meta = document.createElement("code");
|
|
|
|
|
meta.textContent = file.meta;
|
|
|
|
|
body.append(name, meta);
|
|
|
|
|
|
|
|
|
|
const side = document.createElement("div");
|
|
|
|
|
side.className = "file-progress-side";
|
|
|
|
|
const percent = document.createElement("span");
|
|
|
|
|
percent.className = "file-progress-percent";
|
|
|
|
|
percent.textContent = `${file.progress}%`;
|
|
|
|
|
const bar = document.createElement("div");
|
|
|
|
|
bar.className = "progress file-progress";
|
|
|
|
|
const fill = document.createElement("span");
|
|
|
|
|
fill.style.transform = `scaleX(${file.progress / 100})`;
|
|
|
|
|
bar.append(fill);
|
|
|
|
|
side.append(percent, bar);
|
|
|
|
|
|
|
|
|
|
row.append(body, side);
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setTotalProgress(percent) {
|
|
|
|
|
if (totalProgressBar) {
|
|
|
|
|
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setFileProgress(files, totalPercent) {
|
|
|
|
|
if (!uploadQueue) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const count = files.length || 1;
|
|
|
|
|
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
|
|
|
|
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
|
|
|
|
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
|
|
|
|
const percent = row.querySelector(".file-progress-percent");
|
|
|
|
|
const fill = row.querySelector(".file-progress span");
|
|
|
|
|
if (percent) {
|
|
|
|
|
percent.textContent = `${progress}%`;
|
|
|
|
|
}
|
|
|
|
|
if (fill) {
|
|
|
|
|
fill.style.transform = `scaleX(${progress / 100})`;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-25 16:26:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyText(text, button, copiedLabel) {
|
|
|
|
|
if (!text) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-25 17:05:59 +03:00
|
|
|
await writeClipboard(text);
|
2026-05-25 16:26:47 +03:00
|
|
|
const previous = button.textContent;
|
|
|
|
|
button.textContent = copiedLabel;
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
button.textContent = previous;
|
|
|
|
|
}, 1400);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 17:05:59 +03:00
|
|
|
async function copyPreviewLink(button) {
|
|
|
|
|
await writeClipboard(button.href);
|
|
|
|
|
const label = button.querySelector("[data-preview-label]");
|
|
|
|
|
if (!label) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
label.textContent = "Copied";
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
|
|
|
|
}, 1200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setPreviewCopyMode(enabled) {
|
|
|
|
|
ctrlCopyMode = enabled;
|
|
|
|
|
previewActions.forEach((button) => {
|
|
|
|
|
const label = button.querySelector("[data-preview-label]");
|
|
|
|
|
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
|
|
|
|
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
|
|
|
|
if (label) {
|
|
|
|
|
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
|
|
|
|
}
|
|
|
|
|
if (viewIcon) {
|
|
|
|
|
viewIcon.hidden = enabled;
|
|
|
|
|
}
|
|
|
|
|
if (copyIcon) {
|
|
|
|
|
copyIcon.hidden = !enabled;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runContextAction(action, file) {
|
|
|
|
|
if (action === "preview") {
|
|
|
|
|
openInNewTab(file.previewURL);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (action === "view") {
|
|
|
|
|
openInNewTab(file.viewURL);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (action === "copy-preview") {
|
|
|
|
|
await writeClipboard(file.previewURL);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (action === "copy-download") {
|
|
|
|
|
await writeClipboard(file.downloadURL);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (action === "download") {
|
|
|
|
|
openInNewTab(file.downloadURL);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showContextMenu(x, y) {
|
|
|
|
|
fileContextMenu.hidden = false;
|
|
|
|
|
fileContextMenu.style.left = "0px";
|
|
|
|
|
fileContextMenu.style.top = "0px";
|
|
|
|
|
|
|
|
|
|
const rect = fileContextMenu.getBoundingClientRect();
|
|
|
|
|
const margin = 8;
|
|
|
|
|
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
|
|
|
|
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
|
|
|
|
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
|
|
|
|
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideContextMenu() {
|
|
|
|
|
if (!fileContextMenu || fileContextMenu.hidden) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
fileContextMenu.hidden = true;
|
|
|
|
|
contextFile = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 17:37:06 +03:00
|
|
|
function isPointerNearContextMenu(x, y) {
|
|
|
|
|
const rect = fileContextMenu.getBoundingClientRect();
|
|
|
|
|
return x >= rect.left - contextMenuCloseDistance &&
|
|
|
|
|
x <= rect.right + contextMenuCloseDistance &&
|
|
|
|
|
y >= rect.top - contextMenuCloseDistance &&
|
|
|
|
|
y <= rect.bottom + contextMenuCloseDistance;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 17:05:59 +03:00
|
|
|
function openInNewTab(url) {
|
|
|
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeClipboard(text) {
|
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const textarea = document.createElement("textarea");
|
|
|
|
|
textarea.value = text;
|
|
|
|
|
textarea.setAttribute("readonly", "");
|
|
|
|
|
textarea.style.position = "fixed";
|
|
|
|
|
textarea.style.opacity = "0";
|
|
|
|
|
document.body.append(textarea);
|
|
|
|
|
textarea.select();
|
|
|
|
|
document.execCommand("copy");
|
|
|
|
|
textarea.remove();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:26:47 +03:00
|
|
|
function formatDate(value) {
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
return date.toLocaleDateString(undefined, {
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
});
|
2026-05-25 15:36:49 +03:00
|
|
|
}
|
2026-05-25 16:52:57 +03:00
|
|
|
|
|
|
|
|
function formatBytes(bytes) {
|
|
|
|
|
if (bytes < 1024) {
|
|
|
|
|
return `${bytes} B`;
|
|
|
|
|
}
|
|
|
|
|
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
|
|
|
let value = bytes / 1024;
|
|
|
|
|
let unit = 0;
|
|
|
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
|
|
|
value /= 1024;
|
|
|
|
|
unit += 1;
|
|
|
|
|
}
|
|
|
|
|
return `${value.toFixed(1)} ${units[unit]}`;
|
|
|
|
|
}
|
2026-05-25 15:36:49 +03:00
|
|
|
})();
|