feat(preview): add archive listing and browser support
Introduces the ability to browse and preview the contents of archive files directly within the web interface.
Changes include:
- Added a new API endpoint `GET /d/{boxID}/archive/{fileID}` to fetch archive listings.
- Implemented on-demand archive listing generation in the backend.
- Updated the frontend preview component to support rendering and navigating archive contents.
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
downloadURL: preview.dataset.downloadUrl || "",
|
||||
iconURL: preview.dataset.iconUrl || "",
|
||||
sceneURL: preview.dataset.sceneUrl || "",
|
||||
archiveURL: preview.dataset.archiveUrl || "",
|
||||
activeMode: "",
|
||||
defaultMode: "default",
|
||||
pendingMode: "",
|
||||
@@ -26,6 +27,10 @@
|
||||
prismLoaded: false,
|
||||
renderLoaded: false,
|
||||
sceneLoaded: false,
|
||||
archiveLoaded: false,
|
||||
archiveUIRendered: false,
|
||||
archiveData: null,
|
||||
archiveText: "",
|
||||
renderFullscreenFallback: false,
|
||||
confirmedLargeModes: {},
|
||||
tabs: []
|
||||
@@ -43,6 +48,9 @@
|
||||
rawOutput: preview.querySelector("[data-raw-output]"),
|
||||
codePane: preview.querySelector("[data-code-preview]"),
|
||||
codeOutput: preview.querySelector("[data-code-output]"),
|
||||
archiveBrowserPane: preview.querySelector("[data-archive-browser-preview]"),
|
||||
archivePane: preview.querySelector("[data-archive-preview]"),
|
||||
archiveOutput: preview.querySelector("[data-archive-output]"),
|
||||
renderPane: preview.querySelector("[data-render-preview]"),
|
||||
fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
|
||||
gatePane: preview.querySelector("[data-large-preview-gate]"),
|
||||
@@ -68,6 +76,7 @@
|
||||
var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml";
|
||||
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
|
||||
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
|
||||
var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType);
|
||||
|
||||
return {
|
||||
extension: extension,
|
||||
@@ -79,6 +88,7 @@
|
||||
isImage: isImage,
|
||||
isVideo: isVideo,
|
||||
isAudio: isAudio,
|
||||
isArchive: isArchive,
|
||||
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
|
||||
};
|
||||
}
|
||||
@@ -104,6 +114,12 @@
|
||||
return tabs;
|
||||
}
|
||||
|
||||
if (type.isArchive) {
|
||||
tabs.push({ mode: "archive-ui", label: "Archive Preview" });
|
||||
tabs.push({ mode: "archive", label: "Text Tree" });
|
||||
return tabs;
|
||||
}
|
||||
|
||||
if (type.isTextLike) {
|
||||
if (type.isHTML || type.isMarkdown) {
|
||||
tabs.push({ mode: "render", label: "Render Preview" });
|
||||
@@ -122,6 +138,9 @@
|
||||
if (type.isVideo) {
|
||||
return "video";
|
||||
}
|
||||
if (type.isArchive) {
|
||||
return "archive-ui";
|
||||
}
|
||||
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||
return "browser-audio";
|
||||
@@ -198,6 +217,12 @@
|
||||
} else if (mode === "code") {
|
||||
show(els.codePane);
|
||||
ensurePrismPreview();
|
||||
} else if (mode === "archive-ui") {
|
||||
show(els.archiveBrowserPane);
|
||||
ensureArchiveBrowserPreview();
|
||||
} else if (mode === "archive") {
|
||||
show(els.archivePane);
|
||||
ensureArchivePreview();
|
||||
} else if (mode === "render") {
|
||||
show(els.renderPane);
|
||||
if (fileType.isMarkdown) {
|
||||
@@ -416,6 +441,8 @@
|
||||
hide(els.browserAudioPane);
|
||||
hide(els.rawPane);
|
||||
hide(els.codePane);
|
||||
hide(els.archiveBrowserPane);
|
||||
hide(els.archivePane);
|
||||
hide(els.renderPane);
|
||||
hide(els.gatePane);
|
||||
hide(els.placeholder);
|
||||
@@ -512,6 +539,8 @@
|
||||
"browser-audio": "Browser preview",
|
||||
"raw": "Raw preview",
|
||||
"code": "Code preview",
|
||||
"archive-ui": "Archive preview",
|
||||
"archive": "Archive preview",
|
||||
"render": "Render preview"
|
||||
};
|
||||
return labels[mode] || "Preview";
|
||||
@@ -529,6 +558,227 @@
|
||||
state.sceneLoaded = true;
|
||||
}
|
||||
|
||||
function ensureArchivePreview() {
|
||||
if (state.archiveLoaded || !els.archiveOutput || !state.archiveURL) {
|
||||
return;
|
||||
}
|
||||
ensureArchiveData()
|
||||
.then(function () {
|
||||
var text = state.archiveText || archiveDataToText(state.archiveData);
|
||||
els.archiveOutput.textContent = text;
|
||||
state.archiveLoaded = true;
|
||||
hide(els.placeholder);
|
||||
show(els.archivePane);
|
||||
})
|
||||
.catch(function () {
|
||||
showError("Archive preview could not be loaded.");
|
||||
});
|
||||
}
|
||||
|
||||
function ensureArchiveBrowserPreview() {
|
||||
if (state.archiveUIRendered || !els.archiveBrowserPane || !state.archiveURL) {
|
||||
return;
|
||||
}
|
||||
ensureArchiveData()
|
||||
.then(function () {
|
||||
renderArchiveBrowser(state.archiveData);
|
||||
state.archiveUIRendered = true;
|
||||
hide(els.placeholder);
|
||||
show(els.archiveBrowserPane);
|
||||
})
|
||||
.catch(function () {
|
||||
showError("Archive preview could not be loaded.");
|
||||
});
|
||||
}
|
||||
|
||||
function ensureArchiveData() {
|
||||
if (state.archiveData || state.archiveText) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
showLoading("Loading archive contents...");
|
||||
return fetch(state.archiveURL, { credentials: "same-origin" })
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error("Archive preview could not be loaded.");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(function (text) {
|
||||
try {
|
||||
state.archiveData = JSON.parse(text);
|
||||
} catch (error) {
|
||||
state.archiveText = text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderArchiveBrowser(data) {
|
||||
if (!els.archiveBrowserPane) {
|
||||
return;
|
||||
}
|
||||
els.archiveBrowserPane.innerHTML = "";
|
||||
if (!data || !data.root) {
|
||||
var fallback = document.createElement("pre");
|
||||
fallback.className = "archive-browser-legacy";
|
||||
fallback.textContent = state.archiveText || "Archive preview is unavailable.";
|
||||
els.archiveBrowserPane.appendChild(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
var header = document.createElement("div");
|
||||
header.className = "archive-browser-header";
|
||||
header.innerHTML = "<strong></strong><span></span>";
|
||||
header.querySelector("strong").textContent = data.name || state.fileName || "Archive";
|
||||
header.querySelector("span").textContent = [
|
||||
data.type || "Archive",
|
||||
formatArchiveCount(data.fileCount, "file"),
|
||||
formatArchiveCount(data.folderCount, "folder"),
|
||||
formatBytes(data.uncompressedSize || 0)
|
||||
].filter(Boolean).join(" · ");
|
||||
els.archiveBrowserPane.appendChild(header);
|
||||
|
||||
var tree = document.createElement("div");
|
||||
tree.className = "archive-tree";
|
||||
var items = data.root.items || [];
|
||||
if (items.length === 0) {
|
||||
var emptyTree = document.createElement("p");
|
||||
emptyTree.className = "archive-browser-empty";
|
||||
emptyTree.textContent = "This archive is empty.";
|
||||
tree.appendChild(emptyTree);
|
||||
} else {
|
||||
items.forEach(function (item) {
|
||||
tree.appendChild(renderArchiveNode(item, 0));
|
||||
});
|
||||
}
|
||||
els.archiveBrowserPane.appendChild(tree);
|
||||
}
|
||||
|
||||
function renderArchiveNode(node, depth) {
|
||||
var row = document.createElement(node.dir ? "details" : "div");
|
||||
row.className = node.dir ? "archive-node archive-folder" : "archive-node archive-file";
|
||||
if (node.dir && depth < 1) {
|
||||
row.open = true;
|
||||
}
|
||||
|
||||
var summary = document.createElement(node.dir ? "summary" : "div");
|
||||
summary.className = "archive-node-row";
|
||||
summary.style.paddingLeft = (0.45 + depth * 1.15).toFixed(2) + "rem";
|
||||
|
||||
if (node.dir) {
|
||||
summary.appendChild(createArchiveChevron());
|
||||
} else {
|
||||
var spacer = document.createElement("span");
|
||||
spacer.className = "archive-chevron-spacer";
|
||||
summary.appendChild(spacer);
|
||||
}
|
||||
|
||||
summary.appendChild(createArchiveIcon(node.icon || (node.dir ? "folder" : "file")));
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "archive-node-name";
|
||||
name.textContent = node.name + (node.dir ? "/" : "");
|
||||
summary.appendChild(name);
|
||||
|
||||
if (!node.dir) {
|
||||
var size = document.createElement("span");
|
||||
size.className = "archive-node-size";
|
||||
size.textContent = formatBytes(node.size || 0);
|
||||
summary.appendChild(size);
|
||||
}
|
||||
|
||||
row.appendChild(summary);
|
||||
if (node.dir) {
|
||||
(node.items || []).forEach(function (child) {
|
||||
row.appendChild(renderArchiveNode(child, depth + 1));
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function createArchiveChevron() {
|
||||
var chevron = document.createElement("span");
|
||||
chevron.className = "archive-chevron";
|
||||
chevron.setAttribute("aria-hidden", "true");
|
||||
chevron.innerHTML = '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
|
||||
return chevron;
|
||||
}
|
||||
|
||||
function createArchiveIcon(icon) {
|
||||
var element = document.createElement("span");
|
||||
element.className = "archive-file-icon archive-file-icon-" + icon;
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
element.innerHTML = archiveIconSVG(icon);
|
||||
return element;
|
||||
}
|
||||
|
||||
function archiveDataToText(data) {
|
||||
if (!data || !data.root) {
|
||||
return state.archiveText || "";
|
||||
}
|
||||
var lines = [
|
||||
"Archive preview",
|
||||
"Name: " + (data.name || state.fileName || "Archive"),
|
||||
"Type: " + (data.type || "Archive"),
|
||||
"Entries: " + (data.fileCount || 0) + " files, " + (data.folderCount || 0) + " folders",
|
||||
"Uncompressed size: " + formatBytes(data.uncompressedSize || 0),
|
||||
"",
|
||||
"."
|
||||
];
|
||||
appendArchiveTextLines(lines, data.root.items || [], "");
|
||||
if (!(data.root.items || []).length) {
|
||||
lines.push("(empty archive)");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendArchiveTextLines(lines, items, prefix) {
|
||||
items.forEach(function (item, index) {
|
||||
var last = index === items.length - 1;
|
||||
var branch = last ? "`-- " : "|-- ";
|
||||
var nextPrefix = prefix + (last ? " " : "| ");
|
||||
var label = item.dir ? "[DIR] " + item.name + "/" : "[" + (item.icon || "file").toUpperCase() + "] " + item.name + " (" + formatBytes(item.size || 0) + ")";
|
||||
lines.push(prefix + branch + label);
|
||||
if (item.dir) {
|
||||
appendArchiveTextLines(lines, item.items || [], nextPrefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function archiveIconSVG(icon) {
|
||||
var icons = {
|
||||
folder: '<svg viewBox="0 0 24 24" focusable="false"><path d="M3 6.75A2.75 2.75 0 0 1 5.75 4h4.1l2 2.2h6.4A2.75 2.75 0 0 1 21 8.95v8.3A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25Z"/></svg>',
|
||||
img: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 3.2-3.2 2.6 2.6 2.2-2.2L19 17"/><circle cx="9" cy="9" r="1.4"/></svg>',
|
||||
vid: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="6" width="12" height="12" rx="2"/><path d="m16 10 4-2.5v9L16 14"/></svg>',
|
||||
aud: '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>',
|
||||
txt: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M9 12h6M9 15h6M9 18h4"/></svg>',
|
||||
code: '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 8-4 4 4 4M15 8l4 4-4 4M13 5l-2 14"/></svg>',
|
||||
arc: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M10 6h2M10 9h2M10 12h2M10 15h2M10 18h2"/></svg>',
|
||||
file: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5"/></svg>'
|
||||
};
|
||||
return icons[icon] || icons.file;
|
||||
}
|
||||
|
||||
function formatArchiveCount(value, label) {
|
||||
value = Number(value || 0);
|
||||
return value + " " + label + (value === 1 ? "" : "s");
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
value = Number(value || 0);
|
||||
if (value < 1024) {
|
||||
return value + " B";
|
||||
}
|
||||
var units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
var size = value / 1024;
|
||||
for (var i = 0; i < units.length; i++) {
|
||||
if (size < 1024 || i === units.length - 1) {
|
||||
return size.toFixed(1) + " " + units[i];
|
||||
}
|
||||
size /= 1024;
|
||||
}
|
||||
return value + " B";
|
||||
}
|
||||
|
||||
function loadPrism() {
|
||||
if (window.Prism) {
|
||||
return Promise.resolve();
|
||||
@@ -655,6 +905,28 @@
|
||||
return parts.length > 1 ? parts.pop() : "";
|
||||
}
|
||||
|
||||
function isArchiveFile(extension, baseType) {
|
||||
var archiveExtensions = {
|
||||
"apk": true,
|
||||
"docx": true,
|
||||
"ear": true,
|
||||
"epub": true,
|
||||
"jar": true,
|
||||
"pptx": true,
|
||||
"war": true,
|
||||
"xlsx": true,
|
||||
"zip": true
|
||||
};
|
||||
var archiveTypes = {
|
||||
"application/epub+zip": true,
|
||||
"application/java-archive": true,
|
||||
"application/vnd.android.package-archive": true,
|
||||
"application/x-zip-compressed": true,
|
||||
"application/zip": true
|
||||
};
|
||||
return Boolean(archiveExtensions[extension] || archiveTypes[baseType]);
|
||||
}
|
||||
|
||||
function languageFor(extension, baseType) {
|
||||
var extensionMap = {
|
||||
"c": "c",
|
||||
|
||||
Reference in New Issue
Block a user