GitHub Watch Later

Add a Watch Later list to GitHub repositories. Save repos for later, search and sort by added time.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         GitHub Watch Later
// @namespace    https://github.com/hanmi255/github-WatchLater
// @version      0.1.1
// @description  Add a Watch Later list to GitHub repositories. Save repos for later, search and sort by added time.
// @author       hanmi255
// @match        https://github.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @license      MIT
// @icon         https://github.githubassets.com/pinned-octocat.svg
// @homepageURL  https://github.com/hanmi255/github-WatchLater
// @supportURL   https://github.com/hanmi255/github-WatchLater/issues
// ==/UserScript==

(function () {
  "use strict";

  // ── Storage ──────────────────────────────────────────────────────────────

  const STORAGE_KEY = "gwl_data";

  function loadData() {
    const raw = GM_getValue(STORAGE_KEY, null);
    if (raw) return JSON.parse(raw);
    return { version: 1, repos: [], settings: { sortBy: "addedAt_desc" } };
  }

  function saveData(data) {
    GM_setValue(STORAGE_KEY, JSON.stringify(data));
  }

  function getRepos() {
    return loadData().repos;
  }

  function isAdded(id) {
    return getRepos().some((r) => r.id === id);
  }

  function addRepo(repo) {
    const data = loadData();
    if (!data.repos.some((r) => r.id === repo.id)) {
      data.repos.unshift(repo);
      saveData(data);
    }
  }

  function removeRepo(id) {
    const data = loadData();
    data.repos = data.repos.filter((r) => r.id !== id);
    saveData(data);
  }

  // ── Page detection ────────────────────────────────────────────────────────

  const RESERVED = new Set([
    "features",
    "topics",
    "explore",
    "marketplace",
    "settings",
    "notifications",
    "pulls",
    "issues",
    "login",
    "join",
    "organizations",
    "orgs",
    "sponsors",
    "about",
    "pricing",
    "contact",
    "security",
    "enterprise",
    "readme",
    "new",
    "codespaces",
    "gist",
    "discussions",
  ]);

  function getRepoInfo() {
    const parts = location.pathname.split("/").filter(Boolean);
    if (parts.length < 2) return null;
    if (RESERVED.has(parts[0])) return null;
    const owner = parts[0];
    const name = parts[1];
    if (
      [
        "repositories",
        "projects",
        "packages",
        "stars",
        "followers",
        "following",
      ].includes(name)
    )
      return null;

    const descEl = document.querySelector('meta[name="description"]');
    const description = descEl
      ? descEl.content.replace(/\s*\d+[\s\S]*$/, "").trim()
      : "";

    return {
      id: `${owner}/${name}`,
      owner,
      name,
      fullName: `${owner}/${name}`,
      url: `https://github.com/${owner}/${name}`,
      description,
      addedAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      tags: [],
      note: "",
      status: "unread",
    };
  }

  // ── Styles ────────────────────────────────────────────────────────────────

  GM_addStyle(`
    .gwl-btn {
      display: inline-flex; align-items: center; gap: 4px;
      padding: 3px 12px; font-size: 12px; font-weight: 500; line-height: 20px;
      color: var(--fgColor-default, #24292f);
      background-color: var(--bgColor-default, #f6f8fa);
      border: 1px solid var(--borderColor-default, rgba(31,35,40,0.15));
      border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none;
    }
    .gwl-btn:hover { background-color: var(--bgColor-muted, #eaeef2); }
    .gwl-btn.gwl-added {
      color: var(--fgColor-success, #1a7f37);
      border-color: var(--borderColor-success, rgba(31,136,61,0.4));
      background-color: var(--bgColor-success-subtle, #dafbe1);
    }
    .gwl-btn.gwl-added:hover { background-color: #c6efce; }

    .gwl-overlay {
      position: fixed; inset: 0; background: rgba(0,0,0,0.4);
      z-index: 99998; display: flex; align-items: flex-start; justify-content: flex-end;
    }
    .gwl-panel {
      width: 420px; max-width: 100vw; height: 100vh;
      background: var(--bgColor-default, #ffffff);
      border-left: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; flex-direction: column;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
      font-size: 14px; color: var(--fgColor-default, #24292f);
      overflow: hidden; z-index: 99999;
    }
    .gwl-panel-header {
      padding: 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
    }
    .gwl-panel-header h2 { margin: 0; font-size: 16px; font-weight: 600; }
    .gwl-close-btn {
      background: none; border: none; cursor: pointer; padding: 4px;
      color: var(--fgColor-muted, #57606a); font-size: 18px; line-height: 1;
    }
    .gwl-close-btn:hover { color: var(--fgColor-default, #24292f); }
    .gwl-controls {
      padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; flex-direction: column; gap: 8px; flex-shrink: 0;
    }
    .gwl-search {
      width: 100%; padding: 5px 10px; font-size: 14px;
      border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;
      background: var(--bgColor-default, #fff); color: var(--fgColor-default, #24292f);
      box-sizing: border-box;
    }
    .gwl-search:focus { outline: none; border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,0.3); }
    .gwl-row { display: flex; gap: 8px; }
    .gwl-select {
      flex: 1; padding: 5px 8px; font-size: 12px;
      border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;
      background: var(--bgColor-default, #fff); color: var(--fgColor-default, #24292f);
    }
    .gwl-list { flex: 1; overflow-y: auto; padding: 8px 0; }
    .gwl-item {
      padding: 12px 16px;
      border-bottom: 1px solid var(--borderColor-muted, #eaeef2);
    }
    .gwl-item:last-child { border-bottom: none; }
    .gwl-item-title {
      font-weight: 600; font-size: 14px; color: #0969da;
      text-decoration: none; display: block; margin-bottom: 4px;
    }
    .gwl-item-title:hover { text-decoration: underline; }
    .gwl-item-meta { font-size: 12px; color: var(--fgColor-muted, #57606a); margin-bottom: 4px; }
    .gwl-item-desc {
      font-size: 12px; color: var(--fgColor-muted, #57606a); margin-bottom: 8px;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
    }
    .gwl-item-actions { display: flex; gap: 6px; }
    .gwl-action-btn {
      padding: 2px 10px; font-size: 12px; border-radius: 6px; cursor: pointer;
      border: 1px solid var(--borderColor-default, #d0d7de);
      background: var(--bgColor-default, #f6f8fa); color: var(--fgColor-default, #24292f);
      text-decoration: none; display: inline-flex; align-items: center;
    }
    .gwl-action-btn:hover { background: var(--bgColor-muted, #eaeef2); }
    .gwl-action-btn.danger { color: #cf222e; border-color: rgba(207,34,46,0.4); }
    .gwl-action-btn.danger:hover { background: #ffebe9; }
    .gwl-empty {
      padding: 40px 16px; text-align: center;
      color: var(--fgColor-muted, #57606a); font-size: 14px;
    }
    .gwl-empty p { margin: 8px 0; }
    .gwl-footer {
      padding: 12px 16px; border-top: 1px solid var(--borderColor-default, #d0d7de);
      display: flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; align-items: center;
    }
    .gwl-count { font-size: 12px; color: var(--fgColor-muted, #57606a); margin-left: auto; }
    .gwl-toast {
      position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
      background: var(--bgColor-emphasis, #24292f); color: #fff;
      padding: 8px 16px; border-radius: 6px; font-size: 13px; z-index: 100000;
      animation: gwl-fadein 0.2s ease;
    }
    @keyframes gwl-fadein {
      from { opacity: 0; transform: translateX(-50%) translateY(8px); }
      to   { opacity: 1; transform: translateX(-50%) translateY(0); }
    }
    .gwl-fab {
      position: fixed; bottom: 24px; right: 24px; z-index: 99997;
      padding: 5px 12px; font-size: 12px; font-weight: 500; line-height: 20px;
      color: var(--fgColor-default, #24292f);
      background-color: var(--bgColor-default, #f6f8fa);
      border: 1px solid var(--borderColor-default, rgba(31,35,40,0.15));
      border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none;
    }
    .gwl-fab:hover { background-color: var(--bgColor-muted, #eaeef2); }
  `);

  // ── Toast ─────────────────────────────────────────────────────────────────

  function showToast(msg) {
    const el = document.createElement("div");
    el.className = "gwl-toast";
    el.textContent = msg;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), 2000);
  }

  // ── Later Button ──────────────────────────────────────────────────────────

  function updateBtn(btn, added) {
    btn.className = "gwl-btn" + (added ? " gwl-added" : "");
    btn.textContent = added ? "Later ✓" : "Later";
    btn.title = added ? "Remove from Watch Later" : "Add to Watch Later";
  }

  function injectLaterBtn() {
    const repoInfo = getRepoInfo();
    if (!repoInfo) return;

    const existing = document.getElementById("gwl-later-btn");
    if (existing) {
      updateBtn(existing, isAdded(repoInfo.id));
      return;
    }

    const targets = [
      () =>
        document.querySelector("#repository-details-container .d-flex.gap-2"),
      () => document.querySelector(".pagehead-actions"),
      () => {
        const star =
          document.querySelector('[data-hydro-click*="star"]') ||
          document.querySelector('button[aria-label*="Star"]') ||
          document.querySelector(".starring-container");
        return star ? star.closest("li, div.d-flex, div.BtnGroup") : null;
      },
    ];

    let container = null;
    for (const fn of targets) {
      try {
        container = fn();
      } catch (_) {}
      if (container) break;
    }
    if (!container) return;

    const btn = document.createElement("button");
    btn.id = "gwl-later-btn";
    updateBtn(btn, isAdded(repoInfo.id));

    btn.addEventListener("click", () => {
      const info = getRepoInfo() || repoInfo;
      if (isAdded(info.id)) {
        removeRepo(info.id);
        updateBtn(btn, false);
        showToast("Removed from Later");
      } else {
        addRepo(info);
        updateBtn(btn, true);
        showToast("Added to Later");
      }
    });

    if (container.tagName === "UL") {
      const li = document.createElement("li");
      li.appendChild(btn);
      container.appendChild(li);
    } else {
      container.appendChild(btn);
    }
  }

  // ── Panel ─────────────────────────────────────────────────────────────────

  let panelOpen = false;

  function openPanel() {
    if (panelOpen) return;
    panelOpen = true;

    const overlay = document.createElement("div");
    overlay.className = "gwl-overlay";

    const panel = document.createElement("div");
    panel.className = "gwl-panel";

    // Header
    const header = document.createElement("div");
    header.className = "gwl-panel-header";
    const h2 = document.createElement("h2");
    h2.textContent = "GitHub Watch Later";
    const headerRight = document.createElement("div");
    headerRight.style.cssText = "display:flex;gap:4px;align-items:center";

    const refreshBtn = document.createElement("button");
    refreshBtn.className = "gwl-close-btn";
    refreshBtn.title = "Refresh";
    refreshBtn.innerHTML = "&#8635;";
    refreshBtn.addEventListener("click", render);

    const closeBtn = document.createElement("button");
    closeBtn.className = "gwl-close-btn";
    closeBtn.textContent = "×";
    closeBtn.addEventListener("click", close);

    headerRight.appendChild(refreshBtn);
    headerRight.appendChild(closeBtn);
    header.appendChild(h2);
    header.appendChild(headerRight);

    // Controls
    const controls = document.createElement("div");
    controls.className = "gwl-controls";

    const search = document.createElement("input");
    search.className = "gwl-search";
    search.type = "text";
    search.placeholder = "Search by repo or owner...";

    const sortSel = document.createElement("select");
    sortSel.className = "gwl-select";
    [
      ["addedAt_desc", "Newest first"],
      ["addedAt_asc", "Oldest first"],
      ["name_asc", "Name A→Z"],
      ["name_desc", "Name Z→A"],
    ].forEach(([v, t]) => {
      const o = document.createElement("option");
      o.value = v;
      o.textContent = t;
      sortSel.appendChild(o);
    });
    sortSel.value = loadData().settings.sortBy;

    controls.appendChild(search);
    controls.appendChild(sortSel);

    // List
    const list = document.createElement("div");
    list.className = "gwl-list";

    // Footer
    const footer = document.createElement("div");
    footer.className = "gwl-footer";

    const exportBtn = document.createElement("button");
    exportBtn.className = "gwl-action-btn";
    exportBtn.textContent = "Export";
    exportBtn.addEventListener("click", exportData);

    const importBtn = document.createElement("button");
    importBtn.className = "gwl-action-btn";
    importBtn.textContent = "Import";

    const importInput = document.createElement("input");
    importInput.type = "file";
    importInput.accept = ".json";
    importInput.style.display = "none";
    importInput.addEventListener("change", (e) => {
      importData(e);
      render();
    });
    importBtn.addEventListener("click", () => importInput.click());

    const clearBtn = document.createElement("button");
    clearBtn.className = "gwl-action-btn danger";
    clearBtn.textContent = "Clear All";
    clearBtn.addEventListener("click", () => {
      if (
        !confirm("Are you sure you want to clear all Watch Later repositories?")
      )
        return;
      const data = loadData();
      data.repos = [];
      saveData(data);
      render();
      showToast("Cleared all repositories");
    });

    const countEl = document.createElement("span");
    countEl.className = "gwl-count";

    footer.appendChild(exportBtn);
    footer.appendChild(importBtn);
    footer.appendChild(importInput);
    footer.appendChild(clearBtn);
    footer.appendChild(countEl);

    panel.appendChild(header);
    panel.appendChild(controls);
    panel.appendChild(list);
    panel.appendChild(footer);
    overlay.appendChild(panel);
    document.body.appendChild(overlay);

    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) close();
    });
    const onKey = (e) => {
      if (e.key === "Escape") close();
    };
    document.addEventListener("keydown", onKey);

    function render() {
      const keyword = search.value.trim().toLowerCase();
      const sortBy = sortSel.value;

      let repos = getRepos();

      if (keyword)
        repos = repos.filter(
          (r) =>
            r.fullName.toLowerCase().includes(keyword) ||
            r.owner.toLowerCase().includes(keyword) ||
            r.name.toLowerCase().includes(keyword) ||
            (r.description || "").toLowerCase().includes(keyword),
        );

      repos = [...repos].sort((a, b) => {
        switch (sortBy) {
          case "addedAt_asc":
            return new Date(a.addedAt) - new Date(b.addedAt);
          case "name_asc":
            return a.fullName.localeCompare(b.fullName);
          case "name_desc":
            return b.fullName.localeCompare(a.fullName);
          default:
            return new Date(b.addedAt) - new Date(a.addedAt);
        }
      });

      countEl.textContent = `${repos.length} repo${repos.length !== 1 ? "s" : ""}`;
      list.innerHTML = "";

      if (repos.length === 0) {
        const empty = document.createElement("div");
        empty.className = "gwl-empty";
        empty.innerHTML =
          getRepos().length === 0
            ? '<p>No repositories saved yet.</p><p>Click "Later" on any GitHub repository to save it here.</p>'
            : "<p>No matching repositories found.</p>";
        list.appendChild(empty);
        return;
      }

      repos.forEach((repo) => {
        const item = document.createElement("div");
        item.className = "gwl-item";

        const title = document.createElement("a");
        title.className = "gwl-item-title";
        title.href = repo.url;
        title.target = "_blank";
        title.rel = "noopener noreferrer";
        title.textContent = repo.fullName;

        const meta = document.createElement("div");
        meta.className = "gwl-item-meta";
        meta.textContent = `added ${new Date(repo.addedAt).toLocaleDateString()}`;

        const actions = document.createElement("div");
        actions.className = "gwl-item-actions";

        const openBtn = document.createElement("a");
        openBtn.className = "gwl-action-btn";
        openBtn.href = repo.url;
        openBtn.target = "_blank";
        openBtn.rel = "noopener noreferrer";
        openBtn.textContent = "Open";

        const removeBtn = document.createElement("button");
        removeBtn.className = "gwl-action-btn danger";
        removeBtn.textContent = "Remove";
        removeBtn.addEventListener("click", () => {
          removeRepo(repo.id);
          render();
          const btn = document.getElementById("gwl-later-btn");
          const info = getRepoInfo();
          if (btn && info && info.id === repo.id) updateBtn(btn, false);
          showToast("Removed from Later");
        });

        actions.appendChild(openBtn);
        actions.appendChild(removeBtn);
        item.appendChild(title);
        item.appendChild(meta);
        if (repo.description) {
          const desc = document.createElement("div");
          desc.className = "gwl-item-desc";
          desc.textContent = repo.description;
          item.appendChild(desc);
        }
        item.appendChild(actions);
        list.appendChild(item);
      });
    }

    sortSel.addEventListener("change", () => {
      const data = loadData();
      data.settings.sortBy = sortSel.value;
      saveData(data);
      render();
    });

    let searchTimer;
    search.addEventListener("input", () => {
      clearTimeout(searchTimer);
      searchTimer = setTimeout(render, 200);
    });

    render();

    function close() {
      document.removeEventListener("keydown", onKey);
      overlay.remove();
      panelOpen = false;
    }
  }

  // ── Import / Export ───────────────────────────────────────────────────────

  function exportData() {
    const blob = new Blob([JSON.stringify(loadData(), null, 2)], {
      type: "application/json",
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `github-watch-later-${new Date().toISOString().slice(0, 10)}.json`;
    a.click();
    URL.revokeObjectURL(url);
  }

  function importData(e) {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      try {
        const imported = JSON.parse(ev.target.result);
        if (!Array.isArray(imported.repos)) throw new Error();
        const data = loadData();
        const ids = new Set(data.repos.map((r) => r.id));
        let added = 0;
        imported.repos.forEach((r) => {
          if (!ids.has(r.id)) {
            data.repos.push(r);
            added++;
          }
        });
        saveData(data);
        showToast(`Imported ${added} new repo${added !== 1 ? "s" : ""}`);
      } catch (_) {
        showToast("Import failed: invalid file");
      }
    };
    reader.readAsText(file);
    e.target.value = "";
  }

  // ── Keyboard shortcut + event bridge ─────────────────────────────────────

  document.addEventListener('gwl:open-panel', openPanel);

  let bgnLoaded = false;
  document.addEventListener('bgn:loaded', () => {
    bgnLoaded = true;
    const fab = document.getElementById('gwl-fab');
    if (fab) fab.remove();
  });

  function injectFab() {
    if (bgnLoaded) return;
    if (document.getElementById('gwl-fab')) return;
    const btn = document.createElement('button');
    btn.id = 'gwl-fab';
    btn.className = 'gwl-fab';
    btn.title = 'Later (Alt+L)';
    btn.textContent = 'Later';
    btn.addEventListener('click', openPanel);
    document.body.appendChild(btn);
  }

  document.addEventListener('keydown', e => {
    if (e.altKey && e.key === 'l') openPanel();
  });

  // ── MutationObserver + URL change detection ───────────────────────────────

  let lastUrl = location.href;

  function onPageChange() {
    injectFab();
    injectLaterBtn();
  }

  const observer = new MutationObserver(() => {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      setTimeout(onPageChange, 300);
    } else {
      if (!document.getElementById("gwl-later-btn")) injectLaterBtn();
      if (!bgnLoaded && !document.getElementById("gwl-fab")) injectFab();
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });

  onPageChange();
})();