Pawchive ZIP Preview

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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