Pinterest Full

View & download original full size images (no login required) and a pleasing UI

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pinterest Full
// @namespace    https://github.com/ShrekBytes
// @description  View & download original full size images (no login required) and a pleasing UI
// @version      3.0.0
// @author       ShrekBytes
// @match        https://*.pinterest.com/*
// @match        https://*.pinterest.at/*
// @match        https://*.pinterest.ca/*
// @match        https://*.pinterest.ch/*
// @match        https://*.pinterest.cl/*
// @match        https://*.pinterest.co.kr/*
// @match        https://*.pinterest.co.uk/*
// @match        https://*.pinterest.com.au/*
// @match        https://*.pinterest.com.mx/*
// @match        https://*.pinterest.de/*
// @match        https://*.pinterest.dk/*
// @match        https://*.pinterest.es/*
// @match        https://*.pinterest.fr/*
// @match        https://*.pinterest.ie/*
// @match        https://*.pinterest.info/*
// @match        https://*.pinterest.it/*
// @match        https://*.pinterest.jp/*
// @match        https://*.pinterest.nz/*
// @match        https://*.pinterest.ph/*
// @match        https://*.pinterest.pt/*
// @match        https://*.pinterest.se/*
// @icon         https://raw.githubusercontent.com/ShrekBytes/pinterest-full/refs/heads/main/pinterest.png
// @grant        GM_openInTab
// @grant        GM_download
// @run-at       document-start
// @license      GPL-3.0
// @noframes
// @homepageURL  https://github.com/ShrekBytes/pinterest-full
// @supportURL   https://github.com/ShrekBytes/pinterest-full/issues
// ==/UserScript==

