Pawchive ZIP Preview

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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