diff --git a/.env.example b/.env.example index 25b3274..01be126 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/.github/workflows/builds.yaml b/.github/workflows/builds.yaml index 6a3ee1d..49bf905 100644 --- a/.github/workflows/builds.yaml +++ b/.github/workflows/builds.yaml @@ -1,10 +1,9 @@ -name: Check build +name: CI on: push: branches: ["main"] pull_request: - workflow_dispatch: jobs: diff --git a/.gitignore b/.gitignore index d97ade7..da65d6d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,5 @@ dist/ # OS .DS_Store -# Legacy Hugo -public/ +# Personal files +Profile.pdf diff --git a/README.md b/README.md index 3b879c9..0218578 100644 --- a/README.md +++ b/README.md @@ -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-filtered post listings | | `/posts///` | 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 diff --git a/src/components/ActivityRow.astro b/src/components/ActivityRow.astro index 3d54131..94595be 100644 --- a/src/components/ActivityRow.astro +++ b/src/components/ActivityRow.astro @@ -18,10 +18,15 @@ const hasWaka = activity.wakatime.available;

- + + + + + + Activity

- past year + past year
{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 ( -
-
-
-
- ); + 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
; + const isAlive = hasGh || hasWk; + return
; })}
))} @@ -68,6 +73,13 @@ const hasWaka = activity.wakatime.available;
+ {hasWaka && ( + + Both +
+
+
+ )} {hasWaka && (
@@ -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; + } + + diff --git a/src/components/Footer.astro b/src/components/Footer.astro index f69c9e0..e131954 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -7,13 +7,10 @@ const year = new Date().getFullYear(); - + + + Report an issue + @@ -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); - } diff --git a/src/components/GameOfLife.astro b/src/components/GameOfLife.astro deleted file mode 100644 index 67afb13..0000000 --- a/src/components/GameOfLife.astro +++ /dev/null @@ -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. - */ ---- - -
-

- - Game of Life -

- -

hover to bring cells alive

