diff --git a/content/desktop.json b/content/desktop.json index 20e8276..89e919b 100644 --- a/content/desktop.json +++ b/content/desktop.json @@ -16,6 +16,7 @@ { "label": "portfolio", "icon": "ph:briefcase-duotone", "open": { "page": "/portfolio" } }, { "label": "projects", "icon": "ph:folder-open-duotone", "open": { "page": "/projects" } }, { "label": "hackathons", "icon": "ph:trophy-duotone", "open": { "page": "/projects/hackathons" } }, + { "label": "research", "icon": "ph:microscope-duotone", "open": { "page": "/projects/research" } }, { "label": "blog", "icon": "ph:notebook-duotone", "open": { "page": "/blog" } }, { "label": "links", "icon": "ph:qr-code-duotone", "open": { "page": "/links" } } ] diff --git a/content/projects/hackathons.json b/content/projects/hackathons.json index 142acb2..7c21a38 100644 --- a/content/projects/hackathons.json +++ b/content/projects/hackathons.json @@ -37,10 +37,17 @@ "description": "A sustainability platform that helps users detect and report greenwashing, misleading environmental claims made by companies and products.", "image": "/img/hoya26.png", "tags": ["Svelte", "TypeScript", "Python", "Flask", "Tailwind", "Sustainability"], - "featured": true, "event": { "name": "HoyaHax 2026", "university": "Georgetown University", "location": "Washington, D.C.", "year": "2026" }, "links": { "demo": "https://ethix.sirblob.co/", "code": "https://github.com/SirBlobby/Hoya26", "devpost": "https://devpost.com/software/ethix" } }, + { + "name": "groSharey", + "description": "Split grocery costs with your roommates: scan a receipt, let AI assign each item to whoever asked for it, and everyone sees exactly what they owe, with Venmo and PayPal payments and email reminders.", + "image": "/hacks/grosharey.png", + "tags": ["React", "TypeScript", "Expo", "Flask", "Python", "MongoDB", "Gemini", "ElevenLabs"], + "event": { "name": "Technica 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" }, + "links": { "demo": "https://grosharey-frontend.vercel.app/", "code": "https://github.com/SirBlobby/GroSharey", "devpost": "https://devpost.com/software/cool_project" } + }, { "name": "Food Decisive", "description": "Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive.", @@ -61,6 +68,7 @@ { "name": "Fauxcall", "description": "This product is perfect for situations where you are walking at night and you feel unsafe from someone. Fauxcall lets users create a convincing fake phone call to deter potential attackers and provide a quick escape mechanism.", + "image": "/hacks/fauxcall.png", "tags": ["MongoDB", "Next.js", "Python", "React", "Sesame", "Skeleton", "Twilio"], "event": { "name": "HooHacks 2025", "university": "University of Virginia", "location": "Charlottesville, VA", "year": "2025" }, "links": { "code": "https://github.com/SirBlobby/HooHacks-12", "devpost": "https://devpost.com/software/fauxcall" } diff --git a/content/projects/opensource.json b/content/projects/opensource.json index adcdfec..955cd51 100644 --- a/content/projects/opensource.json +++ b/content/projects/opensource.json @@ -6,8 +6,8 @@ "period": "2026 – Present", "featured": true, "tags": ["Rust", "SvelteKit", "Yjs", "Typst", "Self-Hosted"], - "image": "/projects/typstdrive.png", - "links": { "code": "https://github.com/SirBlobby/TypstDrive" } + "image": "/projects/typstdrive-editor.png", + "links": { "code": "https://github.com/SirBlobby/TypstDrive", "docs": "https://sirblobby.github.io/TypstDrive/" } }, { "name": "Typst Desktop", @@ -23,14 +23,12 @@ "name": "Pkit", "description": "CLI toolkit and utilities (Rust project), a small developer-focused CLI.", "tags": ["Rust", "CLI"], - "image": "https://icons.veryicon.com/png/o/business/vscode-program-item-icon/rust-1.png", "links": { "code": "https://github.com/dead-projects-inc/pkit-cli", "demo": "https://pkit.sirblob.co/" } }, { "name": "OBookLLM", "description": "OBookLLM is a powerful, self-hosted, offline-capable alternative to NotebookLLM.", "tags": ["Python", "LLM", "AI", "NotebookLLM"], - "image": "https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg", "links": { "code": "https://github.com/SirBlobby/OBookLLM" } } ] diff --git a/content/projects/organizations.json b/content/projects/organizations.json new file mode 100644 index 0000000..307ca24 --- /dev/null +++ b/content/projects/organizations.json @@ -0,0 +1,28 @@ +[ + { + "name": "ARC - Autonomous Robotics Club", + "organization": "Secretary of ARC · Team Lead of the RAVC Team", + "description": "George Mason University's Autonomous Robotics Club, where members pick a project and build autonomous robots as a team, no experience required. Won the 2026 Raytheon Autonomous Vehicle Competition with a drone that launches from and lands on a moving ground vehicle, navigating with visual-inertial odometry (VIO) instead of GPS.", + "tags": ["Robotics", "Autonomous Vehicles", "Drones", "VIO", "Student Organization"], + "featured": true, + "awards": [{ "track": "2026 Raytheon Autonomous Vehicle Competition", "place": "1st Place" }], + "gallery": ["/organizations/arc-1.jpg", "/organizations/arc-2.jpg", "/organizations/arc-3.jpg"], + "links": { + "website": "https://gmu.autonomousrobotics.club/", + "article": "https://www.gmu.edu/news/2026-05/george-mason-students-fly-first-place-no-gps-no-grade-and-almost-no-problems" + }, + "socials": [ + { "name": "GitHub", "icon": "mdi:github", "url": "https://github.com/ARC-GMU" }, + { "name": "Instagram", "icon": "mdi:instagram", "url": "https://www.instagram.com/arc.gmu/" }, + { "name": "LinkedIn", "icon": "mdi:linkedin", "url": "https://www.linkedin.com/company/arcgmu/" }, + { "name": "CREATE Lab", "icon": "ph:flask-duotone", "url": "https://create.autonomousrobotics.club/projects/autonomous-robotics-club" } + ] + }, + { + "name": "GMU Minecraft Club", + "organization": "President", + "description": "George Mason University's Minecraft club, bringing students together to play, build, and run community servers.", + "tags": ["Minecraft", "Community", "Gaming"], + "links": { "website": "https://gmmc.club/" } + } +] diff --git a/content/projects/packages.json b/content/projects/packages.json index 2faafd7..79d4ad2 100644 --- a/content/projects/packages.json +++ b/content/projects/packages.json @@ -4,18 +4,18 @@ "description": "Selenium automation utilities for MCP workflows, packaged for reuse.", "tags": ["TypeScript", "Selenium"], "featured": true, - "links": { "package": "https://www.npmjs.com/package/@sirblob/mcp-selenium" } + "links": { "package": "https://www.npmjs.com/package/@sirblob/mcp-selenium", "code": "https://github.com/SirBlobby/mcp-selenium" } }, { "name": "PokemonTCGAPI", "description": "Unofficial NPM package wrapper for the PokemonTCG API.", "tags": ["TypeScript", "Node.js"], - "links": { "package": "https://www.npmjs.com/package/@bosstop/pokemontcgapi" } + "links": { "package": "https://www.npmjs.com/package/@bosstop/pokemontcgapi", "code": "https://github.com/SirBlobby/PokemonTCGAPI" } }, { "name": "MCSS TS API", "description": "TypeScript API for MC Server Soft services, published to NPM.", "tags": ["TypeScript", "Node.js"], - "links": { "package": "https://www.npmjs.com/package/@mcserversoft/mcss-api" } + "links": { "package": "https://www.npmjs.com/package/@mcserversoft/mcss-api", "code": "https://github.com/mcserversoft-community/mcss-api-js" } } ] diff --git a/content/projects/research.json b/content/projects/research.json index d390c92..720989d 100644 --- a/content/projects/research.json +++ b/content/projects/research.json @@ -1,14 +1,31 @@ [ { - "name": "Astroswarm", - "organization": "CREATE Lab", + "name": "Autonomous Swarm Robotics Center", + "shortName": "ASRC", + "lab": { "name": "CREATE Lab", "url": "https://create.autonomousrobotics.club/" }, + "institution": "George Mason University", + "role": "Undergraduate Research Assistant", "period": "May 2026 – Present", - "description": "A visual coding game that turns swarm control into a playable simulator, generating trial data at scale on which algorithms best defend a target. Part of a research program on controlling robot fleets with one operator.", - "tags": ["Python", "ROS", "Swarm Robotics"], - "featured": true, - "links": { - "demo": "https://astroswarm.autonomousrobotics.club/", - "article": "https://cec.gmu.edu/news/2026-07/new-art-herding-robots" - } + "description": "Research group working on swarm robotics, with a focus on emergent behaviors and controlling robot fleets with one operator.", + "focus": ["Swarm Robotics", "Emergent Behavior", "Multi-Robot Control", "ROS"], + "links": [ + { "label": "CREATE Lab", "url": "https://create.autonomousrobotics.club/" }, + { "label": "GitHub", "url": "https://github.com/GMU-ASRC" }, + { "label": "My Profile", "url": "https://create.autonomousrobotics.club/team/gagan-manjunatha" } + ], + "projects": [ + { + "name": "Astroswarm", + "period": "May 2026 – Present", + "description": "A visual coding game that turns swarm control into a playable simulator, generating trial data at scale on which algorithms best defend a target.", + "tags": ["Python", "ROS", "Swarm Robotics"], + "featured": true, + "links": { + "demo": "https://astroswarm.autonomousrobotics.club/", + "code": "https://github.com/GMU-ASRC/astro-swarm", + "article": "https://cec.gmu.edu/news/2026-07/new-art-herding-robots" + } + } + ] } ] diff --git a/src/lib/components/projects/ProjectEntry.svelte b/src/lib/components/projects/ProjectEntry.svelte index 28b0f43..656eaed 100644 --- a/src/lib/components/projects/ProjectEntry.svelte +++ b/src/lib/components/projects/ProjectEntry.svelte @@ -1,16 +1,28 @@ diff --git a/src/lib/components/research/ResearchGroup.svelte b/src/lib/components/research/ResearchGroup.svelte new file mode 100644 index 0000000..ce168d0 --- /dev/null +++ b/src/lib/components/research/ResearchGroup.svelte @@ -0,0 +1,163 @@ + + + + {group.name} + + + ~/{group.institution}/{group.lab.name}/{group.shortName} + + + + + Research Group + {group.shortName} + + + {group.role} + {group.period} + + {group.description} + + + {#each group.focus as area, index (area)} + + {/each} + + + + {#each group.links as link (link.url)} + {link.label} + {/each} + + + + + $ ls ~/research/{group.shortName.toLowerCase()}/projects + + + + {#each group.projects as project, index (project.name)} + + {index === group.projects.length - 1 ? '└──' : '├──'} + + + + + {/each} + + + + diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index 0806452..c615b64 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -4,20 +4,24 @@ import leadershipData from '#content/leadership.json'; import aboutData from '#content/about.json'; import desktopData from '#content/desktop.json'; import blogData from '#content/blog.json'; +import musicData from '#content/music.json'; import openSourceData from '#content/projects/opensource.json'; import packagesData from '#content/projects/packages.json'; import hackathonsData from '#content/projects/hackathons.json'; import researchData from '#content/projects/research.json'; +import organizationsData from '#content/projects/organizations.json'; import type { AboutContent, BlogSettings, DesktopContent, Experience, Leadership, + Playlist, Profile, Project, ProjectCategory, - ProjectCategoryId + ProjectCategoryId, + ResearchGroup } from './types'; export type * from './types'; @@ -28,6 +32,8 @@ export const leadership: Leadership[] = leadershipData; export const about: AboutContent = aboutData; export const desktop: DesktopContent = desktopData; export const blogSettings: BlogSettings = blogData; +export const playlist: Playlist = musicData; +export const researchGroups: ResearchGroup[] = researchData; function featuredFirst(projects: Project[]): Project[] { return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured)); @@ -60,7 +66,18 @@ export const projectCategories: ProjectCategory[] = [ title: 'Research', icon: 'ph:microscope-duotone', summary: 'Research roles, projects, and publications.', - projects: featuredFirst(researchData) + projects: featuredFirst( + researchGroups.flatMap((group) => + group.projects.map((project) => ({ ...project, organization: project.organization ?? group.shortName })) + ) + ) + }, + { + id: 'organizations', + title: 'Organizations', + icon: 'ph:users-three-duotone', + summary: 'Clubs and teams I lead and help run.', + projects: featuredFirst(organizationsData) } ]; diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index 10119f8..077c88c 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -18,6 +18,7 @@ export interface Profile { title: string; email: string; avatar: string; + photo?: string; bio: string; tagline: string; education: Education; @@ -55,6 +56,8 @@ export interface ProjectLinks { devpost?: string; package?: string; article?: string; + docs?: string; + website?: string; } export interface HackathonEvent { @@ -82,9 +85,29 @@ export interface Project { event?: HackathonEvent; awards?: Award[]; demoMayBeOffline?: boolean; + socials?: Social[]; + gallery?: string[]; } -export type ProjectCategoryId = 'opensource' | 'packages' | 'hackathons' | 'research'; +export interface ResearchLab { + name: string; + url: string; +} + +export interface ResearchGroup { + name: string; + shortName: string; + lab: ResearchLab; + institution: string; + role: string; + period: string; + description: string; + focus: string[]; + links: LabeledLink[]; + projects: Project[]; +} + +export type ProjectCategoryId = 'opensource' | 'packages' | 'hackathons' | 'research' | 'organizations'; export interface ProjectCategory { id: ProjectCategoryId; @@ -113,6 +136,7 @@ export interface RatedTitle { } export interface AboutContent { + anilistUsername?: string; hobbies: Hobby[]; games: Game[]; shows: RatedTitle[]; @@ -145,6 +169,10 @@ export interface DesktopContent { startupCommand: string; } +export interface Playlist { + videos: string[]; +} + export interface BlogSettings { name: string; tagline: string; diff --git a/src/lib/github/types.ts b/src/lib/github/types.ts new file mode 100644 index 0000000..5f5b866 --- /dev/null +++ b/src/lib/github/types.ts @@ -0,0 +1,4 @@ +export interface RepoStats { + stars: number; + forks: number; +} diff --git a/src/lib/server/github.ts b/src/lib/server/github.ts new file mode 100644 index 0000000..31653f9 --- /dev/null +++ b/src/lib/server/github.ts @@ -0,0 +1,41 @@ +import type { RepoStats } from '#lib/github/types'; + +const cacheDuration = 60 * 60 * 1000; +const githubRepoPattern = /^https:\/\/github\.com\/([^/]+\/[^/#?]+)/; + +const statsCache = new Map(); + +function githubRepoPath(url: string): string | null { + const match = url.match(githubRepoPattern); + return match ? match[1].replace(/\.git$/, '') : null; +} + +async function fetchRepoStats(repoPath: string): Promise { + const cached = statsCache.get(repoPath); + if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.stats; + + try { + const response = await fetch(`https://api.github.com/repos/${repoPath}`, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'portfolio-website' } + }); + if (!response.ok) return cached?.stats ?? null; + + const repo = await response.json(); + const stats: RepoStats = { stars: repo.stargazers_count, forks: repo.forks_count }; + statsCache.set(repoPath, { stats, fetchedAt: Date.now() }); + return stats; + } catch { + return cached?.stats ?? null; + } +} + +export async function getRepoStats(codeUrls: string[]): Promise> { + const results = await Promise.all( + codeUrls.map(async (codeUrl) => { + const repoPath = githubRepoPath(codeUrl); + const stats = repoPath ? await fetchRepoStats(repoPath) : null; + return [codeUrl, stats] as const; + }) + ); + return Object.fromEntries(results.filter((result): result is readonly [string, RepoStats] => result[1] !== null)); +} diff --git a/src/lib/server/npm.ts b/src/lib/server/npm.ts new file mode 100644 index 0000000..b9dbc31 --- /dev/null +++ b/src/lib/server/npm.ts @@ -0,0 +1,62 @@ +const cacheDuration = 60 * 60 * 1000; +const dayDuration = 24 * 60 * 60 * 1000; +const maxRangeDays = 540; +const npmPackagePattern = /^https:\/\/www\.npmjs\.com\/package\/((?:@[^/]+\/)?[^/#?]+)/; + +const downloadsCache = new Map(); + +function npmPackageName(url: string): string | null { + return url.match(npmPackagePattern)?.[1] ?? null; +} + +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +async function fetchCreatedDate(packageName: string): Promise { + const response = await fetch(`https://registry.npmjs.org/${packageName.replace('/', '%2f')}`); + if (!response.ok) throw new Error(`npm registry returned ${response.status} for ${packageName}`); + const metadata = await response.json(); + return new Date(metadata.time.created); +} + +async function fetchDownloadsBetween(packageName: string, start: Date, end: Date): Promise { + const range = `${formatDate(start)}:${formatDate(end)}`; + const response = await fetch(`https://api.npmjs.org/downloads/point/${range}/${packageName}`); + if (!response.ok) throw new Error(`npm downloads returned ${response.status} for ${packageName}`); + const result = await response.json(); + return result.downloads ?? 0; +} + +async function fetchTotalDownloads(packageName: string): Promise { + const cached = downloadsCache.get(packageName); + if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.downloads; + + try { + const today = new Date(); + const rangeRequests: Promise[] = []; + for (let rangeStart = await fetchCreatedDate(packageName); rangeStart <= today; ) { + const rangeEnd = new Date(Math.min(rangeStart.getTime() + (maxRangeDays - 1) * dayDuration, today.getTime())); + rangeRequests.push(fetchDownloadsBetween(packageName, rangeStart, rangeEnd)); + rangeStart = new Date(rangeEnd.getTime() + dayDuration); + } + + const downloads = (await Promise.all(rangeRequests)).reduce((total, count) => total + count, 0); + downloadsCache.set(packageName, { downloads, fetchedAt: Date.now() }); + return downloads; + } catch (error) { + console.error(`npm download count failed for ${packageName}:`, error); + return cached?.downloads ?? null; + } +} + +export async function getPackageDownloads(packageUrls: string[]): Promise> { + const results = await Promise.all( + packageUrls.map(async (packageUrl) => { + const packageName = npmPackageName(packageUrl); + const downloads = packageName ? await fetchTotalDownloads(packageName) : null; + return [packageUrl, downloads] as const; + }) + ); + return Object.fromEntries(results.filter((result): result is readonly [string, number] => result[1] !== null)); +} diff --git a/src/lib/site/pages.ts b/src/lib/site/pages.ts index 56fd2c5..87fbef8 100644 --- a/src/lib/site/pages.ts +++ b/src/lib/site/pages.ts @@ -10,13 +10,13 @@ export interface SitePage { } export const sitePages: SitePage[] = [ - { name: 'about', path: '/about', title: 'About', description: 'Background, hobbies, games, shows and movies.', icon: 'ph:user-circle-duotone' }, + { name: 'about', path: '/about', title: 'About', description: 'Background, hobbies, games, shows, movies and anime.', icon: 'ph:user-circle-duotone' }, { name: 'portfolio', path: '/portfolio', title: 'Portfolio', description: 'Profile, experience, skills and contact info.', icon: 'ph:briefcase-duotone' }, { name: 'projects', path: '/projects', title: 'Projects', - description: 'Open source, packages, hackathons and research.', + description: 'Open source, packages, hackathons, research and organizations.', icon: 'ph:folder-open-duotone', children: projectCategories.map((category) => ({ name: category.id, diff --git a/src/lib/terminal/commands/info.ts b/src/lib/terminal/commands/info.ts index 16bc798..42a6c5c 100644 --- a/src/lib/terminal/commands/info.ts +++ b/src/lib/terminal/commands/info.ts @@ -59,7 +59,7 @@ export const infoCommands: Command[] = [ { name: 'projects', description: 'List projects, optionally by category', - usage: 'projects [opensource|packages|hackathons|research]', + usage: 'projects [opensource|packages|hackathons|research|organizations]', argumentOptions: () => projectCategories.map((category) => category.id), run: (args, context) => { const categories = args[0] ? [findProjectCategory(args[0])] : projectCategories; diff --git a/src/routes/projects/+page.svelte b/src/routes/projects/+page.svelte index b7d83a7..2bc4e2a 100644 --- a/src/routes/projects/+page.svelte +++ b/src/routes/projects/+page.svelte @@ -10,12 +10,13 @@ function countLabel(id: string, count: number): string { if (id === 'hackathons') return count === 1 ? 'event' : 'events'; if (id === 'packages') return 'published'; + if (id === 'organizations') return count === 1 ? 'organization' : 'organizations'; return count === 1 ? 'project' : 'projects'; } - + @@ -46,7 +47,7 @@
{group.description}