diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 0146290..58f5494 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -5,15 +5,16 @@ const BUDGET_BYTES: Record = { // Raised from 103 kB for the interface themes: NieR: Automata and Liquid // Glass each ship their own token block, and the liquid-glass material // layer (SVG displacement filters plus their component rules) adds the - // largest share. The measured bundle is 153.7 kB; 160 kB keeps the - // guardrail close while leaving modest headroom. - ".css": 160_000, + // largest share. The measured bundle is 153.7 kB; 175 kB adds headroom for + // the Developer Hall of Fame page (~15 kB of animated card and hero + // styles that load only on /contributors.html). + ".css": 175_000, // Raised from 510 kB for Bridge discovery, profile editing, automatic // reconnection, and recent device support, which have since grown further // with the supported-device page and MX Master remap controls. Preview // fixtures retain their separate allowance below; the measured aggregate - // is 573.4 kB with them. - ".js": 565_000, + // is 573.4 kB with them, plus the ~11 kB Hall of Fame chunk. + ".js": 590_000, }; const ASSETS = join("dist", "assets"); diff --git a/contributors.html b/contributors.html new file mode 100644 index 0000000..9db7f8e --- /dev/null +++ b/contributors.html @@ -0,0 +1,18 @@ + + + + + + + Developer Hall of Fame — OpenMouse + + + + + + + +
+ + + diff --git a/src/app/Sidebar.tsx b/src/app/Sidebar.tsx index ec1f9e1..3eff12a 100644 --- a/src/app/Sidebar.tsx +++ b/src/app/Sidebar.tsx @@ -52,6 +52,14 @@ const SETTINGS_PATH = ( const REQUEST_PATH = ; +const FAME_PATH = ( + <> + + + + +); + const DEBUG_PATH = ( <> @@ -188,6 +196,10 @@ export function Sidebar({ snapshot, onOpenSupportRequests }: { snapshot: Control Request a mouse + + + Hall of Fame + diff --git a/src/contributors.css b/src/contributors.css new file mode 100644 index 0000000..c20740f --- /dev/null +++ b/src/contributors.css @@ -0,0 +1,613 @@ +/* ── Developer Hall of Fame ─────────────────────────────────────────────── */ + +:root { + color-scheme: dark; + font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #f1f1f3; + background: #09090b; + --hof-bg: #09090b; + --hof-text: #f1f1f3; + --hof-muted: #a1a1a6; + --hof-faint: #6b6b70; + --hof-card: #111113; + --hof-card-soft: #161618; + --hof-border: #242427; + --hof-raise: #1a1a1e; + --hof-accent: #69d28d; + --hof-accent-2: #4ade80; + --hof-accent-3: #22c55e; + --hof-gold: #f5c65d; + --hof-silver: #94a3b8; + --hof-bronze: #cd7f32; + --hof-shadow: 0 24px 70px -28px rgba(0, 0, 0, 0.9); + --hof-glow: rgba(105, 210, 141, 0.45); + --hof-ring: rgba(105, 210, 141, 0.4); +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + min-width: 320px; + background: var(--hof-bg); + color: var(--hof-text); + -webkit-font-smoothing: antialiased; +} +button { font: inherit; cursor: pointer; color: inherit; background: none; border: none; } +a { color: inherit; text-decoration: none; } +input, select { font: inherit; color: inherit; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { + outline: 2px solid var(--hof-accent); + outline-offset: 3px; + border-radius: 4px; +} + +.hof-shell { position: relative; min-height: 100vh; overflow: hidden; } + +/* ── Interactive dot grid background ────────────────────────────────────── */ +.hof-bg { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; + background: var(--hof-bg); +} +.hof-dotgrid { + position: absolute; + inset: 0; + display: block; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ +.hof-header { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 1.25rem; + padding: 0.75rem clamp(1rem, 4vw, 3rem); + background: color-mix(in srgb, var(--hof-bg) 70%, transparent); + backdrop-filter: blur(16px) saturate(1.4); + -webkit-backdrop-filter: blur(16px) saturate(1.4); + border-bottom: 1px solid var(--hof-border); +} +.hof-wordmark { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 800; + font-size: 1.05rem; + letter-spacing: -0.02em; + color: var(--hof-text); +} +.hof-logo { + width: auto; + height: 1.7rem; + object-fit: contain; + filter: drop-shadow(0 2px 8px var(--hof-glow)); +} +.hof-nav { + display: flex; + gap: 0.25rem; + margin-left: auto; +} +.hof-nav a { + padding: 0.42rem 0.85rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 500; + color: var(--hof-muted); + transition: color 0.15s, background 0.15s; +} +.hof-nav a:hover { color: var(--hof-text); background: var(--hof-raise); } +.hof-nav a.is-current { + color: var(--hof-accent); + background: color-mix(in srgb, var(--hof-accent) 12%, transparent); +} +.hof-actions { display: flex; align-items: center; gap: 0.6rem; } +.hof-github { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.9rem; + border: 1px solid var(--hof-border); + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; + color: var(--hof-text); + background: var(--hof-card); + transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; +} +.hof-github svg { opacity: 0.8; } +.hof-stars { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.74rem; + font-variant-numeric: tabular-nums; + color: var(--hof-gold); + background: color-mix(in srgb, var(--hof-gold) 16%, transparent); +} +.hof-stars svg { color: var(--hof-gold); } +.hof-github:hover { + border-color: var(--hof-accent); + box-shadow: 0 6px 24px -10px var(--hof-glow); + transform: translateY(-1px); +} + +/* ── Hero ───────────────────────────────────────────────────────────────── */ +main { position: relative; z-index: 1; } +.hof-hero { + max-width: 900px; + margin: 0 auto; + padding: clamp(3rem, 8vw, 6rem) 1.5rem 2rem; + text-align: center; +} +.hof-eyebrow { + font-family: "JetBrains Mono", monospace; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.18em; + color: var(--hof-accent); + margin: 0 0 1.1rem; + animation: hof-rise 0.7s ease both; +} +.hof-hero h1 { + margin: 0 0 1.1rem; + font-size: clamp(2.8rem, 8vw, 5rem); + font-weight: 800; + line-height: 0.98; + letter-spacing: -0.04em; + animation: hof-rise 0.7s ease 0.08s both; +} +.hof-hero h1 em { + font-style: normal; + background: linear-gradient(100deg, var(--hof-accent), var(--hof-accent-2) 50%, var(--hof-accent-3)); + background-size: 200% 200%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: hof-gradient 6s ease infinite; +} +.hof-lead { + margin: 0 auto 2rem; + max-width: 560px; + font-size: 1.02rem; + line-height: 1.6; + color: var(--hof-muted); + animation: hof-rise 0.7s ease 0.16s both; +} +.hof-lead strong { color: var(--hof-text); font-weight: 600; } + +.hof-stats { + display: flex; + justify-content: center; + gap: clamp(1rem, 4vw, 2.5rem); + flex-wrap: wrap; + animation: hof-rise 0.7s ease 0.24s both; +} +.hof-stat { display: flex; flex-direction: column; align-items: center; } +.hof-stat strong { + font-size: clamp(1.9rem, 5vw, 2.7rem); + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(100deg, var(--hof-text), var(--hof-accent)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} +.hof-stat small { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--hof-faint); +} + +.hof-live { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 1.6rem; + padding: 0.45rem 0.9rem; + border: 1px solid var(--hof-border); + border-radius: 999px; + background: var(--hof-card); + font-size: 0.8rem; + font-weight: 600; + color: var(--hof-muted); + animation: hof-rise 0.7s ease 0.32s both; +} +.live-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); + animation: hof-pulse 2s ease infinite; +} +.live-time { font-weight: 500; color: var(--hof-faint); } +.hof-refresh { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-left: 0.35rem; + padding: 0.3rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--hof-border); + background: var(--hof-raise); + font-size: 0.78rem; + font-weight: 600; + transition: border-color 0.15s, transform 0.15s; +} +.hof-refresh:hover:not(:disabled) { border-color: var(--hof-accent); transform: translateY(-1px); } +.hof-refresh:disabled { opacity: 0.55; cursor: default; } +.refresh-icon { display: inline-block; } +.refresh-icon.is-spinning { animation: hof-spin 0.8s linear infinite; } + +.hof-error { + margin: 1rem auto 0; + max-width: 520px; + padding: 0.55rem 0.9rem; + border: 1px solid color-mix(in srgb, #ef4444 35%, transparent); + border-radius: 10px; + background: color-mix(in srgb, #ef4444 9%, transparent); + color: #ef4444; + font-size: 0.8rem; + animation: hof-rise 0.5s ease both; +} + +/* ── Repo cards ─────────────────────────────────────────────────────────── */ +.hof-repos { + max-width: 1080px; + margin: 0 auto; + padding: 0 1.5rem 2.5rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} +.repo-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.7rem; + padding: 1.2rem 1.3rem; + border: 1px solid var(--hof-border); + border-radius: 16px; + background: linear-gradient(180deg, color-mix(in srgb, var(--hof-card) 96%, var(--hof-accent) 4%), var(--hof-card)); + box-shadow: var(--hof-shadow); + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; +} +.repo-card::before { + content: ""; + position: absolute; + top: 0; left: 1.2rem; right: 1.2rem; + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, transparent, var(--hof-accent), var(--hof-accent-2), transparent); + opacity: 0.35; + transition: opacity 0.25s ease; +} +.repo-card:hover::before { opacity: 1; } +.repo-card:hover { + transform: translateY(-4px); + border-color: var(--hof-accent); + box-shadow: 0 26px 60px -22px var(--hof-glow); +} +.repo-card-top { display: flex; align-items: center; gap: 0.55rem; } +.repo-icon { + display: grid; + place-items: center; + width: 2rem; height: 2rem; + border-radius: 10px; + background: linear-gradient(135deg, var(--hof-accent), var(--hof-accent-2) 60%, var(--hof-accent-3)); + color: #fff; + box-shadow: 0 6px 16px -6px var(--hof-glow); +} +.repo-name { font-weight: 700; font-size: 0.98rem; letter-spacing: -0.01em; } +.repo-arrow { margin-left: auto; color: var(--hof-faint); font-size: 0.85rem; transition: transform 0.2s; } +.repo-card:hover .repo-arrow { transform: translate(2px, -2px); color: var(--hof-accent); } +.repo-desc { + margin: 0; + font-size: 0.82rem; + line-height: 1.5; + color: var(--hof-muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.repo-stats { display: flex; gap: 1rem; font-size: 0.78rem; color: var(--hof-faint); } +.repo-stats strong { color: var(--hof-text); } +.repo-branches { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.repo-branch { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.22rem 0.55rem; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + background: var(--hof-raise); + color: var(--hof-muted); +} +.branch-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--hof-accent); } +.repo-foot { + display: flex; + justify-content: space-between; + padding-top: 0.6rem; + border-top: 1px solid var(--hof-border); + font-size: 0.76rem; + color: var(--hof-faint); +} +.repo-foot strong { color: var(--hof-accent); } +.repo-foot span:last-child { color: var(--hof-accent); font-weight: 600; } + +/* ── Controls ───────────────────────────────────────────────────────────── */ +.hof-controls { + max-width: 1080px; + margin: 0 auto; + padding: 0 1.5rem 1.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} +.hof-search { + flex: 1 1 200px; + min-width: 0; + padding: 0.55rem 0.95rem; + border: 1.5px solid var(--hof-border); + border-radius: 12px; + background: var(--hof-card); + font-size: 0.88rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.hof-search:focus { border-color: var(--hof-accent); box-shadow: 0 0 0 3px var(--hof-ring); outline: none; } +.hof-filters { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.hof-filter { + padding: 0.45rem 0.85rem; + border: 1.5px solid var(--hof-border); + border-radius: 999px; + background: transparent; + font-size: 0.8rem; + font-weight: 600; + color: var(--hof-muted); + transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s; +} +.hof-filter:hover { color: var(--hof-text); border-color: var(--hof-accent); transform: translateY(-1px); } +.hof-filter.is-on { + background: linear-gradient(120deg, var(--hof-accent), var(--hof-accent-2)); + color: #fff; + border-color: transparent; + box-shadow: 0 6px 18px -8px var(--hof-glow); +} +.hof-sort { + margin-left: auto; + padding: 0.5rem 0.8rem; + border: 1.5px solid var(--hof-border); + border-radius: 12px; + background: var(--hof-card); + font-size: 0.82rem; + font-weight: 600; +} + +/* ── Results / grid ─────────────────────────────────────────────────────── */ +.hof-results { max-width: 1080px; margin: 0 auto; padding: 0 1.5rem 4rem; } +.hof-result-head { + font-family: "JetBrains Mono", monospace; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.14em; + color: var(--hof-faint); + margin-bottom: 1rem; +} +.hof-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); + gap: 1rem; +} +.hof-grid.is-revealed .contributor-card { animation: hof-card-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; animation-delay: var(--d, 0ms); } +.hof-empty { + grid-column: 1 / -1; + text-align: center; + color: var(--hof-faint); + padding: 3rem 0; + font-size: 0.9rem; +} + +/* ── Contributor card ───────────────────────────────────────────────────── */ +.contributor-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.55rem; + padding: 1.6rem 1.1rem 1.2rem; + border: 1px solid var(--hof-border); + border-radius: 18px; + background: var(--hof-card); + box-shadow: var(--hof-shadow); + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.28s ease, border-color 0.28s ease; +} +.contributor-card:hover { + transform: translateY(-6px) scale(1.02); + border-color: var(--hof-accent); + box-shadow: 0 30px 70px -24px var(--hof-glow); +} +.medal { + position: absolute; + top: 0.7rem; + left: 0.8rem; + font-size: 1.3rem; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35)); + animation: hof-bounce 3.2s ease-in-out infinite; +} +.avatar-wrap { position: relative; margin-bottom: 0.2rem; } +.avatar-wrap img { + width: 88px; height: 88px; + border-radius: 50%; + display: block; + object-fit: cover; + background: var(--hof-raise); + border: 2px solid var(--hof-border); + transition: transform 0.3s ease, border-color 0.3s ease; +} +.avatar-fallback { + width: 88px; height: 88px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 2rem; + font-weight: 800; + background: linear-gradient(135deg, var(--hof-accent), var(--hof-accent-2)); + color: #fff; + border: 2px solid var(--hof-border); +} +.contributor-card:hover .avatar-wrap img { transform: scale(1.08); border-color: var(--hof-accent); } +.avatar-ring { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid transparent; + pointer-events: none; +} +.contributor-card.is-ranked .avatar-ring { animation: hof-spin-ring 4s linear infinite; border-top-color: var(--hof-ring); } +.contributor-card.rank-1 .avatar-ring { border-color: var(--hof-gold); } +.contributor-card.rank-2 .avatar-ring { border-color: var(--hof-silver); } +.contributor-card.rank-3 .avatar-ring { border-color: var(--hof-bronze); } +.contributor-card.rank-1 { + border-color: var(--hof-gold); + box-shadow: 0 22px 60px -18px rgba(245, 198, 93, 0.4); +} +.contributor-card.rank-1:hover { box-shadow: 0 30px 70px -20px rgba(245, 198, 93, 0.55); } +.contributor-card.rank-2 { + border-color: color-mix(in srgb, var(--hof-silver) 65%, var(--hof-border)); + box-shadow: 0 22px 60px -18px rgba(148, 163, 184, 0.32); +} +.contributor-card.rank-3 { + border-color: color-mix(in srgb, var(--hof-bronze) 70%, var(--hof-border)); + box-shadow: 0 22px 60px -18px rgba(205, 127, 50, 0.35); +} +.contributor-login { + font-weight: 700; + font-size: 0.98rem; + letter-spacing: -0.01em; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--hof-text); + transition: color 0.15s; +} +.contributor-login:hover { color: var(--hof-accent); } +.contributor-total { font-size: 0.78rem; font-weight: 600; color: var(--hof-faint); } +.contributor-total em { font-style: normal; color: var(--hof-faint); } +.contributor-repos { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.35rem; } +.repo-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.28rem 0.6rem; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + background: var(--hof-raise); + color: var(--hof-muted); + transition: transform 0.15s; +} +.repo-chip:hover { transform: translateY(-1px); } +.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--hof-accent); } +.chip-mouse-protocol .chip-dot { background: var(--hof-accent-2); } +.chip-openmouse-bridge .chip-dot { background: var(--hof-accent-3); } +.contributor-link { + margin-top: 0.35rem; + font-size: 0.74rem; + font-weight: 600; + color: var(--hof-accent); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.2s, transform 0.2s; +} +.contributor-card:hover .contributor-link { opacity: 1; transform: translateY(0); } + +/* ── Reveal helper (non-grid elements) ──────────────────────────────────── */ +.hof-reveal { opacity: 0; transform: translateY(24px); transition: opacity 0.7s ease var(--d, 0ms), transform 0.7s ease var(--d, 0ms); } +.hof-reveal.is-in { opacity: 1; transform: translateY(0); } + +/* ── Skeletons ──────────────────────────────────────────────────────────── */ +.skeleton { + border-radius: 16px; + background: linear-gradient(100deg, var(--hof-card) 30%, var(--hof-raise) 50%, var(--hof-card) 70%); + background-size: 220% 100%; + animation: hof-sheen 1.4s ease infinite; +} +.repo-skeleton { height: 150px; } + +/* ── Footer ─────────────────────────────────────────────────────────────── */ +.hof-footer { + position: relative; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: space-between; + padding: 1.5rem clamp(1rem, 4vw, 3rem); + border-top: 1px solid var(--hof-border); + font-size: 0.8rem; + color: var(--hof-faint); +} +.hof-footer-links { display: flex; gap: 1.25rem; flex-wrap: wrap; } +.hof-footer-links a:hover { color: var(--hof-accent); } + +/* ── Keyframes ──────────────────────────────────────────────────────────── */ +@keyframes hof-rise { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes hof-card-in { + from { opacity: 0; transform: translateY(26px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes hof-gradient { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} +@keyframes hof-pulse { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.6); } + 70% { box-shadow: 0 0 0 9px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} +@keyframes hof-spin { to { transform: rotate(360deg); } } +@keyframes hof-spin-ring { to { transform: rotate(360deg); } } +@keyframes hof-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-5px); } +} +@keyframes hof-sheen { + 0% { background-position: 0% 50%; } + 100% { background-position: -120% 50%; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + .hof-reveal, .hof-grid.is-revealed .contributor-card { opacity: 1; transform: none; } +} + +@media (max-width: 640px) { + .hof-nav a { font-size: 0.78rem; padding: 0.35rem 0.55rem; } + .hof-github-label { display: none; } + .hof-github { padding: 0.4rem 0.7rem; } + .hof-sort { margin-left: 0; } +} diff --git a/src/contributors.tsx b/src/contributors.tsx new file mode 100644 index 0000000..ad0d46a --- /dev/null +++ b/src/contributors.tsx @@ -0,0 +1,765 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import "./contributors.css"; + +const ORG = "OpenMouse-Project"; + +type RepoKey = string; + +interface RepoInfo { + key: RepoKey; + label: string; + fullName: string; +} + +interface BranchSource { + key: RepoKey; + repo: string; + branch: string; + label: string; +} + +interface RepoMeta { + description: string; + stars: number; + forks: number; + issues: number; + htmlUrl: string; +} + +interface BranchAuthor { + login: string; + avatar: string | null; + htmlUrl: string | null; + count: number; +} + +interface BranchData { + key: RepoKey; + repo: string; + branch: string; + label: string; + commits: number; + authors: BranchAuthor[]; +} + +interface MergedContributor { + login: string; + avatar: string | null; + htmlUrl: string | null; + total: number; + repos: Partial>; +} + +interface CachedData { + fetchedAt: number; + branches: BranchData[]; + repos: Partial>; + repoList: RepoInfo[]; + merged: MergedContributor[]; +} + +const CACHE_KEY = "openmouse-hall-of-fame-v3"; +const REFRESH_MS = 15 * 60 * 1000; +const GITHUB_API = "https://api.github.com"; +const PER_PAGE = 100; +const MAX_PAGES = 40; + +function readCache(): CachedData | null { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as CachedData; + if (!Array.isArray(parsed.branches) || parsed.branches.length === 0 || !Array.isArray(parsed.repoList) || !parsed.merged) return null; + return parsed; + } catch { + return null; + } +} + +function writeCache(data: CachedData): void { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(data)); + } catch { /* storage unavailable */ } +} + +function avatarWithSize(avatar: string | null, size = 200): string | null { + if (!avatar) return null; + return avatar.includes("?") ? `${avatar}&s=${size}` : `${avatar}?s=${size}`; +} + +interface ApiCommit { + author: { login: string; avatar_url: string; html_url: string } | null; + commit: { author: { name: string } }; +} + +async function githubJson(path: string): Promise<{ data: T; remaining: number | null; link: string | null }> { + const res = await fetch(`${GITHUB_API}${path}`, { + headers: { Accept: "application/vnd.github+json" }, + }); + const remaining = Number(res.headers.get("x-ratelimit-remaining")); + if (!res.ok) throw new Error(`GitHub API ${res.status}`); + const data = (await res.json()) as T; + return { + data, + remaining: Number.isFinite(remaining) ? remaining : null, + link: res.headers.get("link"), + }; +} + +function lastPageNumber(link: string | null): number { + if (!link) return 1; + const match = /page=(\d+)[^>]*>;\s*rel="last"/.exec(link); + return match ? Math.min(Number(match[1]), MAX_PAGES) : 1; +} + +async function fetchBranch(source: BranchSource): Promise { + const pages: ApiCommit[][] = []; + const first = await githubJson( + `/repos/${source.repo}/commits?sha=${source.branch}&per_page=${PER_PAGE}&page=1`, + ); + pages.push(first.data); + const last = lastPageNumber(first.link); + for (let page = 2; page <= last; page += 1) { + const { data } = await githubJson( + `/repos/${source.repo}/commits?sha=${source.branch}&per_page=${PER_PAGE}&page=${page}`, + ); + pages.push(data); + if (data.length < PER_PAGE) break; + } + + const authors = new Map(); + let commits = 0; + for (const commit of pages.flat()) { + commits += 1; + const login = commit.author?.login; + const fallback = commit.author ? null : (commit.commit.author.name ?? "Unknown"); + const name = login ?? fallback ?? "Unknown"; + if (name.endsWith("[bot]") || name === "Unknown") continue; + const entry = authors.get(name) ?? { + login: name, + avatar: avatarWithSize(commit.author?.avatar_url ?? null), + htmlUrl: commit.author?.html_url ?? null, + count: 0, + }; + if (!entry.avatar) entry.avatar = avatarWithSize(commit.author?.avatar_url ?? null); + if (!entry.htmlUrl) entry.htmlUrl = commit.author?.html_url ?? null; + entry.count += 1; + authors.set(name, entry); + } + + return { + key: source.key, + repo: source.repo, + branch: source.branch, + label: source.label, + commits, + authors: [...authors.values()].sort((a, b) => b.count - a.count), + }; +} + +interface ApiOrgRepo { + name: string; + full_name: string; + description: string | null; + default_branch: string; + stargazers_count: number; + forks_count: number; + open_issues_count: number; + html_url: string; + fork: boolean; + archived: boolean; +} + +function repoKeyOf(name: string): RepoKey { + return name.toLowerCase(); +} + +function repoLabelOf(name: string): string { + if (repoKeyOf(name) === "openmouse") return "OpenMouse"; + return name + .split("-") + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" "); +} + +async function fetchOrgRepos(): Promise { + const { data } = await githubJson(`/orgs/${ORG}/repos?per_page=100&sort=full_name`); + return data.filter((repo) => !repo.fork && !repo.archived); +} + +function branchSources(repos: ApiOrgRepo[]): BranchSource[] { + const sources: BranchSource[] = []; + for (const repo of repos) { + sources.push({ + key: repoKeyOf(repo.name), + repo: repo.full_name, + branch: repo.default_branch, + label: repo.default_branch, + }); + if (repoKeyOf(repo.name) === "openmouse" && repo.default_branch !== "dev") { + sources.push({ key: repoKeyOf(repo.name), repo: repo.full_name, branch: "dev", label: "dev" }); + } + } + return sources; +} + +function repoMetaOf(repo: ApiOrgRepo): RepoMeta { + return { + description: repo.description ?? "", + stars: repo.stargazers_count, + forks: repo.forks_count, + issues: repo.open_issues_count, + htmlUrl: repo.html_url, + }; +} + +function mergeBranches(branches: BranchData[]): MergedContributor[] { + const merged = new Map(); + for (const branch of branches) { + for (const author of branch.authors) { + const entry = merged.get(author.login) ?? { + login: author.login, + avatar: author.avatar, + htmlUrl: author.htmlUrl, + total: 0, + repos: {}, + }; + if (!entry.avatar) entry.avatar = author.avatar; + if (!entry.htmlUrl) entry.htmlUrl = author.htmlUrl; + const previous = entry.repos[branch.key] ?? 0; + entry.repos[branch.key] = Math.max(previous, author.count); + merged.set(author.login, entry); + } + } + for (const entry of merged.values()) { + let total = 0; + for (const count of Object.values(entry.repos)) total += count ?? 0; + entry.total = total; + } + return [...merged.values()].sort((a, b) => b.total - a.total); +} + +async function loadData(): Promise<{ data: CachedData; remaining: number | null; cached: boolean }> { + const cached = Boolean(readCache()); + const orgRepos = await fetchOrgRepos(); + const repoList: RepoInfo[] = orgRepos.map((repo) => ({ + key: repoKeyOf(repo.name), + label: repoLabelOf(repo.name), + fullName: repo.full_name, + })); + const repos: Partial> = {}; + for (const repo of orgRepos) repos[repoKeyOf(repo.name)] = repoMetaOf(repo); + const branches: BranchData[] = []; + for (const source of branchSources(orgRepos)) { + branches.push(await fetchBranch(source)); + } + const data: CachedData = { + fetchedAt: Date.now(), + branches, + repos, + repoList, + merged: mergeBranches(branches), + }; + writeCache(data); + return { data, remaining: null, cached }; +} + +function useCountUp(target: number, active: boolean, duration = 1100): number { + const [value, setValue] = useState(0); + useEffect(() => { + if (!active) return; + let raf = 0; + const start = performance.now(); + const tick = (now: number): void => { + const p = Math.min((now - start) / duration, 1); + const eased = 1 - Math.pow(1 - p, 3); + setValue(Math.round(target * eased)); + if (p < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [target, active, duration]); + return value; +} + +function useInView(): { ref: (node: T | null) => void; inView: boolean } { + const [inView, setInView] = useState(false); + const ref = (node: T | null): void => { + if (!node || inView) return; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setInView(true); + observer.disconnect(); + } + } + }, + { threshold: 0.15 }, + ); + observer.observe(node); + }; + return { ref, inView }; +} + +type SortMode = "top" | "az"; +type FilterMode = "all" | "both" | RepoKey; + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +function timeAgo(ms: number, nowMs = Date.now()): string { + const seconds = Math.max(1, Math.round((nowMs - ms) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +function RepoCard({ repoKey, label, meta, branches, index }: { repoKey: RepoKey; label: string; meta: RepoMeta; branches: BranchData[]; index: number }): ReactNode { + const { ref, inView } = useInView(); + const contributors = new Set(branches.flatMap((b) => b.authors.map((a) => a.login))).size; + return ( + + + + {label} + + +

{meta.description}

+ + + + {formatNumber(meta.stars)} + + + + {formatNumber(meta.forks)} + + + + {formatNumber(meta.issues)} + + + + {branches.map((b) => ( + +