-
- - - - diff --git a/src/components/MusicPlayer.astro b/src/components/MusicPlayer.astro new file mode 100644 index 0000000..15cdd7e --- /dev/null +++ b/src/components/MusicPlayer.astro @@ -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`; +} +--- + +
+
+

+ + Listening +

+
+ + {lb.available ? ( +
+
+ +
+ + {lb.recentTracks.length > 0 && ( +
+ Recent +
    + {lb.recentTracks.slice(0, 4).map((t) => ( +
  • + {t.coverArtUrl && ( + + )} +
    + + {t.listenUrl ? ( + {t.trackName} + ) : t.trackName} + + {t.artistName} +
    + {timeAgo(t.listenedAt)} +
  • + ))} +
+
+ )} + + {lb.recentTracks.length === 0 && ( +
+ No recent listens yet +
+ )} +
+ ) : ( +
+ + Connect ListenBrainz to see listening activity +
+ )} +
+ + diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 62ffb2e..7bc836c 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -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" }, diff --git a/src/config/theme.ts b/src/config/theme.ts index 66dab2c..c3af65e 100644 --- a/src/config/theme.ts +++ b/src/config/theme.ts @@ -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", diff --git a/src/data/events.json b/src/data/events.json new file mode 100644 index 0000000..85dfd76 --- /dev/null +++ b/src/data/events.json @@ -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/" } + ] + } +] diff --git a/src/data/resume.json b/src/data/resume.json index 18c64f9..7b553f2 100644 --- a/src/data/resume.json +++ b/src/data/resume.json @@ -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": [ diff --git a/src/layouts/PostLayout.astro b/src/layouts/PostLayout.astro index 6d55992..182cd76 100644 --- a/src/layouts/PostLayout.astro +++ b/src/layouts/PostLayout.astro @@ -10,7 +10,6 @@ interface Props { tags?: string[]; image?: string; readingTime: string; - headings: { depth: number; slug: string; text: string }[]; } const { diff --git a/src/lib/activity.ts b/src/lib/activity.ts index 4889dad..219fc74 100644 --- a/src/lib/activity.ts +++ b/src/lib/activity.ts @@ -1,4 +1,4 @@ -import type { ContributionData, ContributionDay } from "./github"; +import type { ContributionData } from "./github"; import type { WakaTimeData } from "./wakatime"; export interface ActivityDay { diff --git a/src/lib/listenbrainz.ts b/src/lib/listenbrainz.ts new file mode 100644 index 0000000..5476e69 --- /dev/null +++ b/src/lib/listenbrainz.ts @@ -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 { + 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; + } +} diff --git a/src/lib/wakatime.ts b/src/lib/wakatime.ts index 8a16a63..38161f6 100644 --- a/src/lib/wakatime.ts +++ b/src/lib/wakatime.ts @@ -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> { - 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(); +export async function fetchWakaTimeData(): Promise { 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(); + 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 { - 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; diff --git a/src/pages/events.astro b/src/pages/events.astro new file mode 100644 index 0000000..27c8e06 --- /dev/null +++ b/src/pages/events.astro @@ -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 = { + speaker: "Speaker", + attendee: "Attendee", + organizer: "Organizer", + mentor: "Mentor", +}; + +const linkIcons: Record = { + slides: ``, + recording: ``, + video: ``, + photos: ``, + github: ``, + website: ``, + default: ``, +}; + +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; +} +--- + + +
+
+

Events

+

+ Conferences, summits, and meetups I've attended or spoken at. +

+
+ +
+ {events.map((ev) => ( +
+
+ {roleLabels[ev.role]} +
+
+
+
+
+
+
+ {fmtDate(ev.date)} + + + {ev.location} + +
+

{ev.name}

+
+ + {ev.talk &&

{ev.talk}

} + {ev.description &&

{ev.description}

} + + {((ev.links && ev.links.length > 0) || ev.blog) && ( + + )} +
+
+ ))} +
+
+ + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 423d48d..5c6fafe 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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
- +
- +
diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index 5e9666d..5e77ca1 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -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} > diff --git a/src/pages/resume.astro b/src/pages/resume.astro index 9c001e7..866695e 100644 --- a/src/pages/resume.astro +++ b/src/pages/resume.astro @@ -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(); + 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 = { - work: "briefcase", - education: "graduation", - volunteer: "heart", -}; - const typeLabels: Record = { work: "Work", education: "Education", @@ -101,34 +178,72 @@ const typeLabels: Record = {

Experience & Education

- {timeline.map((entry) => ( + {timeline.map((entry) => { + const isActive = !entry.endDate; + return (
{typeLabels[entry.type]}
-
+
-
-
- {fmtDate(entry.startDate)} — {entry.endDate ? fmtDate(entry.endDate) : "Present"} -
-

{entry.title}

-

- {entry.url ? {entry.subtitle} : entry.subtitle} - {entry.location && · {entry.location}} -

-
- {entry.summary &&

{entry.summary}

} - {entry.highlights && entry.highlights.length > 0 && ( -
    - {entry.highlights.map((h) =>
  • {h}
  • )} -
+ {entry.roles ? ( + /* Grouped card: multiple roles at the same org */ + +
+
+ {fmtDate(entry.roles[entry.roles.length - 1].startDate)} — {entry.endDate ? fmtDate(entry.endDate) : "Present"} +
+

+ {entry.url ? {entry.subtitle} : entry.subtitle} +

+ {entry.location &&

{entry.location}

} +
+
+ {entry.roles.map((role, i) => ( +
+
+

{role.title}

+ + {fmtDate(role.startDate)} — {role.endDate ? fmtDate(role.endDate) : "Present"} + +
+ {role.summary &&

{role.summary}

} + {role.highlights && role.highlights.length > 0 && ( +
    + {role.highlights.map((h) =>
  • {h}
  • )} +
+ )} +
+ ))} +
+
+ ) : ( + /* Single card */ + +
+
+ {fmtDate(entry.startDate)} — {entry.endDate ? fmtDate(entry.endDate) : "Present"} +
+

{entry.title}

+

+ {entry.url ? {entry.subtitle} : entry.subtitle} + {entry.location && · {entry.location}} +

+
+ {entry.summary &&

{entry.summary}

} + {entry.highlights && entry.highlights.length > 0 && ( +
    + {entry.highlights.map((h) =>
  • {h}
  • )} +
+ )} +
)}
- ))} + )})}
@@ -349,6 +464,40 @@ const typeLabels: Record = { .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 = { 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;