Pawchive ZIP Preview

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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