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