Preview images inside .zip attachments inline on pawchive post pages — no download & unpack needed.
// ==UserScript== // @name Pawchive ZIP Preview // @namespace pawchive-zip-preview // @version 2.0 // @description Preview images inside .zip attachments inline on pawchive post pages — no download & unpack needed. // @author BlueAnivia // @match *://pawchive.pw/* // @match *://*.pawchive.pw/* // @icon https://www.google.com/s2/favicons?sz=64&domain=pawchive.pw // @license MIT // @grant GM_xmlhttpRequest // @connect pawchive.pw // @connect file.pawchive.pw // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /* ------------------------------------------------------------------ */ /* Config */ /* ------------------------------------------------------------------ */ const IMAGE_EXT = /\.(jpe?g|png|gif|webp|bmp|avif)$/i; const ZIP_EXT = /\.zip(\?|$)/i; const MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', avif: 'image/avif', }; /* ------------------------------------------------------------------ */ /* Styles */ /* ------------------------------------------------------------------ */ const css = document.createElement('style'); css.textContent = ` .pzp-btn { margin-left: .75rem; padding: .3rem .8rem; cursor: pointer; font: inherit; font-size: .85em; border-radius: 4px; border: 1px solid rgba(128,128,128,.5); background: rgba(128,128,128,.15); color: inherit; } .pzp-btn:hover { background: rgba(128,128,128,.3); } .pzp-btn[disabled] { opacity: .6; cursor: default; } .pzp-gallery { margin: .75rem 0 1.25rem; padding: .75rem; border: 1px solid rgba(128,128,128,.35); border-radius: 6px; } .pzp-gallery__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85em; opacity: .8; } .pzp-gallery__grid { display: flex; flex-direction: column; gap: .5rem; align-items: center; } .pzp-gallery__grid img { max-width: 100%; height: auto; display: block; cursor: zoom-in; border-radius: 3px; } .pzp-gallery__name { font-size: .75em; opacity: .55; margin: -0.25rem 0 .25rem; } .pzp-gallery__other { margin-top: .5rem; font-size: .8em; opacity: .7; } .pzp-error { color: #e66; font-size: .85em; margin-left: .75rem; } .pzp-lightbox { position: fixed; inset: 0; z-index: 99999; background: rgba(0,0,0,.92); display: flex; align-items: center; justify-content: center; cursor: zoom-out; } .pzp-lightbox img { max-width: 96vw; max-height: 96vh; object-fit: contain; cursor: default; } .pzp-lightbox__nav { position: fixed; top: 50%; transform: translateY(-50%); font-size: 2.5rem; color: #fff; background: rgba(0,0,0,.35); border: none; cursor: pointer; padding: .5rem 1rem; border-radius: 6px; user-select: none; } .pzp-lightbox__nav:hover { background: rgba(255,255,255,.15); } .pzp-lightbox__prev { left: 1rem; } .pzp-lightbox__next { right: 1rem; } .pzp-lightbox__counter { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); color: #ccc; font-size: .9rem; background: rgba(0,0,0,.4); padding: .25rem .75rem; border-radius: 999px; } `; document.head.appendChild(css); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const naturalSort = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); const fmtMB = (bytes) => (bytes / 1048576).toFixed(1) + ' MB'; const nextFrame = () => new Promise((r) => setTimeout(r, 0)); // let the UI repaint function fetchZip(url, onProgress) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onprogress: (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); else onProgress(null, e.loaded); }, onload: (r) => { if (r.status < 200 || r.status >= 300) return reject(new Error('HTTP ' + r.status)); let data = r.response; if (typeof data === 'string') { const bytes = new Uint8Array(data.length); for (let i = 0; i < data.length; i++) bytes[i] = data.charCodeAt(i) & 0xff; data = bytes; } else { data = new Uint8Array(data); } resolve(data); }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timeout')), }); }); } /* ------------------------------------------------------------------ */ /* Lightbox */ /* ------------------------------------------------------------------ */ function openLightbox(urls, index) { let i = index; const box = document.createElement('div'); box.className = 'pzp-lightbox'; box.innerHTML = ` <button class="pzp-lightbox__nav pzp-lightbox__prev">‹</button> <img> <button class="pzp-lightbox__nav pzp-lightbox__next">›</button> <div class="pzp-lightbox__counter"></div> `; const img = box.querySelector('img'); const counter = box.querySelector('.pzp-lightbox__counter'); const show = () => { img.src = urls[i]; counter.textContent = `${i + 1} / ${urls.length}`; }; const step = (d) => { i = (i + d + urls.length) % urls.length; show(); }; const close = () => { document.removeEventListener('keydown', onKey); box.remove(); }; const onKey = (e) => { if (e.key === 'Escape') close(); else if (e.key === 'ArrowLeft') step(-1); else if (e.key === 'ArrowRight') step(1); }; box.querySelector('.pzp-lightbox__prev').addEventListener('click', (e) => { e.stopPropagation(); step(-1); }); box.querySelector('.pzp-lightbox__next').addEventListener('click', (e) => { e.stopPropagation(); step(1); }); img.addEventListener('click', (e) => e.stopPropagation()); box.addEventListener('click', close); document.addEventListener('keydown', onKey); show(); document.body.appendChild(box); } /* ------------------------------------------------------------------ */ /* Main preview logic */ /* ------------------------------------------------------------------ */ async function previewZip(link, btn) { const li = link.closest('li') || link.parentElement; // Toggle: gallery already built → just show/hide it const existing = li.querySelector('.pzp-gallery'); if (existing) { const hidden = existing.style.display === 'none'; existing.style.display = hidden ? '' : 'none'; btn.textContent = hidden ? 'Hide preview' : 'Preview zip'; return; } btn.disabled = true; try { /* 1. Download the zip ---------------------------------------------- */ btn.textContent = 'Downloading… 0%'; const data = await fetchZip(link.href, (frac, loaded) => { btn.textContent = frac != null ? `Downloading… ${Math.round(frac * 100)}%` : `Downloading… ${fmtMB(loaded)}`; }); /* 2. Unzip (synchronous — can't deadlock) --------------------------- */ btn.textContent = 'Unpacking…'; await nextFrame(); // paint the label before we block the thread const otherNames = []; const isJunk = (name) => name.endsWith('/') || name.startsWith('__MACOSX') || name.split('/').pop().startsWith('.'); // filter: extract only images; record everything else by name const extracted = fflate.unzipSync(data, { filter(file) { if (isJunk(file.name)) return false; if (IMAGE_EXT.test(file.name)) return true; otherNames.push(file.name); return false; }, }); const names = Object.keys(extracted).sort(naturalSort.compare); /* 3. Build the gallery shell ---------------------------------------- */ const gallery = document.createElement('div'); gallery.className = 'pzp-gallery'; gallery.innerHTML = ` <div class="pzp-gallery__head"> <span>${names.length} image${names.length === 1 ? '' : 's'} · ${fmtMB(data.byteLength)}</span> <span class="pzp-gallery__status"></span> </div> <div class="pzp-gallery__grid"></div> `; li.appendChild(gallery); const grid = gallery.querySelector('.pzp-gallery__grid'); /* 4. Turn each image into a blob URL and append ---------------------- */ const blobUrls = []; for (let idx = 0; idx < names.length; idx++) { const name = names[idx]; const ext = name.split('.').pop().toLowerCase(); const url = URL.createObjectURL( new Blob([extracted[name]], { type: MIME[ext] || 'image/jpeg' }) ); blobUrls.push(url); const wrap = document.createElement('div'); const img = document.createElement('img'); img.src = url; img.loading = 'lazy'; const cap = document.createElement('div'); cap.className = 'pzp-gallery__name'; cap.textContent = name; const myIndex = idx; img.addEventListener('click', () => openLightbox(blobUrls, myIndex)); wrap.appendChild(img); wrap.appendChild(cap); grid.appendChild(wrap); if (idx % 3 === 2) await nextFrame(); // keep the page responsive } /* 5. List non-image files -------------------------------------------- */ if (otherNames.length) { const div = document.createElement('div'); div.className = 'pzp-gallery__other'; div.textContent = 'Other files in zip: ' + otherNames.join(', '); gallery.appendChild(div); } if (!names.length && !otherNames.length) { grid.textContent = 'Zip appears to be empty.'; } btn.textContent = 'Hide preview'; } catch (err) { console.error('[pzp]', err); btn.textContent = 'Preview zip'; let e = li.querySelector('.pzp-error'); if (!e) { e = document.createElement('span'); e.className = 'pzp-error'; btn.after(e); } e.textContent = 'Failed: ' + (err && err.message ? err.message : err); } finally { btn.disabled = false; } } /* ------------------------------------------------------------------ */ /* Attach buttons to zip attachment links */ /* ------------------------------------------------------------------ */ function scan(root = document) { root.querySelectorAll('a.post__attachment-link').forEach((link) => { if (link.dataset.pzp) return; const name = link.getAttribute('download') || link.href; if (!ZIP_EXT.test(name) && !ZIP_EXT.test(link.href)) return; link.dataset.pzp = '1'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'pzp-btn'; btn.textContent = 'Preview zip'; btn.addEventListener('click', (e) => { e.preventDefault(); previewZip(link, btn); }); link.after(btn); }); } scan(); new MutationObserver(() => scan()).observe(document.body, { childList: true, subtree: true }); })();