Pawchive ZIP Preview

Preview images inside .zip attachments inline on pawchive post pages — no download & unpack needed.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 });
})();