(() => {
  'use strict';

  // ===== CONFIGURATION =====
  const CONFIG = {
    ROUTE_DEBOUNCE_MS: 150,
    DOWNLOAD_FEEDBACK_MS: 500,
    API_TIMEOUT_MS: 10000,
    SWIPE_THRESHOLD_PX: 50,
    FILENAME_MAX_LENGTH: 80,
    BATCH_DOWNLOAD_DELAY_MS: 600,
    TOAST_DURATION_MS: 3000,
    MUTATION_DEBOUNCE_MS: 200,
  };

  // ===== UTILITIES =====
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const qs = (sel, root = document) => root.querySelector(sel);

  /**
   * Debounce function to limit how often a function is called
   * @param {Function} fn - Function to debounce
   * @param {number} delay - Delay in milliseconds
   * @returns {Function} Debounced function
   */
  function debounce(fn, delay) {
    let timer;
    return function(...args) {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), delay);
    };
  }

  /**
   * Extract file extension from URL
   * @param {string} url - Image URL
   * @returns {string} File extension with dot (e.g., '.jpg')
   */
  function getFileExtension(url) {
    if (!url) return '.jpg';
    const cleanUrl = url.split('?')[0];
    const match = cleanUrl.match(/\.(jpg|jpeg|png|gif|webp)$/i);
    return match ? match[0] : '.jpg';
  }

  /**
   * Get file extension type for display (without dot, uppercase)
   * @param {string} url - Image URL
   * @returns {string} Extension type (e.g., 'JPEG')
   */
  function getExtensionType(url) {
    const ext = getFileExtension(url).substring(1).toUpperCase();
    return ext === 'JPG' ? 'JPEG' : ext;
  }

  /**
   * Format file size in human-readable format
   * @param {number} bytes - File size in bytes
   * @returns {string} Formatted size string
   */
  function formatFileSize(bytes) {
    if (!bytes || bytes === 0) return '';
    if (bytes < 1024) return bytes + ' B';
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
  }

  /**
   * Sanitize filename to remove invalid characters
   * @param {string} filename - Original filename
   * @returns {string} Sanitized filename
   */
  function sanitizeFilename(filename) {
    if (!filename) return 'pinterest';
    return filename.replace(/[\/\\?%*:|"<>]/g, '-').slice(0, CONFIG.FILENAME_MAX_LENGTH) || 'pinterest';
  }

  /**
   * Download a file using GM_download or fallback to anchor element
   * @param {string} url - File URL
   * @param {string} filename - Desired filename
   * @returns {Promise<boolean>} Success status
   */
  async function downloadFile(url, filename) {
    if (!url) return false;
    
    try {
      const sanitized = sanitizeFilename(filename);
      const fullName = sanitized + getFileExtension(url);
      
      if (typeof GM_download === 'function') {
        GM_download({ url, name: fullName });
      } else {
        const a = document.createElement('a');
        a.href = url;
        a.download = fullName;
        document.body.appendChild(a);
        a.click();
        a.remove();
      }
      return true;
    } catch (e) {
      console.error('Download failed:', e);
      return false;
    }
  }

  /**
   * Open URL in new tab using GM_openInTab or fallback
   * @param {string} url - URL to open
   */
  function openInNewTab(url) {
    if (!url) return;
    
    if (typeof GM_openInTab === 'function') {
      GM_openInTab(url, { active: true, insert: true });
    } else if (typeof GM?.openInTab === 'function') {
      GM.openInTab(url, { active: true, insert: true });
    } else {
      window.open(url, '_blank');
    }
  }

  /**
   * Fetch file size from URL using HEAD request
   * @param {string} url - File URL
   * @returns {Promise<number|null>} File size in bytes or null
   */
  async function getFileSize(url) {
    if (!url) return null;
    
    try {
      const response = await fetch(url, { method: 'HEAD' });
      if (!response.ok) return null;
      const size = response.headers.get('content-length');
      return size ? parseInt(size, 10) : null;
    } catch {
      return null;
    }
  }

  /**
   * Manage button loading state
   * @param {HTMLElement} btn - Button element
   * @param {boolean} isLoading - Whether button is in loading state
   * @param {string} loadingText - Text to display during loading
   */
  function setButtonState(btn, isLoading, loadingText = 'Loading...') {
    if (!btn) return;
    
    if (isLoading) {
      btn.dataset.originalText = btn.textContent;
      btn.textContent = loadingText;
      btn.disabled = true;
    } else {
      btn.textContent = btn.dataset.originalText || btn.textContent;
      btn.disabled = false;
      delete btn.dataset.originalText;
    }
  }

  /**
   * Create a button element with common attributes
   * @param {Object} config - Button configuration
   * @returns {HTMLElement} Button element
   */
  function createButton({ id, text, ariaLabel, className = 'pp-btn' }) {
    const btn = document.createElement('button');
    if (id) btn.id = id;
    btn.className = className;
    btn.textContent = text;
    btn.setAttribute('aria-label', ariaLabel || text);
    btn.setAttribute('role', 'button');
    return btn;
  }

  // ===== TOAST NOTIFICATION SYSTEM =====
  const Toast = (() => {
    let container;

    /**
     * Initialize toast container
     */
    function init() {
      if (container) return;
      container = document.createElement('div');
      container.className = 'pp-toast-container';
      document.body.appendChild(container);
    }

    /**
     * Show a toast notification
     * @param {string} message - Message to display
     * @param {string} type - Toast type: 'success', 'error', 'warning', 'info'
     * @param {number} duration - Display duration in milliseconds
     */
    function show(message, type = 'info', duration = CONFIG.TOAST_DURATION_MS) {
      if (!message) return;
      
      init();
      
      const toast = document.createElement('div');
      toast.className = `pp-toast pp-toast-${type}`;
      toast.textContent = message;
      
      container.appendChild(toast);
      
      // Trigger animation
      setTimeout(() => toast.classList.add('pp-toast-show'), 10);
      
      // Auto dismiss
      setTimeout(() => {
        toast.classList.remove('pp-toast-show');
        setTimeout(() => toast.remove(), 300);
      }, duration);
    }

    return { show };
  })();

  // ===== CSS =====
  const CSS = `
  /* ===== Pinterest Full Modern CSS ===== */
  .pp-btn {
    all: unset;
    display: inline-flex; align-items: center; gap: .5rem;
    font-weight: 700; cursor: pointer; user-select: none;
    border-radius: 9999px; padding: .5rem .9rem; line-height: 1;
    box-shadow: 0 4px 12px rgba(0,0,0,.15);
    transition: transform .12s ease, background .2s ease, opacity .2s ease;
    font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
    background: #e60023; color: #fff;
  }
  .pp-btn:hover { background: #ad081b; }
  .pp-btn:disabled { 
    opacity: 0.6; 
    cursor: not-allowed; 
    background: #666; 
  }
  .pp-btn:disabled:hover { background: #666; }
  
  .pp-btn-sm {
    padding: .4rem .7rem;
    font-size: 13px;
  }
  
  #pp-main-btn { margin-right: 8px; }

  .pp-overlay {
    position: fixed; 
    top: 0; right: 0; bottom: 0; left: 0;
    background: rgba(0,0,0,.85); 
    z-index: 2147483647;
    display: grid; 
    grid-template-rows: auto 1fr auto;
    opacity: 0; 
    pointer-events: none; 
    transition: opacity .2s ease;
  }
  .pp-overlay.open { opacity: 1; pointer-events: auto; }

  .pp-head {
    display:flex; align-items:center; justify-content: space-between; padding: 10px 14px;
    background: rgba(20,20,20,.6); backdrop-filter: blur(4px);
    flex-wrap: wrap; gap: 8px;
  }
  .pp-head .pp-actions { display:flex; gap:8px; align-items:center; flex-wrap: wrap; }
  .pp-head .pp-info { display:flex; gap:8px; align-items:center; flex-wrap: wrap; }
  .pp-chip { 
    font-size:12px; 
    background:#222; 
    color:#fff; 
    padding:.3rem .6rem; 
    border-radius:999px;
    white-space: nowrap;
  }

  .pp-stage {
    display:grid; place-items:center; overflow:auto; padding: 16px;
  }
  .pp-img { 
    max-width: 95vw; 
    max-height: 82vh; 
    border-radius: 12px; 
    box-shadow: 0 12px 48px rgba(0,0,0,.4);
  }

  .pp-footer {
    display:flex; align-items:center; justify-content:center; gap:8px; padding:10px; 
    background: rgba(20,20,20,.6);
    flex-wrap: wrap;
  }
  .pp-thumb {
    width: 72px; height: 72px; object-fit: cover; border-radius: 8px; 
    opacity:.7; cursor:pointer; border:2px solid transparent;
    transition: opacity .2s ease, border-color .2s ease;
  }
  .pp-thumb.active { opacity:1; border-color:#fff; }
  .pp-thumb:hover { opacity: 0.9; }

  .pp-toast-container {
    position: fixed;
    top: 20px;
    right: 20px;
    z-index: 2147483648;
    display: flex;
    flex-direction: column;
    gap: 10px;
    pointer-events: none;
  }

  .pp-toast {
    padding: 12px 20px;
    border-radius: 8px;
    font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
    font-size: 14px;
    font-weight: 500;
    color: #fff;
    box-shadow: 0 4px 12px rgba(0,0,0,.15);
    opacity: 0;
    transform: translateX(100px);
    transition: opacity .3s ease, transform .3s ease;
    pointer-events: auto;
    max-width: 300px;
  }

  .pp-toast-show {
    opacity: 1;
    transform: translateX(0);
  }

  .pp-toast-success { background: #059669; }
  .pp-toast-error { background: #dc2626; }
  .pp-toast-warning { background: #f59e0b; }
  .pp-toast-info { background: #3b82f6; }

  @media (max-width: 640px) {
    .pp-head {
      flex-direction: column;
      align-items: stretch;
    }
    
    .pp-head .pp-actions,
    .pp-head .pp-info {
      justify-content: center;
    }
    
    .pp-toast-container {
      left: 20px;
      right: 20px;
    }
    
    .pp-toast {
      max-width: none;
    }
  }
  `;

  /**
   * Inject CSS into the page once
   */
  function ensureCSS() {
    if (qs('#pp-css')) return;
    const style = document.createElement('style');
    style.id = 'pp-css';
    style.textContent = CSS;
    (document.head || document.documentElement).appendChild(style);
  }

  // ===== PINTEREST API & DATA EXTRACTION =====

  /**
   * Derive original URL from image element (fallback method)
   * @param {HTMLImageElement} img - Image element
   * @returns {string|null} Original image URL
   */
  function fromSrcOrSrcset(img) {
    if (!img) return null;
    
    // Prefer largest from srcset
    if (img.srcset) {
      const parts = img.srcset.split(',').map(p => p.trim());
      let best = null, bestW = 0;
      for (const p of parts) {
        const [url, size] = p.split(' ');
        const w = parseInt(size || '0', 10) || 0;
        if (w >= bestW) { best = url; bestW = w; }
      }
      if (best) return best.replace(/\/\d+x\//, '/originals/');
    }
    
    if (img.src) return img.src.replace(/\/\d+x\//, '/originals/');
    return null;
  }

  /**
   * Extract pin ID from URL
   * @param {string} url - URL to parse
   * @returns {string|null} Pin ID
   */
  function getPinIdFromUrl(url = location.href) {
    const m = url?.match(/\/pin\/([^\/?#]+)/i);
    return m ? m[1] : null;
  }

  /**
   * Fetch pin data from Pinterest's internal API
   * @param {string} pinId - Pinterest pin ID
   * @returns {Promise<Object|null>} Pin data or null on failure
   */
  async function fetchPinData(pinId) {
    if (!pinId) return null;
    
    try {
      const t = Date.now();
      const u = `https://${location.host}/resource/PinResource/get/?source_url=%2Fpin%2F${encodeURIComponent(pinId)}%2F&data=%7B%22options%22%3A%7B%22id%22%3A%22${encodeURIComponent(pinId)}%22%2C%22field_set_key%22%3A%22detailed%22%2C%22noCache%22%3Atrue%7D%2C%22context%22%3A%7B%7D%7D&_=${t}`;
      
      // Create AbortController for timeout (browser compatibility)
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), CONFIG.API_TIMEOUT_MS);
      
      const res = await fetch(u, {
        headers: { 'X-Pinterest-PWS-Handler': 'www/pin/[id].js' },
        credentials: 'include',
        signal: controller.signal
      });
      
      clearTimeout(timeoutId);
      
      if (!res.ok) throw new Error(`API returned ${res.status}`);
      const json = await res.json();
      if (json?.resource_response?.status !== 'success') throw new Error('Invalid API response');
      return json.resource_response.data;
    } catch (e) {
      console.warn('Failed to fetch pin data:', e.message);
      if (e.name !== 'AbortError') {
        Toast.show('Failed to load pin data from API', 'warning');
      }
      return null;
    }
  }

  /**
   * Extract best quality images from pin data
   * @param {Object} pin - Pin data from API
   * @returns {Object} Pack with items array and title
   */
  function getBestFromPinData(pin) {
    const pack = { items: [], title: (pin?.grid_title || pin?.title || '').trim() };
    if (!pin) return pack;

    // Story pins (multi-page content)
    if (pin.story_pin_data?.pages?.length) {
      for (const page of pin.story_pin_data.pages) {
        const url = page?.image?.images?.originals?.url
                 || page?.blocks?.[0]?.image?.images?.originals?.url
                 || page?.blocks?.[0]?.image?.images?.orig?.url;
        if (url) pack.items.push({ url, width: 0, height: 0, thumb: url });
      }
    }

    // Regular pin original image
    const orig = pin.images?.orig;
    if (orig?.url) {
      if (!pack.items.length) {
        pack.items.push({ url: orig.url, width: orig.width || 0, height: orig.height || 0, thumb: orig.url });
      } else if (!pack.items.some(i => i.url === orig.url)) {
        // Ensure main original is present (dedupe)
        pack.items.unshift({ url: orig.url, width: orig.width || 0, height: orig.height || 0, thumb: orig.url });
      }
    }

    // Deduplicate
    const seen = new Set();
    pack.items = pack.items.filter(i => i.url && !seen.has(i.url) && (seen.add(i.url) || true));
    return pack;
  }

  /**
   * Derive image from DOM as fallback
   * @returns {Array} Array of image items
   */
  function deriveFromDomAsFallback() {
    const closeup = qs("div[data-test-id='CloseupMainPin'], div.reactCloseupScrollContainer") || document;
    const img = qs('img[srcset], img[src]', closeup);
    const url = fromSrcOrSrcset(img);
    return url ? [{ url, width: 0, height: 0, thumb: url }] : [];
  }

  // ===== OVERLAY GALLERY =====
  const Overlay = (() => {
    let root, stage, footer, titleEl, resEl, counterEl, metaEl;
    let currentIndex = 0;
    let items = [];

    /**
     * Build overlay DOM structure
     */
    function build() {
      if (root) return;
      
      root = document.createElement('div');
      root.className = 'pp-overlay';
      root.innerHTML = `
        <div class="pp-head">
          <div class="pp-actions">
            <button class="pp-btn pp-btn-sm" id="pp-download">Download</button>
            <button class="pp-btn pp-btn-sm" id="pp-download-all" style="display:none;">Download All</button>
            <button class="pp-btn pp-btn-sm" id="pp-open">Open</button>
          </div>
          <div class="pp-info">
            <span id="pp-counter" class="pp-chip" style="display:none;"></span>
            <span id="pp-title" class="pp-chip"></span>
            <span id="pp-meta" class="pp-chip"></span>
            <span id="pp-res" class="pp-chip"></span>
            <button class="pp-btn pp-btn-sm" id="pp-close">Close</button>
          </div>
        </div>
        <div class="pp-stage"></div>
        <div class="pp-footer"></div>
      `;
      document.body.appendChild(root);
      
      stage = qs('.pp-stage', root);
      footer = qs('.pp-footer', root);
      titleEl = qs('#pp-title', root);
      resEl = qs('#pp-res', root);
      counterEl = qs('#pp-counter', root);
      metaEl = qs('#pp-meta', root);

      setupEventListeners();
    }

    /**
     * Setup all event listeners for overlay
     */
    function setupEventListeners() {
      // Close button
      qs('#pp-close', root).addEventListener('click', close);
      
      // Download current
      qs('#pp-download', root).addEventListener('click', async () => {
        const btn = qs('#pp-download', root);
        if (btn.disabled) return;
        
        setButtonState(btn, true, 'Downloading...');
        
        try {
          await downloadCurrent();
          await sleep(CONFIG.DOWNLOAD_FEEDBACK_MS);
          Toast.show('Download started!', 'success');
        } catch (error) {
          Toast.show('Download failed', 'error');
        } finally {
          setButtonState(btn, false);
        }
      });

      // Download all
      qs('#pp-download-all', root).addEventListener('click', async () => {
        const btn = qs('#pp-download-all', root);
        if (btn.disabled) return;
        
        setButtonState(btn, true, 'Preparing...');
        
        try {
          await downloadAll(btn);
          Toast.show('All downloads completed!', 'success');
        } catch (error) {
          Toast.show('Some downloads failed', 'error');
        } finally {
          setButtonState(btn, false);
        }
      });

      // Open in new tab
      qs('#pp-open', root).addEventListener('click', openCurrent);

      // Keyboard navigation
      document.addEventListener('keydown', (e) => {
        if (!isOpen()) return;
        
        switch(e.key) {
          case 'Escape':
            close();
            break;
          case 'ArrowRight':
            next();
            break;
          case 'ArrowLeft':
            prev();
            break;
        }
        
        if (e.key.toLowerCase() === 'd') {
          e.preventDefault();
          downloadCurrent();
        }
      }, { capture: true });

      // Swipe gestures (mobile)
      let touchX = 0;
      stage.addEventListener('touchstart', (e) => {
        touchX = e.touches[0].clientX;
      }, { passive: true });
      
      stage.addEventListener('touchend', (e) => {
        const dx = e.changedTouches[0].clientX - touchX;
        if (Math.abs(dx) > CONFIG.SWIPE_THRESHOLD_PX) {
          dx < 0 ? next() : prev();
        }
      });
    }

    /**
     * Open overlay with image pack
     * @param {Object} pack - Pack containing items and title
     */
    function open(pack) {
      build();
      items = pack?.items || [];
      
      if (items.length === 0) {
        Toast.show('No images found', 'warning');
        return;
      }
      
      titleEl.textContent = pack.title || '';
      currentIndex = 0;
      
      // Show/hide download all button
      const downloadAllBtn = qs('#pp-download-all', root);
      downloadAllBtn.style.display = items.length > 1 ? '' : 'none';
      
      render();
      root.classList.add('open');
    }

    /**
     * Close overlay
     */
    function close() {
      root?.classList.remove('open');
    }

    /**
     * Check if overlay is open
     * @returns {boolean}
     */
    function isOpen() {
      return root?.classList.contains('open') || false;
    }

    /**
     * Render current image and UI
     */
    async function render() {
      stage.innerHTML = '';
      const cur = items[currentIndex];
      if (!cur) return;

      // Update counter
      counterEl.style.display = items.length > 1 ? '' : 'none';
      if (items.length > 1) {
        counterEl.textContent = `${currentIndex + 1} / ${items.length}`;
      }

      // Create and load image
      const el = document.createElement('img');
      el.className = 'pp-img';
      el.alt = titleEl.textContent || 'Image';
      el.src = cur.url;
      
      el.addEventListener('load', async () => {
        const w = el.naturalWidth || cur.width || 0;
        const h = el.naturalHeight || cur.height || 0;
        resEl.textContent = w && h ? `${w}×${h}` : '';
        
        // Display file format
        const ext = getExtensionType(cur.url);
        metaEl.textContent = ext;
        
        // Fetch file size asynchronously
        const size = await getFileSize(cur.url);
        if (size) {
          metaEl.textContent = `${ext} • ${formatFileSize(size)}`;
        }
      }, { once: true });

      stage.appendChild(el);

      // Render thumbnails
      renderThumbnails();
    }

    /**
     * Render thumbnail navigation
     */
    function renderThumbnails() {
      footer.innerHTML = '';
      
      if (items.length <= 1) return;
      
      items.forEach((it, i) => {
        const t = document.createElement('img');
        t.className = 'pp-thumb' + (i === currentIndex ? ' active' : '');
        t.src = it.thumb || it.url;
        t.alt = `Image ${i + 1}`;
        t.loading = 'lazy';
        t.addEventListener('click', () => {
          currentIndex = i;
          render();
        });
        footer.appendChild(t);
      });
    }

    /**
     * Navigate to next image
     */
    function next() {
      if (currentIndex < items.length - 1) {
        currentIndex++;
        render();
      }
    }

    /**
     * Navigate to previous image
     */
    function prev() {
      if (currentIndex > 0) {
        currentIndex--;
        render();
      }
    }

    /**
     * Get current image item
     * @returns {Object|null}
     */
    function current() {
      return items[currentIndex] || null;
    }

    /**
     * Download current image
     */
    async function downloadCurrent() {
      const c = current();
      if (!c) throw new Error('No image to download');
      
      const filename = titleEl.textContent || 'pinterest';
      const success = await downloadFile(c.url, filename);
      
      if (!success) {
        throw new Error('Download failed');
      }
    }

    /**
     * Download all images sequentially
     * @param {HTMLElement} btn - Button element to update
     */
    async function downloadAll(btn) {
      const total = items.length;
      const baseTitle = titleEl.textContent || 'pinterest';
      
      for (let i = 0; i < total; i++) {
        if (btn) {
          btn.textContent = `Downloading ${i + 1}/${total}...`;
        }
        
        const filename = total > 1 ? `${baseTitle}_page_${i + 1}` : baseTitle;
        const success = await downloadFile(items[i].url, filename);
        
        if (!success) {
          Toast.show(`Failed to download image ${i + 1}`, 'error');
        }
        
        // Delay between downloads to avoid browser blocking
        if (i < total - 1) {
          await sleep(CONFIG.BATCH_DOWNLOAD_DELAY_MS);
        }
      }
    }

    /**
     * Open current image in new tab
     */
    function openCurrent() {
      const c = current();
      if (c) openInNewTab(c.url);
    }

    return { open, close, isOpen };
  })();

  // ===== MAIN APP LOGIC =====
  const App = (() => {
    let routeObserverSetup = false;
    let domObserver;
    const injectedButtons = new WeakSet();

    /**
     * Initialize the application
     */
    async function init() {
      ensureCSS();
      setupRouteObserver();
      setupDomObserver();
      onRoute();
    }

    /**
     * Setup SPA route detection
     */
    function setupRouteObserver() {
      if (routeObserverSetup) return;
      
      routeObserverSetup = true;
      const push = history.pushState;
      const replace = history.replaceState;
      
      history.pushState = function(...args) {
        const r = push.apply(this, args);
        onRoute();
        return r;
      };
      
      history.replaceState = function(...args) {
        const r = replace.apply(this, args);
        onRoute();
        return r;
      };
      
      window.addEventListener('popstate', onRoute, { passive: true });
    }

    /**
     * Setup DOM mutation observer with debouncing
     */
    function setupDomObserver() {
      if (domObserver) return;
      
      const debouncedInject = debounce(() => {
        if (getPinIdFromUrl()) {
          injectCloseupButton();
        }
      }, CONFIG.MUTATION_DEBOUNCE_MS);

      domObserver = new MutationObserver((mutations) => {
        if (!getPinIdFromUrl()) return;
        
        const hasRelevantChanges = mutations.some(m =>
          m.type === 'childList' &&
          (m.target.matches?.('[data-test-id*="Closeup"]') ||
           m.target.matches?.('[data-test-id*="share"]') ||
           m.target.closest?.('[data-test-id*="Closeup"]'))
        );
        
        if (hasRelevantChanges) {
          debouncedInject();
        }
      });
      
      domObserver.observe(document.documentElement, { childList: true, subtree: true });
    }

    /**
     * Handle route changes
     */
    async function onRoute() {
      await sleep(CONFIG.ROUTE_DEBOUNCE_MS);
      injectCloseupButton();
    }

    /**
     * Inject View and Download buttons into Pinterest UI
     */
    function injectCloseupButton() {
      if (!getPinIdFromUrl()) return;

      const bar = findActionBar();
      if (!bar || injectedButtons.has(bar) || qs('#pp-main-btn', bar)) return;

      injectedButtons.add(bar);
      
      injectViewButton(bar);
      injectDownloadButton(bar);
    }

    /**
     * Find Pinterest action bar
     * @returns {HTMLElement|null}
     */
    function findActionBar() {
      return qs("div[data-test-id='share-button']")?.parentElement ||
             qs("div[data-test-id='closeupActionBar']>div>div") ||
             qs("div[data-test-id='CloseupDetails']") ||
             qs("div[data-test-id='CloseupMainPin'] div:has(button)") ||
             null;
    }

    /**
     * Handle pack resolution with loading state
     * @param {HTMLElement} btn - Button element
     * @param {Function} callback - Callback to execute with resolved pack
     */
    async function handlePackAction(btn, callback) {
      if (btn.disabled) return;
      
      setButtonState(btn, true);
      
      try {
        const pack = await resolveCurrentPinPack();
        if (pack?.items?.length) {
          await callback(pack);
        } else {
          Toast.show('No images found', 'warning');
        }
      } catch (error) {
        console.error('Pack action failed:', error);
        Toast.show('Failed to load images', 'error');
      } finally {
        setButtonState(btn, false);
      }
    }

    /**
     * Inject View button
     * @param {HTMLElement} bar - Action bar element
     */
    function injectViewButton(bar) {
      const btn = createButton({
        id: 'pp-main-btn',
        text: 'View',
        ariaLabel: 'View full size image'
      });

      // Left click = open overlay
      btn.addEventListener('mousedown', (e) => {
        e.preventDefault();
        if (e.button === 0) {
          handlePackAction(btn, pack => Overlay.open(pack));
        } else if (e.button === 1) {
          // Middle click = open in tab
          resolveCurrentPinPack().then(pack => {
            if (pack?.items?.[0]) openInNewTab(pack.items[0].url);
          });
        }
      }, { passive: false });

      // Mobile support
      btn.addEventListener('touchend', () => {
        handlePackAction(btn, pack => Overlay.open(pack));
      }, { passive: true });

      bar.appendChild(btn);
    }

    /**
     * Inject Download button
     * @param {HTMLElement} bar - Action bar element
     */
    function injectDownloadButton(bar) {
      if (qs('#pp-mini-download', bar)) return;
      
      const btn = createButton({
        id: 'pp-mini-download',
        text: 'Download',
        ariaLabel: 'Download current image'
      });
      
      btn.addEventListener('click', async () => {
        if (btn.disabled) return;
        
        setButtonState(btn, true, 'Downloading...');
        
        try {
          const pack = await resolveCurrentPinPack();
          if (!pack?.items?.length) {
            Toast.show('No images found', 'warning');
            return;
          }
          
          const success = await downloadFile(pack.items[0].url, pack.title || 'pinterest');
          if (success) {
            Toast.show('Download started!', 'success');
          } else {
            throw new Error('Download failed');
          }
        } catch (error) {
          console.error('Download failed:', error);
          Toast.show('Download failed', 'error');
        } finally {
          setButtonState(btn, false);
        }
      });
      
      bar.appendChild(btn);
    }

    /**
     * Resolve current pin pack with images
     * @returns {Promise<Object>} Pack with items and title
     */
    async function resolveCurrentPinPack() {
      const pinId = getPinIdFromUrl();
      
      if (!pinId) {
        const items = deriveFromDomAsFallback();
        return { title: '', items };
      }
      
      const data = await fetchPinData(pinId);
      const pack = getBestFromPinData(data);
      
      if (!pack.items.length) {
        pack.items = deriveFromDomAsFallback();
      }
      
      if (!pack.title) {
        const img = qs('img[alt]');
        if (img?.alt) pack.title = img.alt;
      }
      
      pack.title = sanitizeFilename(pack.title);
      return pack;
    }

    return { init };
  })();

  // ===== INITIALIZE =====
  if (document.readyState === 'loading') {
    window.addEventListener('DOMContentLoaded', () => App.init());
  } else {
    App.init();
  }
})();