1
0
mirror of https://github.com/avinal/avinal.github.io.git synced 2026-07-03 23:30:09 +05:30

feat: add events page and music widget

- add music source from Listenbrainz, easter egg and evets page
- update design of resume page

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-02-26 17:17:48 +05:30
committed by Morumotto
parent 6b07ea345f
commit ef70634b2a
20 changed files with 1429 additions and 387 deletions
+7 -3
View File
@@ -1,3 +1,7 @@
# WakaTime API key — used to fetch coding activity stats for the homepage
# Get yours at https://wakatime.com/settings/api-key
WAKATIME_API_KEY=
# No environment variables required.
# All external data is fetched from public APIs or configured in src/config/theme.ts.
#
# WakaTime: public share URL (configured in src/lib/wakatime.ts)
# GitHub: public API (configured in src/lib/github.ts)
# ListenBrainz: public API, username in src/config/theme.ts
# Cal.com: embedded via CDN script (configured in src/pages/meeting.astro)
+1 -2
View File
@@ -1,10 +1,9 @@
name: Check build
name: CI
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch:
jobs:
+2 -2
View File
@@ -17,5 +17,5 @@ dist/
# OS
.DS_Store
# Legacy Hugo
public/
# Personal files
Profile.pdf
+8 -10
View File
@@ -13,11 +13,12 @@ Personal website and blog built with [Astro](https://astro.build). Minimal, fast
| Route | Description |
|-------|-------------|
| `/` | Homepage with hero card, GitHub/WakaTime activity graph, Game of Life widget, recent posts, and pinned repos |
| `/` | Homepage with hero card, GitHub/WakaTime activity graph, ListenBrainz music widget, recent posts, and pinned repos |
| `/posts/` | Blog index with category filters and featured images |
| `/posts/<category>/` | Category-filtered post listings |
| `/posts/<category>/<slug>/` | Individual blog posts |
| `/resume/` | Resume page (data driven from `src/data/resume.json`) |
| `/events/` | Conferences and events timeline |
| `/meeting/` | Book a meeting via [Cal.com](https://cal.com) embed |
| `/setup/` | Hardware and software setup |
| `/rss.xml` | RSS feed |
@@ -35,15 +36,12 @@ cd avinal.github.io
make install
```
Copy the example env file and add your keys:
No environment variables are required. All external data is fetched from public APIs:
```bash
cp .env.example .env
```
| Variable | Required | Description |
|----------|----------|-------------|
| `WAKATIME_API_KEY` | No | Enables WakaTime coding stats on the homepage activity graph. Get yours at [wakatime.com/settings/api-key](https://wakatime.com/settings/api-key) |
- **GitHub** — contributions graph and user info (public API)
- **WakaTime** — coding stats via public share URL
- **ListenBrainz** — music listening activity (public API, username in `src/config/theme.ts`)
- **Cal.com** — meeting booking (embedded via CDN)
## Development
@@ -64,7 +62,7 @@ src/
├── components/ # Reusable Astro components
├── config/ # Theme tokens and site config
├── content/posts/ # Blog posts (Markdown)
├── data/ # JSON data (resume, repos)
├── data/ # JSON data (resume, repos, events)
├── layouts/ # Page layouts
├── lib/ # Utilities and rehype plugins
├── pages/ # Route pages
+234 -21
View File
@@ -18,10 +18,15 @@ const hasWaka = activity.wakatime.available;
<div class="activity-card card">
<div class="activity-header">
<h3 class="widget-title">
<svg 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 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">past year</span>
<span class="text-muted text-xs" id="activity-subtitle">past year</span>
</div>
{activity.weeks.length > 0 ? (
@@ -43,18 +48,18 @@ const hasWaka = activity.wakatime.available;
const hasGh = ghLevel > 0;
const hasWk = wakaLvl > 0;
const isSplit = hasGh && hasWk;
if (isSplit) {
return (
<div class="graph-cell split-cell" title={tip}>
<div class="cell-gh" style={`background-color: var(--graph-${ghLevel})`}></div>
<div class="cell-waka" style={`background-color: var(--waka-${wakaLvl})`}></div>
</div>
);
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 color = hasWk ? `var(--waka-${wakaLvl})` : `var(--graph-${ghLevel})`;
return <div class="graph-cell" style={`background-color: ${color}`} title={tip}></div>;
const isAlive = hasGh || hasWk;
return <div class="graph-cell" style={`background-color: ${color}`} title={tip} data-alive={isAlive ? "1" : "0"}></div>;
})}
</div>
))}
@@ -68,6 +73,13 @@ const hasWaka = activity.wakatime.available;
<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>
@@ -162,15 +174,6 @@ const hasWaka = activity.wakatime.available;
cursor: default;
}
.split-cell {
display: flex;
overflow: hidden;
background: none;
}
.cell-gh { width: 50%; height: 11px; border-radius: 2px 0 0 2px; }
.cell-waka { width: 50%; height: 11px; border-radius: 0 2px 2px 0; }
.graph-legend {
display: flex;
align-items: center;
@@ -229,4 +232,214 @@ const hasWaka = activity.wakatime.available;
.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>
+8 -18
View File
@@ -7,13 +7,10 @@ const year = new Date().getFullYear();
<p class="footer-copy">
&copy; {year} Avinal Kumar
</p>
<div class="footer-links">
<a href="https://github.com/avinal" target="_blank" rel="noopener noreferrer">GitHub</a>
<span class="footer-sep" aria-hidden="true">&middot;</span>
<a href="https://linkedin.com/in/avinal" target="_blank" rel="noopener noreferrer">LinkedIn</a>
<span class="footer-sep" aria-hidden="true">&middot;</span>
<a href="/posts">Posts</a>
</div>
<a href="https://github.com/avinal/avinal.github.io/issues/new" class="footer-report" target="_blank" rel="noopener noreferrer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Report an issue
</a>
</div>
</footer>
@@ -39,24 +36,17 @@ const year = new Date().getFullYear();
color: var(--text-muted);
}
.footer-links {
display: flex;
.footer-report {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
}
.footer-links a {
color: var(--text-secondary);
color: var(--text-muted);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
.footer-links a:hover {
.footer-report:hover {
color: var(--text);
}
.footer-sep {
color: var(--text-muted);
}
</style>
-209
View File
@@ -1,209 +0,0 @@
---
/**
* Conway's Game of Life widget.
* Runs continuously; hovering over the canvas brings cells to life.
* Resumes simulation after hover interaction.
*/
---
<div class="gol-card card">
<h3 class="widget-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Game of Life
</h3>
<canvas id="gol-canvas"></canvas>
<p class="gol-hint text-muted text-xs">hover to bring cells alive</p>
</div>
<style>
.gol-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
height: 100%;
min-height: 220px;
overflow: hidden;
}
#gol-canvas {
flex: 1;
width: 100%;
min-height: 180px;
border-radius: var(--radius-sm);
cursor: crosshair;
image-rendering: pixelated;
}
@media (max-width: 900px) {
.gol-card {
min-height: 200px;
}
}
.gol-hint {
text-align: center;
font-style: italic;
}
</style>
<script>
const canvas = document.getElementById("gol-canvas") as HTMLCanvasElement;
if (canvas) {
const ctx = canvas.getContext("2d")!;
const CELL = 8;
const GAP = 1;
const STEP = CELL + GAP;
let cols = 0;
let rows = 0;
let grid: Uint8Array;
let next: Uint8Array;
function resize() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const newCols = Math.floor(rect.width / STEP);
const newRows = Math.floor(rect.height / STEP);
if (newCols !== cols || newRows !== rows) {
const oldGrid = grid;
const oldCols = cols;
const oldRows = rows;
cols = newCols;
rows = newRows;
grid = new Uint8Array(cols * rows);
next = new Uint8Array(cols * rows);
if (oldGrid) {
const mc = Math.min(oldCols, cols);
const mr = Math.min(oldRows, rows);
for (let r = 0; r < mr; r++) {
for (let c = 0; c < mc; c++) {
grid[r * cols + c] = oldGrid[r * oldCols + c];
}
}
} else {
seed();
}
}
}
function seed() {
for (let i = 0; i < grid.length; i++) {
grid[i] = Math.random() < 0.3 ? 1 : 0;
}
}
function idx(r: number, c: number) {
return ((r + rows) % rows) * cols + ((c + cols) % cols);
}
function step() {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let neighbors = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
neighbors += grid[idx(r + dr, c + dc)];
}
}
const alive = grid[r * cols + c];
next[r * cols + c] =
alive ? (neighbors === 2 || neighbors === 3 ? 1 : 0) : (neighbors === 3 ? 1 : 0);
}
}
[grid, next] = [next, grid];
}
function getColors() {
const style = getComputedStyle(document.documentElement);
return {
bg: style.getPropertyValue("--bg-surface").trim() || "#f8f9fa",
alive: style.getPropertyValue("--accent").trim() || "#2563eb",
dim: style.getPropertyValue("--border").trim() || "#e5e7eb",
};
}
function draw() {
const colors = getColors();
const rect = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * STEP;
const y = r * STEP;
ctx.fillStyle = grid[r * cols + c] ? colors.alive : colors.dim;
ctx.globalAlpha = grid[r * cols + c] ? 0.85 : 0.15;
ctx.fillRect(x, y, CELL, CELL);
}
}
ctx.globalAlpha = 1;
}
let animId: number;
let lastTick = 0;
const TICK_MS = 300;
function loop(ts: number) {
if (ts - lastTick >= TICK_MS) {
step();
draw();
lastTick = ts;
}
animId = requestAnimationFrame(loop);
}
function paintCells(e: MouseEvent | TouchEvent) {
const rect = canvas.getBoundingClientRect();
let clientX: number, clientY: number;
if ("touches" in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
const x = clientX - rect.left;
const y = clientY - rect.top;
const c = Math.floor(x / STEP);
const r = Math.floor(y / STEP);
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const nr = r + dr;
const nc = c + dc;
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
grid[nr * cols + nc] = 1;
}
}
}
}
let isDrawing = false;
canvas.addEventListener("mouseenter", () => { isDrawing = true; });
canvas.addEventListener("mouseleave", () => { isDrawing = false; });
canvas.addEventListener("mousemove", (e) => {
if (isDrawing) paintCells(e);
});
canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
paintCells(e);
}, { passive: false });
canvas.addEventListener("click", (e) => paintCells(e));
const resizeObs = new ResizeObserver(() => {
resize();
draw();
});
resizeObs.observe(canvas);
resize();
animId = requestAnimationFrame(loop);
}
</script>
+237
View File
@@ -0,0 +1,237 @@
---
import type { LBData } from "@/lib/listenbrainz";
interface Props {
lb: LBData;
}
const { lb } = Astro.props;
const profileUrl = `https://listenbrainz.org/user/${lb.username}`;
const embedUrl = `https://listenbrainz.org/user/${lb.username}/embed/playing-now`;
function timeAgo(ts?: number): string {
if (!ts) return "";
const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
---
<div class="music-card card">
<div class="music-header">
<h3 class="widget-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<a href={profileUrl} target="_blank" rel="noopener noreferrer" class="lb-title-link">Listening</a>
</h3>
</div>
{lb.available ? (
<div class="lb-content">
<div class="lb-now-embed">
<iframe
src={embedUrl}
title="Now playing on ListenBrainz"
frameborder="0"
scrolling="no"
loading="lazy"
></iframe>
</div>
{lb.recentTracks.length > 0 && (
<div class="lb-recent-section">
<span class="lb-recent-label">Recent</span>
<ul class="lb-recent">
{lb.recentTracks.slice(0, 4).map((t) => (
<li class="lb-track">
{t.coverArtUrl && (
<img
class="lb-cover"
src={t.coverArtUrl}
alt=""
loading="lazy"
onerror="this.style.display='none'"
/>
)}
<div class="lb-info">
<span class="lb-name">
{t.listenUrl ? (
<a href={t.listenUrl} target="_blank" rel="noopener noreferrer">{t.trackName}</a>
) : t.trackName}
</span>
<span class="lb-artist">{t.artistName}</span>
</div>
<span class="lb-time">{timeAgo(t.listenedAt)}</span>
</li>
))}
</ul>
</div>
)}
{lb.recentTracks.length === 0 && (
<div class="lb-empty">
<span class="text-muted text-xs">No recent listens yet</span>
</div>
)}
</div>
) : (
<div class="lb-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<span class="text-muted text-xs">Connect ListenBrainz to see listening activity</span>
</div>
)}
</div>
<style>
.music-card {
display: flex;
flex-direction: column;
height: 100%;
min-height: 220px;
overflow: hidden;
}
.music-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.lb-title-link {
color: inherit;
text-decoration: none;
}
.lb-title-link:hover {
color: var(--accent);
}
.lb-content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
overflow: hidden;
}
.lb-now-embed {
border-radius: var(--radius-sm);
overflow: hidden;
height: 85px;
flex-shrink: 0;
background: var(--bg-surface-hover);
}
.lb-now-embed iframe {
width: calc(100% + 2px);
height: 85px;
border: none;
display: block;
margin: -1px;
}
.lb-recent-section {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-1);
overflow: hidden;
}
.lb-recent-label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.lb-recent {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
overflow: hidden;
}
.lb-track {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: background var(--duration-fast) var(--ease-out);
}
.lb-track:hover {
background: var(--bg-surface-hover);
}
.lb-cover {
width: 32px;
height: 32px;
border-radius: 3px;
object-fit: cover;
flex-shrink: 0;
background: var(--border);
}
.lb-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.lb-name {
font-size: var(--text-xs);
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lb-name a {
color: inherit;
text-decoration: none;
}
.lb-name a:hover {
color: var(--accent);
}
.lb-artist {
font-size: var(--text-xs);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lb-time {
font-size: var(--text-xs);
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.lb-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
@media (max-width: 900px) {
.music-card {
min-height: 180px;
}
}
</style>
+1
View File
@@ -3,6 +3,7 @@ const navLinks: { href: string; label: string; external?: boolean }[] = [
{ href: "/", label: "Home" },
{ href: "/posts", label: "Posts" },
{ href: "/resume", label: "Resume" },
{ href: "/events", label: "Events" },
{ href: "/meeting", label: "Meet" },
{ href: "https://todo.avinal.space/explore", label: "Memos", external: true },
{ href: "/setup", label: "Setup" },
+3 -1
View File
@@ -43,6 +43,7 @@ export interface ThemeConfig {
url: string;
author: string;
logoText: string;
listenBrainzUser?: string;
};
fonts: {
@@ -102,6 +103,7 @@ const theme: ThemeConfig = {
url: "https://avinal.space",
author: "Avinal Kumar",
logoText: "avinal.space",
listenBrainzUser: "avinal",
},
fonts: {
@@ -169,7 +171,7 @@ const theme: ThemeConfig = {
spacing: {
base: "0.25rem",
navHeight: "3.5rem",
maxProse: "42rem",
maxProse: "48rem",
maxPage: "64rem",
sectionGap: "4rem",
cardPadding: "1.5rem",
+56
View File
@@ -0,0 +1,56 @@
[
{
"name": "Opportunity Open Source Conference 2024",
"date": "2024-08-25",
"location": "IIT Kanpur, India",
"role": "speaker",
"talk": "From First Commit to Mentor: Climbing the Open Source Ladder",
"description": "Presented at the Canonical/Ubuntu conference on the journey from first-time contributor to mentor in open source. Covered practical advice on starting out, staying motivated, transitioning to mentorship, and fostering stronger communities. Included a deep-dive into my GSoC experience at FOSSology.",
"links": [
{ "label": "Event Page", "url": "https://events.canonical.com/event/89/contributions/485/" },
{ "label": "Slides", "url": "https://www.canva.com/design/DAGNthYwrck/Gt7mv99p6zH-aVbx2AtEuQ/view" }
]
},
{
"name": "Google Summer of Code Mentor Summit 2023",
"date": "2023-10-13",
"location": "Google, Sunnyvale, USA",
"role": "mentor",
"description": "Represented the FOSSology project at the annual GSoC Mentor Summit hosted at Google's Sunnyvale campus. Participated in unconference sessions on mentoring practices, community building, and project sustainability.",
"links": [
{ "label": "GSoC", "url": "https://summerofcode.withgoogle.com/" },
{ "label": "FOSSology", "url": "https://www.fossology.org" }
]
},
{
"name": "Open Source Month (OSM)",
"date": "2021-10-09",
"location": "NIT Hamirpur, India",
"role": "organizer",
"description": "Organized and led a month-long series of talks and workshops as a GitHub Campus Expert. Topics ranged from introduction to open source, Git workflows, and writing GSoC proposals to lightning talks by contributors from projects like Julia and VideoLAN. Over 200 students participated across 7 sessions.",
"links": [
{ "label": "Campus Expert Profile", "url": "https://githubcampus.expert/avinal/" }
]
},
{
"name": "GitHub Universe 2021",
"date": "2021-10-27",
"location": "Virtual",
"role": "attendee",
"description": "Attended GitHub's annual developer conference as a GitHub Campus Expert. Explored new features including Copilot, Codespaces, and Actions improvements.",
"links": [
{ "label": "GitHub Universe", "url": "https://githubuniverse.com/" }
]
},
{
"name": "GCE Live India",
"date": "2021-08-27",
"location": "Banmankhi, India",
"role": "speaker",
"talk": "Campus Experts: Preparation, Application Tips, Perks & Benefits",
"description": "Delivered a live session on how to become a GitHub Campus Expert — covering the application process, preparation tips, community benefits, and what to expect from the program.",
"links": [
{ "label": "Campus Expert Profile", "url": "https://githubcampus.expert/avinal/" }
]
}
]
+136 -16
View File
@@ -84,8 +84,8 @@
]
},
{
"name": "The FOSSology Project — Google Summer of Code 2021",
"position": "DevOps & Software Development Engineer Intern",
"name": "The FOSSology Project",
"position": "Google Summer of Code Student",
"url": "https://www.fossology.org",
"startDate": "2021-05-01",
"endDate": "2021-08-31",
@@ -122,23 +122,31 @@
"Documented VLC for Android using Sphinx, reStructuredText, Markdown, and shell scripting",
"Delivered user-friendly documentation with supporting screenshots and step-by-step tutorials"
]
}
],
"volunteer": [
},
{
"organization": "The FOSSology Project",
"name": "The FOSSology Project",
"position": "Google Summer of Code Mentor",
"url": "https://www.fossology.org",
"startDate": "2022-05-01",
"summary": "Mentoring GSoC students since 2022, guiding open source license compliance tooling projects"
"summary": "Mentoring & Open Source",
"location": "Remote",
"highlights": [
"Mentoring GSoC students since 2022, guiding open source license compliance tooling projects"
]
},
{
"organization": "GNU C Library",
"name": "GNU C Library",
"position": "Contributor",
"url": "https://www.gnu.org/software/libc/",
"startDate": "2024-05-01",
"summary": "Submitting patches to the GNU C Library (glibc)"
},
"summary": "GNU Project",
"location": "Remote",
"highlights": [
"Submitting patches to the GNU C Library (glibc)"
]
}
],
"volunteer": [
{
"organization": "GitHub Education",
"position": "GitHub Campus Expert",
@@ -155,6 +163,14 @@
"endDate": "2021-05-31",
"summary": "Guided beginners getting started with open source development"
},
{
"organization": "Script Winter of Code",
"position": "Participant",
"url": "",
"startDate": "2020-12-01",
"endDate": "2021-02-28",
"summary": "Contributed to open source projects during the winter program"
},
{
"organization": "VideoLAN",
"position": "Open Source Contributor",
@@ -171,13 +187,77 @@
"endDate": "2022-06-30",
"summary": "Led the Computer Science Engineers Club — organized hackathons, workshops, and coding competitions"
},
{
"organization": "CSEC, NIT Hamirpur",
"position": "Coordinator",
"url": "https://csec.nith.ac.in",
"startDate": "2020-09-01",
"endDate": "2021-06-30",
"summary": "Coordinated events and activities for the Computer Science Engineers Club"
},
{
"organization": "CSEC, NIT Hamirpur",
"position": "Executive Member",
"url": "https://csec.nith.ac.in",
"startDate": "2019-08-01",
"endDate": "2020-08-31",
"summary": "Active executive member contributing to club events and technical initiatives"
},
{
"organization": "CSEC, NIT Hamirpur",
"position": "Volunteer",
"url": "https://csec.nith.ac.in",
"startDate": "2019-01-01",
"endDate": "2019-07-31",
"summary": "Volunteered for club events and technical workshops"
},
{
"organization": "SRIJAN, NIT Hamirpur",
"position": "Hindi Head Editor",
"url": "",
"startDate": "2022-02-01",
"endDate": "2022-09-30",
"summary": "Led the Hindi editorial team for the college's literary and technical magazine"
},
{
"organization": "SRIJAN, NIT Hamirpur",
"position": "Executive Editor",
"url": "",
"startDate": "2020-07-01",
"endDate": "2022-09-30",
"endDate": "2022-01-31",
"summary": "Editorial work for the college's literary and technical magazine"
},
{
"organization": "SRIJAN, NIT Hamirpur",
"position": "Associate Editor",
"url": "",
"startDate": "2019-07-01",
"endDate": "2020-06-30",
"summary": "Associate editorial work for the college magazine"
},
{
"organization": "SRIJAN, NIT Hamirpur",
"position": "Hindi Editor",
"url": "",
"startDate": "2018-11-01",
"endDate": "2019-06-30",
"summary": "Hindi section editorial work for the college magazine"
},
{
"organization": "GNU/Linux Users Group, NIT Hamirpur",
"position": "Volunteer",
"url": "",
"startDate": "2018-11-01",
"endDate": "2022-07-31",
"summary": "Open-source community at NIT Hamirpur — shared knowledge about GNU/Linux and FOSS"
},
{
"organization": "SPIC MACAY, NIT Hamirpur",
"position": "Volunteer",
"url": "",
"startDate": "2018-09-01",
"endDate": "2022-05-31",
"summary": "Organized arts and culture events on campus"
}
],
"education": [
@@ -219,7 +299,7 @@
"skills": [
{
"name": "Languages",
"keywords": ["C++", "Go", "C", "Python", "Bash", "Elm"]
"keywords": ["C++", "Go", "C", "Python", "Bash"]
},
{
"name": "Cloud & DevOps",
@@ -246,13 +326,45 @@
],
"certificates": [
{
"name": "Career Edge - Knockdown the Lockdown",
"issuer": "TCS iON"
"name": "A Beginner's Guide to Linux Kernel Development (LFD103)",
"issuer": "The Linux Foundation"
},
{
"name": "Introduction to Artificial Intelligence",
"issuer": "IBM"
},
{
"name": "Google IT Support Professional Certificate",
"issuer": "Google"
},
{
"name": "IT Security: Defense against the digital dark arts",
"issuer": "Google"
},
{
"name": "System Administration and IT Infrastructure Services",
"issuer": "Google"
},
{
"name": "Operating Systems and You: Becoming a Power User",
"issuer": "Google"
},
{
"name": "The Bits and Bytes of Computer Networking",
"issuer": "Google"
},
{
"name": "Technical Support Fundamentals",
"issuer": "Google"
},
{
"name": "Build a Modern Computer from First Principles: Nand to Tetris",
"issuer": "Coursera"
},
{
"name": "Accelerated Computer Science Fundamentals",
"issuer": "Coursera"
},
{
"name": "Unordered Data Structures",
"issuer": "University of Illinois"
@@ -262,8 +374,16 @@
"issuer": "University of Illinois"
},
{
"name": "Introduction to Artificial Intelligence",
"issuer": "IBM"
"name": "Ordered Data Structures",
"issuer": "Coursera"
},
{
"name": "Workshop on Basic Programming using Python",
"issuer": "FOSSEE"
},
{
"name": "Career Edge - Knockdown the Lockdown",
"issuer": "TCS iON"
}
],
"projects": [
-1
View File
@@ -10,7 +10,6 @@ interface Props {
tags?: string[];
image?: string;
readingTime: string;
headings: { depth: number; slug: string; text: string }[];
}
const {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ContributionData, ContributionDay } from "./github";
import type { ContributionData } from "./github";
import type { WakaTimeData } from "./wakatime";
export interface ActivityDay {
+89
View File
@@ -0,0 +1,89 @@
import theme from "@/config/theme";
const LB_API = "https://api.listenbrainz.org/1";
const USERNAME = theme.site.listenBrainzUser ?? "avinal";
export interface LBTrack {
trackName: string;
artistName: string;
releaseName?: string;
listenedAt?: number;
coverArtUrl?: string;
listenUrl?: string;
}
export interface LBData {
available: boolean;
username: string;
nowPlaying: LBTrack | null;
recentTracks: LBTrack[];
}
function parseTrack(listen: any): LBTrack {
const meta = listen.track_metadata ?? {};
const info = meta.additional_info ?? {};
const mbids = meta.mbid_mapping ?? {};
const releaseMbid = mbids.release_mbid ?? info.release_mbid;
const coverArtUrl = releaseMbid
? `https://coverartarchive.org/release/${releaseMbid}/front-250`
: undefined;
const listenUrl =
info.origin_url ??
info.spotify_id ??
(mbids.recording_mbid
? `https://listenbrainz.org/player?recording_mbids=${mbids.recording_mbid}`
: undefined);
return {
trackName: meta.track_name ?? "Unknown",
artistName: meta.artist_name ?? "Unknown",
releaseName: meta.release_name,
listenedAt: listen.listened_at,
coverArtUrl,
listenUrl,
};
}
export async function fetchListenBrainzData(): Promise<LBData> {
const empty: LBData = {
available: false,
username: USERNAME,
nowPlaying: null,
recentTracks: [],
};
if (!USERNAME) return empty;
try {
const [nowRes, listensRes] = await Promise.all([
fetch(`${LB_API}/user/${USERNAME}/playing-now`),
fetch(`${LB_API}/user/${USERNAME}/listens?count=5`),
]);
let nowPlaying: LBTrack | null = null;
if (nowRes.ok) {
const nowData = await nowRes.json();
const np = nowData?.payload?.listens?.[0];
if (np) nowPlaying = parseTrack(np);
}
let recentTracks: LBTrack[] = [];
if (listensRes.ok) {
const listensData = await listensRes.json();
const listens = listensData?.payload?.listens ?? [];
recentTracks = listens.map(parseTrack);
}
return {
available: true,
username: USERNAME,
nowPlaying,
recentTracks,
};
} catch (e) {
console.warn("ListenBrainz fetch failed:", e);
return empty;
}
}
+53 -51
View File
@@ -1,3 +1,6 @@
const SHARE_URL =
"https://wakatime.com/share/@Avinal/13174f97-2646-484b-9644-bc3c07315068.json";
export interface WakaTimeDaySummary {
date: string;
totalSeconds: number;
@@ -13,6 +16,26 @@ export interface WakaTimeData {
topLangs: string[];
}
interface RawCategory {
name: string;
total: number;
}
interface RawDay {
date: string;
total: number;
categories?: RawCategory[];
}
const EXCLUDED_CATEGORIES = new Set(["Browsing"]);
function codingSeconds(categories: RawCategory[] | undefined): number {
if (!categories) return 0;
return categories
.filter((c) => !EXCLUDED_CATEGORIES.has(c.name))
.reduce((sum, c) => sum + (c.total ?? 0), 0);
}
function formatSeconds(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
@@ -21,65 +44,44 @@ function formatSeconds(s: number): string {
return `${h}h ${m}m`;
}
function wakaAuth(): string | null {
const key = import.meta.env.WAKATIME_API_KEY;
if (!key) return null;
return `Basic ${btoa(key)}`;
}
async function fetchStats(auth: string) {
const res = await fetch(
"https://wakatime.com/api/v1/users/current/stats/last_year",
{ headers: { Authorization: auth } },
);
if (!res.ok) return null;
return (await res.json()).data;
}
async function fetchSummaries(auth: string): Promise<Map<string, WakaTimeDaySummary>> {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 14);
const fmt = (d: Date) => d.toISOString().slice(0, 10);
const days = new Map<string, WakaTimeDaySummary>();
export async function fetchWakaTimeData(): Promise<WakaTimeData | null> {
try {
const res = await fetch(
`https://wakatime.com/api/v1/users/current/summaries?start=${fmt(start)}&end=${fmt(end)}`,
{ headers: { Authorization: auth } },
);
if (!res.ok) return days;
const res = await fetch(SHARE_URL);
if (!res.ok) return null;
const json = await res.json();
for (const day of json.data ?? []) {
const secs = day.grand_total?.total_seconds ?? 0;
days.set(day.range.date, {
date: day.range.date,
totalSeconds: secs,
text: secs > 0 ? formatSeconds(secs) : "0m",
const rawDays: RawDay[] = json.days ?? [];
if (rawDays.length === 0) return null;
const days = new Map<string, WakaTimeDaySummary>();
let codingTotal = 0;
let fullTotal = 0;
let bestCodingDay = 0;
let codingActiveDays = 0;
for (const d of rawDays) {
const coding = codingSeconds(d.categories);
fullTotal += d.total ?? 0;
codingTotal += coding;
if (coding > bestCodingDay) bestCodingDay = coding;
if (coding > 0) codingActiveDays++;
days.set(d.date, {
date: d.date,
totalSeconds: coding,
text: coding > 0 ? formatSeconds(coding) : "0m",
});
}
} catch { /* graceful degrade */ }
return days;
}
export async function fetchWakaTimeData(): Promise<WakaTimeData | null> {
const auth = wakaAuth();
if (!auth) return null;
try {
const [stats, days] = await Promise.all([
fetchStats(auth),
fetchSummaries(auth),
]);
if (!stats) return null;
const dailyAvg = codingActiveDays > 0 ? codingTotal / codingActiveDays : 0;
return {
totalSeconds: stats.total_seconds ?? 0,
totalText: stats.human_readable_total ?? "—",
dailyAvgText: stats.human_readable_daily_average ?? "—",
bestDayText: stats.best_day?.text ?? "—",
totalSeconds: codingTotal,
totalText: formatSeconds(codingTotal),
dailyAvgText: formatSeconds(dailyAvg),
bestDayText: formatSeconds(bestCodingDay),
days,
topLangs: (stats.languages ?? []).slice(0, 5).map((l: { name: string }) => l.name),
topLangs: [],
};
} catch {
return null;
+322
View File
@@ -0,0 +1,322 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import eventsData from "@/data/events.json";
interface EventLink {
label: string;
url: string;
}
interface EventEntry {
name: string;
date: string;
location: string;
role: "speaker" | "attendee" | "organizer" | "mentor";
talk?: string;
description?: string;
blog?: string;
links?: EventLink[];
}
const events = (eventsData as EventEntry[]).sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
function fmtDate(iso: string) {
const d = new Date(iso);
return `${d.getDate()} ${d.toLocaleString("en-US", { month: "short" })} ${d.getFullYear()}`;
}
const roleLabels: Record<string, string> = {
speaker: "Speaker",
attendee: "Attendee",
organizer: "Organizer",
mentor: "Mentor",
};
const linkIcons: Record<string, string> = {
slides: `<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>`,
recording: `<polygon points="5 3 19 12 5 21 5 3"/>`,
video: `<polygon points="5 3 19 12 5 21 5 3"/>`,
photos: `<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>`,
github: `<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/>`,
website: `<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>`,
default: `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>`,
};
function iconForLabel(label: string): string {
const key = label.toLowerCase();
for (const [k, v] of Object.entries(linkIcons)) {
if (key.includes(k)) return v;
}
return linkIcons.default;
}
---
<BaseLayout title="Events" description="Conferences, meetups, and events attended or spoken at">
<div class="events-page">
<header class="events-header">
<h1>Events</h1>
<p class="events-desc">
Conferences, summits, and meetups I've attended or spoken at.
</p>
</header>
<div class="timeline">
{events.map((ev) => (
<div class={`tl-entry ev-${ev.role}`}>
<div class="tl-label-cell">
<span class="tl-type-label">{roleLabels[ev.role]}</span>
</div>
<div class="tl-rail-cell">
<div class="tl-dot" />
</div>
<div class="tl-card">
<div class="tl-card-header">
<div class="tl-dates">
{fmtDate(ev.date)}
<span class="tl-location">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{ev.location}
</span>
</div>
<h3 class="tl-title">{ev.name}</h3>
</div>
{ev.talk && <p class="event-talk">{ev.talk}</p>}
{ev.description && <p class="event-description">{ev.description}</p>}
{((ev.links && ev.links.length > 0) || ev.blog) && (
<div class="event-links">
{ev.links?.map((link) => (
<a href={link.url} class="event-link" target="_blank" rel="noopener noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><Fragment set:html={iconForLabel(link.label)} /></svg>
{link.label}
</a>
))}
{ev.blog && (
<a href={ev.blog} class="event-link blog-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
Blog Post
</a>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
</BaseLayout>
<style>
.events-page {
max-width: var(--max-w-page);
margin-inline: auto;
}
.events-header {
margin-bottom: var(--space-10);
}
.events-header h1 {
margin-bottom: var(--space-3);
}
.events-desc {
font-size: var(--text-lg);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
max-width: var(--max-w-prose);
}
/* ---- Timeline ---- */
.timeline {
display: flex;
flex-direction: column;
gap: var(--space-1);
position: relative;
}
.tl-entry {
display: grid;
grid-template-columns: 64px 24px 1fr;
gap: 0 var(--space-2);
position: relative;
}
.tl-label-cell {
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding-top: var(--space-5);
padding-right: var(--space-2);
}
.tl-type-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
white-space: nowrap;
}
.ev-speaker .tl-type-label { color: var(--accent); }
.ev-attendee .tl-type-label { color: #10b981; }
.ev-organizer .tl-type-label { color: #f59e0b; }
.ev-mentor .tl-type-label { color: #8b5cf6; }
.tl-rail-cell {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.tl-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--accent);
background: var(--bg);
position: relative;
z-index: 2;
margin-top: var(--space-5);
flex-shrink: 0;
}
.ev-speaker .tl-dot { border-color: var(--accent); }
.ev-attendee .tl-dot { border-color: #10b981; }
.ev-organizer .tl-dot { border-color: #f59e0b; }
.ev-mentor .tl-dot { border-color: #8b5cf6; }
.tl-rail-cell::after {
content: "";
position: absolute;
top: calc(var(--space-5) + 12px);
bottom: 0;
left: 50%;
width: 2px;
background: var(--border);
transform: translateX(-50%);
z-index: 1;
}
.tl-entry:last-child .tl-rail-cell::after { display: none; }
.tl-card {
padding: var(--space-4) var(--space-5);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-surface);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.tl-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow);
}
.tl-dates {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-muted);
margin-bottom: var(--space-1);
font-variant-numeric: tabular-nums;
flex-wrap: wrap;
}
.tl-location {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--text-muted);
}
.tl-title {
font-size: var(--text-base);
font-weight: 600;
margin-bottom: var(--space-1);
}
.event-talk {
font-size: var(--text-sm);
font-weight: 500;
font-style: italic;
color: var(--accent);
margin-bottom: var(--space-2);
}
.event-talk::before {
content: "\201C";
}
.event-talk::after {
content: "\201D";
}
.event-description {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.event-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.event-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-muted);
text-decoration: none;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-full);
transition: all var(--duration-fast) var(--ease-out);
}
.event-link:hover {
border-color: var(--accent);
color: var(--accent);
background-color: var(--accent-subtle);
}
.blog-link {
color: var(--accent);
border-color: var(--accent);
}
/* ---- Responsive ---- */
@media (max-width: 768px) {
.tl-entry {
grid-template-columns: 48px 16px 1fr;
}
.tl-type-label { font-size: 8px; }
.tl-dot {
width: 10px;
height: 10px;
}
.tl-dates {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
}
</style>
+6 -4
View File
@@ -2,7 +2,7 @@
import { getCollection } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
import HeroCard from "@/components/HeroCard.astro";
import GameOfLife from "@/components/GameOfLife.astro";
import MusicPlayer from "@/components/MusicPlayer.astro";
import ActivityRow from "@/components/ActivityRow.astro";
import RepoList from "@/components/RepoList.astro";
import RecentPosts from "@/components/RecentPosts.astro";
@@ -10,13 +10,15 @@ import RecentPosts from "@/components/RecentPosts.astro";
import { fetchGitHubUser, fetchGitHubRepos, fetchContributions } from "@/lib/github";
import { fetchWakaTimeData } from "@/lib/wakatime";
import { mergeActivity } from "@/lib/activity";
import { fetchListenBrainzData } from "@/lib/listenbrainz";
const [user, repos, contributions, wakatime, allPosts] = await Promise.all([
const [user, repos, contributions, wakatime, allPosts, listenBrainz] = await Promise.all([
fetchGitHubUser(),
fetchGitHubRepos(),
fetchContributions(),
fetchWakaTimeData(),
getCollection("posts", ({ data }) => !data.draft),
fetchListenBrainzData(),
]);
const activity = mergeActivity(contributions, wakatime);
@@ -28,7 +30,7 @@ const recentPosts = allPosts
<BaseLayout title="Home">
<div class="bento">
<!-- Row 1: Hero (profile+about+skills+links) | Game of Life widget -->
<!-- Row 1: Hero (profile+about+skills+links) | Now Playing widget -->
<div class="bento-8">
<HeroCard
name="Avinal Kumar"
@@ -39,7 +41,7 @@ const recentPosts = allPosts
</div>
<div class="bento-4">
<GameOfLife />
<MusicPlayer lb={listenBrainz} />
</div>
<!-- Row 2: Activity graph + stats (side by side in one card) -->
+1 -2
View File
@@ -11,7 +11,7 @@ export async function getStaticPaths() {
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
const { Content } = await render(post);
const wordCount = post.body?.split(/\s+/).length ?? 0;
const minutes = Math.max(1, Math.round(wordCount / 220));
@@ -27,7 +27,6 @@ const readingTime = `${minutes} min read`;
tags={post.data.tags}
image={post.data.image}
readingTime={readingTime}
headings={headings}
>
<Content />
</PostLayout>
+264 -46
View File
@@ -2,13 +2,21 @@
import BaseLayout from "@/layouts/BaseLayout.astro";
import resume from "@/data/resume.json";
const { basics, work, volunteer, education, skills, projects, languages, certificates } = resume as any;
const { basics, work, volunteer, education, skills, projects } = resume as any;
function fmtDate(iso: string) {
const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}
type Role = {
title: string;
startDate: string;
endDate?: string;
summary?: string;
highlights?: string[];
};
type TimelineEntry = {
type: "work" | "education" | "volunteer";
title: string;
@@ -19,20 +27,105 @@ type TimelineEntry = {
endDate?: string;
summary?: string;
highlights?: string[];
roles?: Role[];
};
type FlatEntry = {
key: string;
type: TimelineEntry["type"];
title: string;
subtitle: string;
url?: string;
location?: string;
startDate: string;
endDate?: string;
summary?: string;
highlights?: string[];
};
/**
* Groups flat entries by their `key` (organization/company name).
* Single-entry orgs stay flat; multi-entry orgs become grouped cards
* with nested roles sorted reverse-chronologically.
*/
function groupToTimeline(entries: FlatEntry[]): TimelineEntry[] {
const byKey = new Map<string, FlatEntry[]>();
for (const e of entries) {
if (!byKey.has(e.key)) byKey.set(e.key, []);
byKey.get(e.key)!.push(e);
}
const result: TimelineEntry[] = [];
for (const [, items] of byKey) {
const sorted = [...items].sort(
(a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime(),
);
const latest = sorted[0];
if (sorted.length === 1) {
result.push({
type: latest.type,
title: latest.title,
subtitle: latest.subtitle,
url: latest.url,
location: latest.location,
startDate: latest.startDate,
endDate: latest.endDate,
summary: latest.summary,
highlights: latest.highlights,
});
} else {
const earliest = sorted[sorted.length - 1];
result.push({
type: latest.type,
title: latest.subtitle,
subtitle: latest.subtitle,
url: latest.url,
location: latest.location,
startDate: latest.startDate,
endDate: latest.endDate,
roles: sorted.map((e) => ({
title: e.title,
startDate: e.startDate,
endDate: e.endDate,
summary: e.summary,
highlights: e.highlights,
})),
});
}
}
return result;
}
const workFlat: FlatEntry[] = work.map((w: any) => ({
key: w.name,
type: "work" as const,
title: w.position,
subtitle: w.name,
url: w.url || undefined,
location: w.location,
startDate: w.startDate,
endDate: w.endDate,
summary: w.summary,
highlights: w.highlights,
}));
const volunteerFlat: FlatEntry[] = volunteer.map((v: any) => ({
key: v.organization,
type: "volunteer" as const,
title: v.position,
subtitle: v.organization,
url: v.url || undefined,
location: undefined,
startDate: v.startDate,
endDate: v.endDate,
summary: v.summary,
highlights: undefined as string[] | undefined,
}));
const timeline: TimelineEntry[] = [
...work.map((w) => ({
type: "work" as const,
title: w.position,
subtitle: w.name,
url: w.url || undefined,
location: (w as any).location,
startDate: w.startDate,
endDate: w.endDate,
summary: w.summary,
highlights: w.highlights,
})),
...groupToTimeline(workFlat),
...groupToTimeline(volunteerFlat),
...education.map((e: any) => ({
type: "education" as const,
title: e.area ? `${e.studyType} in ${e.area}` : e.studyType,
@@ -44,24 +137,8 @@ const timeline: TimelineEntry[] = [
summary: e.score ? `CGPA: ${e.score}` : undefined,
highlights: undefined as string[] | undefined,
})),
...volunteer.map((v) => ({
type: "volunteer" as const,
title: v.position,
subtitle: v.organization,
url: v.url || undefined,
startDate: v.startDate || "2022-01-01",
endDate: v.endDate,
summary: v.summary,
highlights: undefined as string[] | undefined,
})),
].sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
const typeIcons: Record<string, string> = {
work: "briefcase",
education: "graduation",
volunteer: "heart",
};
const typeLabels: Record<string, string> = {
work: "Work",
education: "Education",
@@ -101,34 +178,72 @@ const typeLabels: Record<string, string> = {
<section class="timeline-section">
<h2>Experience & Education</h2>
<div class="timeline">
{timeline.map((entry) => (
{timeline.map((entry) => {
const isActive = !entry.endDate;
return (
<div class={`tl-entry tl-${entry.type}`}>
<div class="tl-label-cell">
<span class="tl-type-label">{typeLabels[entry.type]}</span>
</div>
<div class="tl-rail-cell">
<div class="tl-dot" />
<div class:list={["tl-dot", { "tl-dot-active": isActive }]} />
</div>
<div class="tl-card">
<div class="tl-card-header">
<div class="tl-dates">
{fmtDate(entry.startDate)} — {entry.endDate ? fmtDate(entry.endDate) : "Present"}
</div>
<h3 class="tl-title">{entry.title}</h3>
<p class="tl-subtitle">
{entry.url ? <a href={entry.url}>{entry.subtitle}</a> : entry.subtitle}
{entry.location && <span class="tl-location"> · {entry.location}</span>}
</p>
</div>
{entry.summary && <p class="tl-summary">{entry.summary}</p>}
{entry.highlights && entry.highlights.length > 0 && (
<ul class="tl-highlights">
{entry.highlights.map((h) => <li>{h}</li>)}
</ul>
{entry.roles ? (
/* Grouped card: multiple roles at the same org */
<Fragment>
<div class="tl-card-header">
<div class="tl-dates">
{fmtDate(entry.roles[entry.roles.length - 1].startDate)} — {entry.endDate ? fmtDate(entry.endDate) : "Present"}
</div>
<h3 class="tl-title tl-org-title">
{entry.url ? <a href={entry.url}>{entry.subtitle}</a> : entry.subtitle}
</h3>
{entry.location && <p class="tl-subtitle"><span class="tl-location">{entry.location}</span></p>}
</div>
<div class="tl-roles">
{entry.roles.map((role, i) => (
<div class:list={["tl-role", { "tl-role-current": i === 0 }]}>
<div class="tl-role-header">
<h4 class="tl-role-title">{role.title}</h4>
<span class="tl-role-dates">
{fmtDate(role.startDate)} — {role.endDate ? fmtDate(role.endDate) : "Present"}
</span>
</div>
{role.summary && <p class="tl-summary">{role.summary}</p>}
{role.highlights && role.highlights.length > 0 && (
<ul class="tl-highlights">
{role.highlights.map((h) => <li>{h}</li>)}
</ul>
)}
</div>
))}
</div>
</Fragment>
) : (
/* Single card */
<Fragment>
<div class="tl-card-header">
<div class="tl-dates">
{fmtDate(entry.startDate)} — {entry.endDate ? fmtDate(entry.endDate) : "Present"}
</div>
<h3 class="tl-title">{entry.title}</h3>
<p class="tl-subtitle">
{entry.url ? <a href={entry.url}>{entry.subtitle}</a> : entry.subtitle}
{entry.location && <span class="tl-location"> · {entry.location}</span>}
</p>
</div>
{entry.summary && <p class="tl-summary">{entry.summary}</p>}
{entry.highlights && entry.highlights.length > 0 && (
<ul class="tl-highlights">
{entry.highlights.map((h) => <li>{h}</li>)}
</ul>
)}
</Fragment>
)}
</div>
</div>
))}
)})}
</div>
</section>
@@ -349,6 +464,40 @@ const typeLabels: Record<string, string> = {
.tl-education .tl-dot { border-color: #10b981; background: #10b981; }
.tl-volunteer .tl-dot { border-color: #f59e0b; }
.tl-dot-active {
animation: pulse-dot 2s ease-out infinite;
}
.tl-work .tl-dot-active {
background: var(--accent);
border-color: var(--accent);
--pulse-color: var(--accent);
}
.tl-education .tl-dot-active {
background: #10b981;
border-color: #10b981;
--pulse-color: #10b981;
}
.tl-volunteer .tl-dot-active {
background: #f59e0b;
border-color: #f59e0b;
--pulse-color: #f59e0b;
}
@keyframes pulse-dot {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--pulse-color) 50%, transparent);
}
70% {
box-shadow: 0 0 0 8px color-mix(in srgb, var(--pulse-color) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--pulse-color) 0%, transparent);
}
}
.tl-card {
padding: var(--space-4) var(--space-5);
border: 1px solid var(--border);
@@ -426,6 +575,75 @@ const typeLabels: Record<string, string> = {
font-weight: 700;
}
/* ---- Grouped roles ---- */
.tl-org-title {
font-size: var(--text-lg);
}
.tl-org-title a {
color: var(--accent);
text-decoration: none;
}
.tl-org-title a:hover {
text-decoration: underline;
}
.tl-roles {
display: flex;
flex-direction: column;
gap: 0;
margin-top: var(--space-4);
}
.tl-role {
padding: var(--space-3) 0 var(--space-3) var(--space-4);
border-left: 2px solid var(--border);
position: relative;
}
.tl-role::before {
content: "";
position: absolute;
left: -5px;
top: calc(var(--space-3) + 6px);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
}
.tl-role-current::before {
background: var(--accent);
}
.tl-role-current {
border-left-color: var(--accent);
}
.tl-role-header {
display: flex;
align-items: baseline;
gap: var(--space-3);
flex-wrap: wrap;
}
.tl-role-title {
font-size: var(--text-sm);
font-weight: 600;
}
.tl-role-current .tl-role-title {
font-size: var(--text-base);
color: var(--text);
}
.tl-role-dates {
font-size: var(--text-xs);
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ---- Two column: Skills + Projects ---- */
.two-col {
display: grid;