Fix model view for archives with long paths; preview inside archive-only models

- Long file names inside archives (deep folder paths) widened the details
  column until the viewer was crushed to a sliver and content ran off the
  modal. Both columns now keep their share (minmax(0, …)), long file names
  wrap, and viewer tabs truncate.
- Archive entries show the file name first with its folder dimmed after.
- A model whose only file is an archive opens the most useful file inside
  it (a slicer project, then other 3D files, then pictures/PDFs) instead of
  "No 3D preview for .none files".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-15 08:01:04 +10:00
parent ea2afeac0e
commit fbf1b706bf
3 changed files with 34 additions and 7 deletions

View file

@ -993,8 +993,13 @@ function openModelDetail(modelId) {
if (detailItems.length) {
loadViewer(detailItems[0]);
} else {
// A model that's only archives previews a file from inside once they're listed.
const hasArchive = model.files.some((f) => ARCHIVE_FORMATS.includes(f.format));
document.getElementById('regenThumbBtn').style.display = 'none';
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', { fallbackImage: currentDetailThumb });
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', {
fallbackImage: currentDetailThumb,
message: hasArchive ? 'Opening a file from the archive…' : 'Nothing here can be previewed yet — add files below.',
});
}
}
@ -1038,6 +1043,20 @@ function resolverFor(model, archiveEntries = null) {
return (name) => byName.get(key(name)) || null;
}
// For a model with nothing to preview directly: open the most useful file
// inside its archive — a slicer project, then other 3D files, then pictures/PDFs.
const ENTRY_PREVIEW_ORDER = ['3mf', 'stl', 'obj', 'step', 'stp', 'fbx', '3dm', 'jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
function previewFirstEntry(archiveId, entries) {
const rank = (e) => (ENTRY_PREVIEW_ORDER.includes(e.format) ? ENTRY_PREVIEW_ORDER.indexOf(e.format) : Infinity);
const best = [...entries].sort((a, b) => rank(a) - rank(b))[0];
if (!best || rank(best) === Infinity) {
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none',
{ fallbackImage: currentDetailThumb, message: 'Nothing inside this archive can be previewed.' });
return;
}
openViewerItem({ kind: 'entry', id: best.id, format: best.format, name: best.path.split('/').pop(), archiveId });
}
async function loadArchiveEntries(modelId, fileId) {
const container = () => (fileDetailModal.dataset.currentModelId === String(modelId)
? document.querySelector(`[data-entries-for="${fileId}"]`) : null);
@ -1057,14 +1076,19 @@ async function loadArchiveEntries(modelId, fileId) {
archiveEntryCache.set(fileId, data.entries);
const { PREVIEWABLE } = window.ModelViewer;
container().innerHTML = `<div class="entry-head">${data.entries.length} file${data.entries.length === 1 ? '' : 's'} inside</div>`
+ data.entries.map((e) => `
+ data.entries.map((e) => {
const name = e.path.split('/').pop();
const folder = e.path.replace(/^\.\//, '').slice(0, -name.length);
return `
<div class="entry-row">
<span title="${escapeHtml(e.path)}">${escapeHtml(e.path.replace(/^\.\//, ''))} <em>${formatFileSize(e.size)}</em></span>
<span title="${escapeHtml(e.path)}">${escapeHtml(name)}${folder ? `<span class="entry-folder">${escapeHtml(folder)}</span>` : ''} <em>${formatFileSize(e.size)}</em></span>
<span class="row-actions">
${PREVIEWABLE.includes(e.format) && e.format !== 'mtl' ? `<button type="button" class="link-btn" data-view-entry="${e.id}" data-archive="${fileId}">view</button>` : ''}
<a href="/api/entries/${e.id}/raw" download>download</a>
</span>
</div>`).join('');
</div>`;
}).join('');
if (!detailItems.length) previewFirstEntry(fileId, data.entries);
} catch (err) {
if (container()) container().innerHTML = `<div class="entry-head">Couldn't list this archive: ${escapeHtml(err.message)}</div>`;
}

View file

@ -205,7 +205,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
/* ---- model detail ---- */
.file-detail-content { display: grid; grid-template-columns: 1.1fr 1fr; gap: 26px; }
/* minmax(0, …) keeps a long file name from widening its column and crushing the viewer */
.file-detail-content { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 26px; }
.file-detail-preview { display: flex; flex-direction: column; gap: 10px; }
.model-viewer { width: 100%; height: 380px; background: radial-gradient(circle at 50% 40%, #232730 0%, #131519 100%); border: 1px solid var(--border); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; }
.model-viewer canvas { display: block; border-radius: var(--radius); }
@ -224,6 +225,7 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.info-section .entry-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 3px 0; font-size: 13px; color: var(--text-muted); }
.entry-row > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.entry-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 11px; margin-left: 4px; }
.entry-row .entry-folder { color: var(--text-dim); margin-left: 4px; }
.row-actions { display: flex; gap: 12px; flex-shrink: 0; align-items: center; }
.link-btn { background: none; border: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; }
.link-btn:hover { text-decoration: underline; }
@ -240,7 +242,7 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.swatch { width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; }
.file-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.file-tab { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 5px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; font-family: inherit; }
.file-tab { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 5px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; font-family: inherit; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-tab:hover { color: var(--text); }
.file-tab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
@ -253,6 +255,7 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
.file-row:last-child { border-bottom: none; }
.file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; }
.file-row > span:first-child { min-width: 0; overflow-wrap: anywhere; }
/* ---- import / settings ---- */
.import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; }

View file

@ -193,7 +193,7 @@ async function render(container, url, format, opts = {}) {
return;
}
if (!MODEL_FORMATS.includes(format)) {
showFallback(container, opts.fallbackImage, `No 3D preview for .${format} files.`);
showFallback(container, opts.fallbackImage, opts.message || `No 3D preview for .${format} files.`);
return;
}