GitHub Release Dashboard

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();