mirror of
https://github.com/avinal/avinal.github.io.git
synced 2026-07-03 23:30:09 +05:30
6e7b3c86ee
Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
446 lines
12 KiB
Plaintext
446 lines
12 KiB
Plaintext
---
|
|
/**
|
|
* Combined activity card: graph (left) + stats (right) in one row.
|
|
* Mirrors jay.fish's "Commit Carnage" + "Kill Count" layout.
|
|
*/
|
|
import type { ActivityData } from "@/lib/activity";
|
|
import type { GitHubUser } from "@/lib/github";
|
|
|
|
interface Props {
|
|
activity: ActivityData;
|
|
user: GitHubUser | null;
|
|
}
|
|
|
|
const { activity, user } = Astro.props;
|
|
const hasWaka = activity.wakatime.available;
|
|
---
|
|
|
|
<div class="activity-card card">
|
|
<div class="activity-header">
|
|
<h3 class="widget-title">
|
|
<span class="icon-trigger" id="gol-trigger">
|
|
<svg class="icon-heart" width="24" height="24" viewBox="0 0 24 24" fill="#e11d48" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
|
</svg>
|
|
<svg class="icon-signal" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
|
</span>
|
|
Activity
|
|
</h3>
|
|
<span class="text-muted text-xs" id="activity-subtitle">past year</span>
|
|
</div>
|
|
|
|
{activity.weeks.length > 0 ? (
|
|
<div class="graph-scroll">
|
|
<div class="graph-grid" role="img" aria-label="Activity graph">
|
|
{activity.weeks.map((week) => (
|
|
<div class="graph-col">
|
|
{week.map((day) => {
|
|
const ghLevel = day.githubLevel;
|
|
const wakaLvl = hasWaka && day.wakaSeconds > 0
|
|
? Math.min(Math.max(1, Math.ceil(day.wakaSeconds / 1800)), 4)
|
|
: 0;
|
|
|
|
const d = new Date(day.date);
|
|
const fmtDate = `${String(d.getDate()).padStart(2, "0")} ${d.toLocaleString("en-US", { month: "short" })} ${d.getFullYear()}`;
|
|
const tipParts = [fmtDate, `${day.githubCount} contribution${day.githubCount !== 1 ? "s" : ""}`];
|
|
if (hasWaka && day.wakaSeconds > 0) tipParts.push(day.wakaText);
|
|
const tip = tipParts.join(" · ");
|
|
|
|
const hasGh = ghLevel > 0;
|
|
const hasWk = wakaLvl > 0;
|
|
|
|
let color: string;
|
|
if (hasGh && hasWk) {
|
|
const ghPct = Math.round((ghLevel / (ghLevel + wakaLvl)) * 100);
|
|
color = `color-mix(in oklch, var(--graph-${ghLevel}) ${ghPct}%, var(--waka-${wakaLvl}))`;
|
|
} else if (hasWk) {
|
|
color = `var(--waka-${wakaLvl})`;
|
|
} else {
|
|
color = `var(--graph-${ghLevel})`;
|
|
}
|
|
const isAlive = hasGh || hasWk;
|
|
return <div class="graph-cell" style={`background-color: ${color}`} title={tip} data-alive={isAlive ? "1" : "0"}></div>;
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div class="graph-legend">
|
|
<span class="legend-group">
|
|
<span class="text-xs text-muted">GitHub</span>
|
|
<div class="legend-cell" style="background-color: var(--graph-1)"></div>
|
|
<div class="legend-cell" style="background-color: var(--graph-2)"></div>
|
|
<div class="legend-cell" style="background-color: var(--graph-3)"></div>
|
|
<div class="legend-cell" style="background-color: var(--graph-4)"></div>
|
|
</span>
|
|
{hasWaka && (
|
|
<span class="legend-group">
|
|
<span class="text-xs text-muted">Both</span>
|
|
<div class="legend-cell" style="background-color: color-mix(in oklch, var(--graph-2) 50%, var(--waka-2))"></div>
|
|
<div class="legend-cell" style="background-color: color-mix(in oklch, var(--graph-3) 50%, var(--waka-3))"></div>
|
|
</span>
|
|
)}
|
|
{hasWaka && (
|
|
<span class="legend-group">
|
|
<div class="legend-cell" style="background-color: var(--waka-1)"></div>
|
|
<div class="legend-cell" style="background-color: var(--waka-2)"></div>
|
|
<div class="legend-cell" style="background-color: var(--waka-3)"></div>
|
|
<div class="legend-cell" style="background-color: var(--waka-4)"></div>
|
|
<span class="text-xs text-muted">WakaTime</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p class="text-muted text-sm">Activity data unavailable.</p>
|
|
)}
|
|
|
|
<!-- Stats below the graph -->
|
|
<div class="activity-stats">
|
|
<dl class="stats-row">
|
|
<div class="stat-item gh">
|
|
<dt>Contributions</dt>
|
|
<dd>{activity.github.total.toLocaleString()}</dd>
|
|
</div>
|
|
<div class="stat-item gh">
|
|
<dt>Public Repos</dt>
|
|
<dd>{user?.public_repos ?? "—"}</dd>
|
|
</div>
|
|
<div class="stat-item gh">
|
|
<dt>Followers</dt>
|
|
<dd>{user?.followers ?? "—"}</dd>
|
|
</div>
|
|
{hasWaka && (
|
|
<Fragment>
|
|
<div class="stat-item waka">
|
|
<dt>Tracked (year)</dt>
|
|
<dd>{activity.wakatime.totalText}</dd>
|
|
</div>
|
|
<div class="stat-item waka">
|
|
<dt>Daily Avg</dt>
|
|
<dd>{activity.wakatime.dailyAvgText}</dd>
|
|
</div>
|
|
<div class="stat-item waka">
|
|
<dt>Best Day</dt>
|
|
<dd>{activity.wakatime.bestDayText}</dd>
|
|
</div>
|
|
</Fragment>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.activity-card {
|
|
padding: var(--space-5);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.activity-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.graph-scroll {
|
|
overflow: hidden;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.graph-scroll {
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
|
|
.graph-grid {
|
|
display: flex;
|
|
gap: 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-col {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
|
|
.graph-cell {
|
|
width: 11px;
|
|
height: 11px;
|
|
border-radius: 2px;
|
|
cursor: default;
|
|
}
|
|
|
|
.graph-legend {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.legend-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
}
|
|
|
|
.legend-cell {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
/* Stats — horizontal row below the graph */
|
|
.activity-stats {
|
|
padding-top: var(--space-4);
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.stats-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: var(--space-4) var(--space-6);
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.stat-item dt {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.stat-item dd {
|
|
font-size: var(--text-sm);
|
|
font-weight: 700;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--text);
|
|
}
|
|
|
|
.stat-item.gh dd {
|
|
color: var(--graph-3);
|
|
}
|
|
|
|
.stat-item.waka dd {
|
|
color: var(--waka-3);
|
|
}
|
|
|
|
/* Easter egg: heart icon trigger */
|
|
.icon-trigger {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
width: 26px;
|
|
height: 26px;
|
|
}
|
|
|
|
.icon-heart {
|
|
position: absolute;
|
|
opacity: 0;
|
|
transform: scale(0.6);
|
|
transition: opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.icon-signal {
|
|
position: relative;
|
|
z-index: 1;
|
|
transition: opacity 0.3s var(--ease-out);
|
|
}
|
|
|
|
.icon-trigger:hover .icon-heart {
|
|
opacity: 0.4;
|
|
transform: scale(1);
|
|
animation: heart-pulse 1.2s ease-in-out infinite;
|
|
}
|
|
|
|
.icon-trigger:hover .icon-signal {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
:global(.gol-active) .icon-heart {
|
|
opacity: 0.5;
|
|
transform: scale(1);
|
|
animation: heart-pulse 1.2s ease-in-out infinite;
|
|
}
|
|
|
|
:global(.gol-active) .icon-signal {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
@keyframes heart-pulse {
|
|
0%, 100% { opacity: 0.3; transform: scale(0.88); }
|
|
50% { opacity: 0.6; transform: scale(1.12); }
|
|
}
|
|
|
|
:global(.gol-active) .graph-cell {
|
|
cursor: crosshair;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
const trigger = document.getElementById("gol-trigger");
|
|
const card = document.querySelector(".activity-card");
|
|
const graphGrid = document.querySelector(".graph-grid") as HTMLElement | null;
|
|
const subtitle = document.getElementById("activity-subtitle");
|
|
|
|
if (trigger && graphGrid && card) {
|
|
let active = false;
|
|
let tickId: number | null = null;
|
|
let grid: number[][] = [];
|
|
let saved: { bg: string; title: string }[][] = [];
|
|
|
|
const columns = () => graphGrid.querySelectorAll(".graph-col");
|
|
|
|
function readGrid(): number[][] {
|
|
return Array.from(columns()).map((col) =>
|
|
Array.from(col.querySelectorAll(".graph-cell")).map((c) => {
|
|
if (c.getAttribute("data-alive") !== "1") return 0;
|
|
return Math.random() < 0.45 ? 1 : 0;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function saveState() {
|
|
saved = Array.from(columns()).map((col) =>
|
|
Array.from(col.querySelectorAll(".graph-cell")).map((c) => ({
|
|
bg: (c as HTMLElement).style.backgroundColor,
|
|
title: c.getAttribute("title") || "",
|
|
})),
|
|
);
|
|
}
|
|
|
|
function restoreState() {
|
|
const cols = columns();
|
|
cols.forEach((col, ci) => {
|
|
col.querySelectorAll(".graph-cell").forEach((c, ri) => {
|
|
const el = c as HTMLElement;
|
|
el.style.backgroundColor = saved[ci]?.[ri]?.bg || "";
|
|
el.setAttribute("title", saved[ci]?.[ri]?.title || "");
|
|
});
|
|
});
|
|
}
|
|
|
|
function countAlive(): number {
|
|
return grid.reduce((s, col) => s + col.reduce((a, v) => a + v, 0), 0);
|
|
}
|
|
|
|
function step() {
|
|
const numC = grid.length;
|
|
const numR = grid[0]?.length || 0;
|
|
const out = grid.map((col) => col.map(() => 0));
|
|
|
|
for (let c = 0; c < numC; c++) {
|
|
for (let r = 0; r < numR; r++) {
|
|
let n = 0;
|
|
for (let dc = -1; dc <= 1; dc++) {
|
|
for (let dr = -1; dr <= 1; dr++) {
|
|
if (dc === 0 && dr === 0) continue;
|
|
n += grid[(c + dc + numC) % numC][(r + dr + numR) % numR];
|
|
}
|
|
}
|
|
const a = grid[c][r];
|
|
out[c][r] = a ? (n === 2 || n === 3 ? 1 : 0) : n === 3 ? 1 : 0;
|
|
}
|
|
}
|
|
grid = out;
|
|
}
|
|
|
|
function render() {
|
|
const style = getComputedStyle(document.documentElement);
|
|
const deadColor = style.getPropertyValue("--graph-0").trim();
|
|
const fallbackAlive = style.getPropertyValue("--accent").trim();
|
|
|
|
const cols = columns();
|
|
cols.forEach((col, ci) => {
|
|
col.querySelectorAll(".graph-cell").forEach((c, ri) => {
|
|
const el = c as HTMLElement;
|
|
const alive = grid[ci]?.[ri];
|
|
const origColor = saved[ci]?.[ri]?.bg;
|
|
const isOrigAlive = origColor && origColor !== deadColor;
|
|
el.style.backgroundColor = alive
|
|
? (isOrigAlive ? origColor : fallbackAlive)
|
|
: deadColor;
|
|
el.setAttribute("title", "");
|
|
});
|
|
});
|
|
}
|
|
|
|
function tick() {
|
|
step();
|
|
if (countAlive() === 0) {
|
|
stop();
|
|
return;
|
|
}
|
|
render();
|
|
tickId = window.setTimeout(tick, 280);
|
|
}
|
|
|
|
function start() {
|
|
saveState();
|
|
grid = readGrid();
|
|
|
|
if (countAlive() < 10) {
|
|
for (let c = 0; c < grid.length; c++) {
|
|
for (let r = 0; r < (grid[0]?.length || 0); r++) {
|
|
if (Math.random() < 0.3) grid[c][r] = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
active = true;
|
|
card.classList.add("gol-active");
|
|
if (subtitle) subtitle.textContent = "hover to bring cells alive";
|
|
render();
|
|
tickId = window.setTimeout(tick, 280);
|
|
}
|
|
|
|
function stop() {
|
|
if (tickId !== null) clearTimeout(tickId);
|
|
tickId = null;
|
|
active = false;
|
|
card.classList.remove("gol-active");
|
|
if (subtitle) subtitle.textContent = "past year";
|
|
restoreState();
|
|
}
|
|
|
|
trigger.addEventListener("click", () => {
|
|
active ? stop() : start();
|
|
});
|
|
|
|
graphGrid.addEventListener("mousemove", (e) => {
|
|
if (!active) return;
|
|
const target = e.target as HTMLElement;
|
|
if (!target.classList.contains("graph-cell")) return;
|
|
|
|
const col = target.parentElement;
|
|
if (!col) return;
|
|
const allCols = Array.from(columns());
|
|
const ci = allCols.indexOf(col);
|
|
const cells = Array.from(col.querySelectorAll(".graph-cell"));
|
|
const ri = cells.indexOf(target);
|
|
|
|
if (ci < 0 || ri < 0) return;
|
|
for (let dc = -1; dc <= 1; dc++) {
|
|
for (let dr = -1; dr <= 1; dr++) {
|
|
const nc = ci + dc;
|
|
const nr = ri + dr;
|
|
if (nc >= 0 && nc < grid.length && nr >= 0 && nr < (grid[0]?.length || 0)) {
|
|
grid[nc][nr] = 1;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
</script>
|