Pawchive ZIP Preview

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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