GitHub Release Dashboard

Adds a polished release/download dashboard to GitHub repository and releases pages.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         GitHub Release Dashboard
// @description  Adds a polished release/download dashboard to GitHub repository and releases pages.
// @version      1.0.0
// @namespace    lafa2k
// @author       L2K
// @license      MIT 
// @match        https://github.com/*/*
// @match        https://github.com/*/*/releases*
// @grant        none
// ==/UserScript==

(() => {
    'use strict';

    if (window.__l2kGithubReleaseDashboardLoaded) {
        return;
    }
    window.__l2kGithubReleaseDashboardLoaded = true;

    const SCRIPT_ID = 'l2k-github-release-dashboard';
    const STYLE_ID = `${SCRIPT_ID}-style`;
    const CACHE_TTL_MS = 5 * 60 * 1000;
    const MAX_RELEASE_PAGES = 10;

    let currentRepoKey = null;
    let bootToken = 0;

    function parseRepoContext() {
        const match = location.pathname.match(/^\/([^/]+)\/([^/]+)(\/.*)?$/);
        if (!match) return null;

        const owner = match[1];
        const repo = match[2];
        const tail = match[3] || '';
        if (!owner || !repo) return null;

        return {
            owner,
            repo,
            fullName: `${owner}/${repo}`,
            isRepoHome: tail === '' || tail === '/',
            isReleasesPage: tail === '/releases' || tail.startsWith('/releases/')
        };
    }

    function injectStyles() {
        if (document.getElementById(STYLE_ID)) return;

        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = `
            @keyframes l2k-rd-slow-pan {
                0% {
                    background-position: 0% 0%, 100% 100%, 0% 50%;
                }
                50% {
                    background-position: 8% 4%, 92% 96%, 100% 50%;
                }
                100% {
                    background-position: 0% 0%, 100% 100%, 0% 50%;
                }
            }

            .l2k-rd-card {
                position: relative;
                overflow: hidden;
                border: 1px solid rgba(86, 187, 255, 0.22);
                border-radius: 18px;
                padding: 16px;
                background:
                    radial-gradient(circle at top left, rgba(34, 197, 94, 0.18), transparent 30%),
                    radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.2), transparent 36%),
                    linear-gradient(145deg, rgba(11, 18, 32, 0.98), rgba(15, 23, 42, 0.98));
                background-size: 120% 120%, 120% 120%, 200% 200%;
                box-shadow:
                    0 14px 30px rgba(0, 0, 0, 0.20),
                    inset 0 1px 0 rgba(255, 255, 255, 0.05);
                color: #e5eefc;
                margin-bottom: 16px;
                backdrop-filter: blur(8px);
                animation: l2k-rd-slow-pan 18s ease-in-out infinite;
            }

            .l2k-rd-card::before {
                content: '';
                position: absolute;
                inset: 0;
                pointer-events: none;
                background:
                    linear-gradient(120deg, transparent 0%, rgba(255,255,255,0.04) 20%, transparent 40%);
            }

            .l2k-rd-title {
                font-size: 14px;
                font-weight: 800;
                letter-spacing: 0.03em;
                color: #f8fbff;
                margin-bottom: 4px;
            }

            .l2k-rd-subtitle {
                font-size: 12px;
                color: #93c5fd;
                margin-bottom: 14px;
            }

            .l2k-rd-badge-row {
                display: flex;
                flex-wrap: wrap;
                gap: 8px;
                margin-bottom: 14px;
            }

            .l2k-rd-badge {
                display: inline-flex;
                align-items: center;
                justify-content: center;
                padding: 7px 12px;
                border-radius: 999px;
                font-size: 12px;
                font-weight: 700;
                line-height: 1;
                white-space: nowrap;
                color: #d9fff2;
                border: 1px solid rgba(110, 231, 183, 0.24);
                background: rgba(16, 185, 129, 0.12);
            }

            .l2k-rd-section {
                margin-top: 12px;
            }

            .l2k-rd-section-title {
                font-size: 11px;
                text-transform: uppercase;
                letter-spacing: 0.08em;
                color: #9fb4d4;
                margin-bottom: 8px;
            }

            .l2k-rd-release-row {
                display: grid;
                grid-template-columns: repeat(2, minmax(0, 1fr));
                gap: 10px;
                margin-top: 4px;
                margin-bottom: 12px;
            }

            .l2k-rd-release-panel {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 12px;
                padding: 12px;
                border-radius: 14px;
                border: 1px solid rgba(148, 163, 184, 0.14);
                background: rgba(255, 255, 255, 0.04);
            }

            .l2k-rd-release-body {
                min-width: 0;
                flex: 1 1 auto;
            }

            .l2k-rd-release-label {
                font-size: 11px;
                text-transform: uppercase;
                letter-spacing: 0.08em;
                color: #9fb4d4;
                margin-bottom: 6px;
            }

            .l2k-rd-release-name {
                font-size: 13px;
                font-weight: 700;
                color: #f8fbff;
                margin-bottom: 4px;
                overflow: hidden;
                text-overflow: ellipsis;
                white-space: nowrap;
            }

            .l2k-rd-release-name a {
                color: #f8fbff;
                text-decoration: none;
            }

            .l2k-rd-release-name a:hover {
                text-decoration: underline;
            }

            .l2k-rd-release-meta {
                font-size: 12px;
                color: #9ec5ff;
            }

            .l2k-rd-list {
                display: flex;
                flex-direction: column;
                gap: 8px;
            }

            .l2k-rd-list-item {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 10px;
                padding: 9px 10px;
                border-radius: 12px;
                background: rgba(255, 255, 255, 0.04);
                border: 1px solid rgba(148, 163, 184, 0.12);
            }

            .l2k-rd-list-item a {
                color: #dbeafe;
                text-decoration: none;
                font-size: 12px;
                font-weight: 600;
                overflow: hidden;
                text-overflow: ellipsis;
                white-space: nowrap;
            }

            .l2k-rd-list-item a:hover {
                text-decoration: underline;
            }

            .l2k-rd-count {
                font-size: 12px;
                color: #86efac;
                font-weight: 700;
                white-space: nowrap;
            }

            .l2k-rd-footer {
                display: flex;
                flex-wrap: wrap;
                align-items: center;
                justify-content: flex-start;
                gap: 12px;
                margin-top: 14px;
                font-size: 12px;
            }

            .l2k-rd-link {
                color: #86efac !important;
                text-decoration: none !important;
                font-weight: 700;
            }

            .l2k-rd-link:hover {
                text-decoration: underline !important;
            }

            .l2k-rd-muted {
                color: #a9bbd5;
            }

            .l2k-rd-asset-badge {
                display: inline-flex;
                margin-left: 8px;
                padding: 2px 8px;
                border-radius: 999px;
                font-size: 11px;
                font-weight: 700;
                color: #d1fae5;
                border: 1px solid rgba(110, 231, 183, 0.24);
                background: rgba(16, 185, 129, 0.12);
                vertical-align: middle;
            }

            .l2k-rd-inline-summary {
                margin-top: 16px;
            }

            .l2k-rd-error {
                color: #fecaca;
                border-color: rgba(248, 113, 113, 0.25);
                background:
                    radial-gradient(circle at top left, rgba(239, 68, 68, 0.18), transparent 30%),
                    linear-gradient(145deg, rgba(30, 10, 16, 0.98), rgba(42, 15, 23, 0.98));
            }

            @media (max-width: 768px) {
                .l2k-rd-release-row {
                    grid-template-columns: 1fr;
                }
            }

        `;
        document.head.appendChild(style);
    }

    function formatNumber(value) {
        return Number(value || 0).toLocaleString();
    }

    function formatDate(value) {
        if (!value) return 'Unknown';
        const date = new Date(value);
        return date.toLocaleString(undefined, {
            year: 'numeric',
            month: 'short',
            day: 'numeric'
        });
    }

    function getCacheKey(repoFull) {
        return `${SCRIPT_ID}:${repoFull}`;
    }

    function loadCache(repoFull) {
        try {
            const raw = sessionStorage.getItem(getCacheKey(repoFull));
            if (!raw) return null;
            const parsed = JSON.parse(raw);
            if (!parsed || (Date.now() - parsed.cachedAt) > CACHE_TTL_MS) {
                sessionStorage.removeItem(getCacheKey(repoFull));
                return null;
            }
            return parsed.payload;
        } catch {
            return null;
        }
    }

    function saveCache(repoFull, payload) {
        try {
            sessionStorage.setItem(getCacheKey(repoFull), JSON.stringify({
                cachedAt: Date.now(),
                payload
            }));
        } catch {
        }
    }

    async function fetchJson(url) {
        const res = await fetch(url, {
            headers: {
                'Accept': 'application/vnd.github+json'
            }
        });
        if (!res.ok) {
            throw new Error(`GitHub API error ${res.status}`);
        }
        return res.json();
    }

    async function fetchRepoPayload(repoFull) {
        const cached = loadCache(repoFull);
        if (cached) return cached;

        const [repoInfo, releases] = await Promise.all([
            fetchJson(`https://api.github.com/repos/${repoFull}`),
            fetchAllReleases(repoFull)
        ]);

        const payload = { repoInfo, releases };
        saveCache(repoFull, payload);
        return payload;
    }

    async function fetchAllReleases(repoFull) {
        const releases = [];

        for (let page = 1; page <= MAX_RELEASE_PAGES; page++) {
            const url = `https://api.github.com/repos/${repoFull}/releases?per_page=100&page=${page}`;
            const batch = await fetchJson(url);
            if (!Array.isArray(batch) || batch.length === 0) break;
            releases.push(...batch);
            if (batch.length < 100) break;
        }

        return releases;
    }

    function computeStats(repoInfo, releases) {
        let totalDownloads = 0;
        let totalAssets = 0;
        const topAssets = [];
        const releaseSummaries = [];

        for (const release of releases) {
            const assets = Array.isArray(release.assets) ? release.assets : [];
            let releaseDownloads = 0;

            for (const asset of assets) {
                const count = Number(asset.download_count || 0);
                releaseDownloads += count;
                totalDownloads += count;
                totalAssets += 1;

                topAssets.push({
                    name: asset.name,
                    count,
                    url: asset.browser_download_url,
                    releaseName: release.name || release.tag_name || 'Release'
                });
            }

            releaseSummaries.push({
                id: release.id,
                name: release.name || release.tag_name || 'Untitled release',
                tag: release.tag_name || '',
                url: release.html_url,
                assetsCount: assets.length,
                downloads: releaseDownloads,
                publishedAt: release.published_at || release.created_at
            });
        }

        topAssets.sort((a, b) => b.count - a.count);
        releaseSummaries.sort((a, b) => b.downloads - a.downloads);

        const latestRelease = releases[0] || null;
        const firstRelease = releases.length > 0 ? releases[releases.length - 1] : null;
        const latestReleaseDownloads = latestRelease
            ? releaseSummaries.find(r => r.id === latestRelease.id)?.downloads || 0
            : 0;
        const firstReleaseDownloads = firstRelease
            ? releaseSummaries.find(r => r.id === firstRelease.id)?.downloads || 0
            : 0;

        return {
            repoInfo,
            releases,
            totalDownloads,
            totalAssets,
            latestRelease,
            latestReleaseDownloads,
            firstRelease,
            firstReleaseDownloads,
            topAssets: topAssets.slice(0, 5),
            topReleases: releaseSummaries.slice(0, 5),
            releaseCount: releases.length
        };
    }

    function createEl(tag, className, text) {
        const el = document.createElement(tag);
        if (className) el.className = className;
        if (typeof text === 'string') el.textContent = text;
        return el;
    }

    function makeBadge(text) {
        return createEl('span', 'l2k-rd-badge', text);
    }

    function makeListItem(label, count, url) {
        const item = createEl('div', 'l2k-rd-list-item');
        const link = createEl('a');
        link.href = url;
        link.textContent = label;
        link.target = '_blank';
        link.rel = 'noopener noreferrer';
        item.appendChild(link);
        item.appendChild(createEl('div', 'l2k-rd-count', `${formatNumber(count)} downloads`));
        return item;
    }

    function makeReleasePanel(label, release, downloads) {
        const panel = createEl('div', 'l2k-rd-release-panel');
        const body = createEl('div', 'l2k-rd-release-body');
        body.appendChild(createEl('div', 'l2k-rd-release-label', label));

        const name = createEl('div', 'l2k-rd-release-name');
        if (release?.html_url) {
            const link = createEl('a');
            link.href = release.html_url;
            link.textContent = release.name || release.tag_name || 'Untitled release';
            link.target = '_blank';
            link.rel = 'noopener noreferrer';
            name.appendChild(link);
        } else {
            name.textContent = 'No release';
        }
        body.appendChild(name);

        const parts = [];
        if (release?.published_at || release?.created_at) {
            parts.push(formatDate(release.published_at || release.created_at));
        }
        if (release?.assets?.length) {
            parts.push(`${formatNumber(release.assets.length)} assets`);
        }

        body.appendChild(createEl('div', 'l2k-rd-release-meta', parts.join('  •  ')));
        panel.appendChild(body);
        panel.appendChild(createEl('div', 'l2k-rd-count', `${formatNumber(downloads)} downloads`));
        return panel;
    }

    function buildDashboardCard(stats, repoFull) {
        const card = createEl('section', 'l2k-rd-card');

        card.appendChild(createEl('div', 'l2k-rd-title', 'Release Dashboard'));
        card.appendChild(createEl('div', 'l2k-rd-subtitle', repoFull));

        const badges = createEl('div', 'l2k-rd-badge-row');
        badges.appendChild(makeBadge(`Total Downloads: ${formatNumber(stats.totalDownloads)}`));
        badges.appendChild(makeBadge(`Releases: ${formatNumber(stats.releaseCount)}`));
        badges.appendChild(makeBadge(`Assets: ${formatNumber(stats.totalAssets)}`));
        badges.appendChild(makeBadge(`Stars: ${formatNumber(stats.repoInfo.stargazers_count)}`));
        badges.appendChild(makeBadge(`Forks: ${formatNumber(stats.repoInfo.forks_count)}`));
        badges.appendChild(makeBadge(`Issues: ${formatNumber(stats.repoInfo.open_issues_count)}`));
        card.appendChild(badges);

        const releaseRow = createEl('div', 'l2k-rd-release-row');
        releaseRow.appendChild(makeReleasePanel('Last Release', stats.latestRelease, stats.latestReleaseDownloads));
        releaseRow.appendChild(makeReleasePanel('First Release', stats.firstRelease, stats.firstReleaseDownloads));
        card.appendChild(releaseRow);

        const footer = createEl('div', 'l2k-rd-footer');

        const latestLink = createEl('a', 'l2k-rd-link', 'Open latest release');
        latestLink.href = `https://github.com/${repoFull}/releases/latest`;
        latestLink.target = '_blank';
        latestLink.rel = 'noopener noreferrer';
        footer.appendChild(latestLink);

        const pushed = createEl('span', 'l2k-rd-muted', `Last push: ${formatDate(stats.repoInfo.pushed_at)}`);
        footer.appendChild(pushed);

        card.appendChild(footer);
        return card;
    }

    function buildReleasesSummaryCard(stats, repoFull) {
        return buildDashboardCard(stats, repoFull);
    }

    function mountOnRepoHome(stats, repoFull) {
        const host =
            document.querySelector('main .Layout-sidebar .BorderGrid') ||
            document.querySelector('main .Layout-sidebar') ||
            document.querySelector('main');
        if (!host) return false;

        if (document.getElementById(`${SCRIPT_ID}-repo-home`)) return true;

        const card = buildDashboardCard(stats, repoFull);
        card.id = `${SCRIPT_ID}-repo-home`;

        if (host.classList.contains('BorderGrid')) {
            const row = document.createElement('div');
            row.className = 'BorderGrid-row';
            const cell = document.createElement('div');
            cell.className = 'BorderGrid-cell';
            cell.appendChild(card);
            row.appendChild(cell);
            host.prepend(row);
        } else {
            host.prepend(card);
        }

        return true;
    }

    function annotateReleaseAssets(stats) {
        const assetCounts = new Map();
        for (const release of stats.releases) {
            for (const asset of release.assets || []) {
                assetCounts.set(asset.name, Number(asset.download_count || 0));
            }
        }

        const links = document.querySelectorAll('a[href*="/releases/download/"]');
        for (const link of links) {
            const fileName = link.textContent.trim();
            if (!fileName || link.dataset.l2kAssetAnnotated === '1') continue;
            const count = assetCounts.get(fileName);
            if (count === undefined) continue;

            const badge = createEl('span', 'l2k-rd-asset-badge', `${formatNumber(count)} downloads`);
            link.after(badge);
            link.dataset.l2kAssetAnnotated = '1';
        }
    }

    function mountOnReleasesPage(stats, repoFull) {
        const host =
            document.querySelector('main .container-xl') ||
            document.querySelector('main [data-testid="release-list"]') ||
            document.querySelector('main');
        if (!host) return false;

        if (!document.getElementById(`${SCRIPT_ID}-releases-page`)) {
            const wrapper = createEl('div', 'l2k-rd-inline-summary');
            wrapper.id = `${SCRIPT_ID}-releases-page`;
            wrapper.appendChild(buildReleasesSummaryCard(stats, repoFull));

            const anchor =
                host.querySelector('[data-testid="release-list"]') ||
                host.firstElementChild ||
                host;

            if (anchor && anchor.parentElement) {
                anchor.parentElement.insertBefore(wrapper, anchor);
            } else {
                host.prepend(wrapper);
            }
        }

        annotateReleaseAssets(stats);
        return true;
    }

    function mountError(message, context) {
        const existing = document.getElementById(`${SCRIPT_ID}-error`);
        if (existing) return;

        const card = createEl('section', 'l2k-rd-card l2k-rd-error');
        card.id = `${SCRIPT_ID}-error`;
        card.appendChild(createEl('div', 'l2k-rd-title', 'Release Dashboard'));
        card.appendChild(createEl('div', 'l2k-rd-subtitle', context.fullName));
        card.appendChild(createEl('div', 'l2k-rd-muted', message));

        const host = document.querySelector('main .Layout-sidebar') || document.querySelector('main');
        if (host) host.prepend(card);
    }

    function clearMountedUi() {
        const ids = [
            `${SCRIPT_ID}-repo-home`,
            `${SCRIPT_ID}-releases-page`,
            `${SCRIPT_ID}-error`
        ];

        for (const id of ids) {
            const node = document.getElementById(id);
            if (node) {
                node.remove();
            }
        }
    }

    async function boot() {
        const context = parseRepoContext();
        if (!context || (!context.isRepoHome && !context.isReleasesPage)) return;

        injectStyles();
        clearMountedUi();

        const thisBoot = ++bootToken;
        currentRepoKey = context.fullName;

        try {
            const payload = await fetchRepoPayload(context.fullName);
            if (thisBoot !== bootToken || currentRepoKey !== context.fullName) return;

            const stats = computeStats(payload.repoInfo, payload.releases);
            if (context.isRepoHome) mountOnRepoHome(stats, context.fullName);
            if (context.isReleasesPage) mountOnReleasesPage(stats, context.fullName);
        } catch (error) {
            if (thisBoot !== bootToken) return;
            mountError(`Unable to load release stats right now. ${error.message || error}`, context);
            console.error(`${SCRIPT_ID}:`, error);
        }
    }

    function scheduleBoot() {
        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                boot();
            });
        });
    }

    window.addEventListener('turbo:load', scheduleBoot, { passive: true });
    window.addEventListener('turbo:render', scheduleBoot, { passive: true });
    scheduleBoot();
})();