my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(github): add GitHub profile page with full details

- New /github route showing profile stats, avatar, bio
- Notable repositories grid with language colors, stars, topics
- GitHub API service with caching
- Navigation link in header

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+493
+1
src/lib/data/navItems.ts
··· 5 5 export const navItems: NavItem[] = [ 6 6 { href: '/', label: 'Home', iconPath: 'Home' }, 7 7 { href: '/site/meta', label: 'Site Meta', iconPath: 'Info' }, 8 + { href: '/github', label: 'GitHub', iconPath: 'Github' }, 8 9 { href: '/archive', label: 'Archive', iconPath: 'Archive' } 9 10 ];
+127
src/lib/services/github/fetch.ts
··· 1 + /** 2 + * GitHub API fetch functions with caching 3 + */ 4 + import type { GitHubProfile, GitHubRepo, GitHubLanguageStats } from './types'; 5 + 6 + const GITHUB_API_BASE = 'https://api.github.com'; 7 + const CACHE_TTL = 1000 * 60 * 15; // 15 minutes 8 + 9 + interface CacheEntry<T> { 10 + data: T; 11 + timestamp: number; 12 + } 13 + 14 + const cache = new Map<string, CacheEntry<unknown>>(); 15 + 16 + function getCached<T>(key: string): T | null { 17 + const entry = cache.get(key) as CacheEntry<T> | undefined; 18 + if (!entry) return null; 19 + if (Date.now() - entry.timestamp > CACHE_TTL) { 20 + cache.delete(key); 21 + return null; 22 + } 23 + return entry.data; 24 + } 25 + 26 + function setCached<T>(key: string, data: T): void { 27 + cache.set(key, { data, timestamp: Date.now() }); 28 + } 29 + 30 + async function githubFetch<T>(endpoint: string, fetchFn?: typeof fetch): Promise<T> { 31 + const cacheKey = endpoint; 32 + const cached = getCached<T>(cacheKey); 33 + if (cached) return cached; 34 + 35 + const fetchImpl = fetchFn || globalThis.fetch; 36 + const response = await fetchImpl(`${GITHUB_API_BASE}${endpoint}`, { 37 + headers: { 38 + Accept: 'application/vnd.github.v3+json', 39 + // Add user agent for rate limits 40 + 'User-Agent': 'ewancroft-uk-website' 41 + } 42 + }); 43 + 44 + if (!response.ok) { 45 + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); 46 + } 47 + 48 + const data = await response.json(); 49 + setCached(cacheKey, data); 50 + return data; 51 + } 52 + 53 + /** 54 + * Fetch a GitHub user profile by username 55 + */ 56 + export async function fetchGitHubProfile( 57 + username: string, 58 + fetchFn?: typeof fetch 59 + ): Promise<GitHubProfile> { 60 + return githubFetch<GitHubProfile>(`/users/${username}`, fetchFn); 61 + } 62 + 63 + /** 64 + * Fetch a user's public repositories 65 + */ 66 + export async function fetchGitHubRepos( 67 + username: string, 68 + fetchFn?: typeof fetch, 69 + options?: { sort?: 'created' | 'updated' | 'pushed' | 'full_name'; direction?: 'asc' | 'desc'; per_page?: number } 70 + ): Promise<GitHubRepo[]> { 71 + const params = new URLSearchParams({ 72 + sort: options?.sort || 'updated', 73 + direction: options?.direction || 'desc', 74 + per_page: String(options?.per_page || 100) 75 + }); 76 + return githubFetch<GitHubRepo[]>(`/users/${username}/repos?${params}`, fetchFn); 77 + } 78 + 79 + /** 80 + * Fetch language breakdown for a specific repo 81 + */ 82 + export async function fetchRepoLanguages( 83 + owner: string, 84 + repo: string, 85 + fetchFn?: typeof fetch 86 + ): Promise<GitHubLanguageStats> { 87 + return githubFetch<GitHubLanguageStats>(`/repos/${owner}/${repo}/languages`, fetchFn); 88 + } 89 + 90 + /** 91 + * Fetch pinned repositories via the pinned API endpoint 92 + * Note: GitHub's API doesn't directly expose pinned repos via REST, 93 + * so we'll fetch all repos and mark notable ones 94 + */ 95 + export async function fetchNotableRepos( 96 + username: string, 97 + fetchFn?: typeof fetch, 98 + limit: number = 6 99 + ): Promise<GitHubRepo[]> { 100 + const repos = await fetchGitHubRepos(username, fetchFn, { sort: 'updated', per_page: 30 }); 101 + 102 + // Filter and sort: prefer non-forks, then by stars + activity 103 + const notable = repos 104 + .filter((repo) => !repo.fork && !repo.archived) 105 + .sort((a, b) => { 106 + const scoreA = a.stargazers_count * 2 + (a.homepage ? 5 : 0) + (a.description ? 2 : 0); 107 + const scoreB = b.stargazers_count * 2 + (b.homepage ? 5 : 0) + (b.description ? 2 : 0); 108 + return scoreB - scoreA; 109 + }) 110 + .slice(0, limit); 111 + 112 + return notable; 113 + } 114 + 115 + /** 116 + * Fetch profile with notable repos in parallel 117 + */ 118 + export async function fetchGitHubData( 119 + username: string, 120 + fetchFn?: typeof fetch 121 + ): Promise<{ profile: GitHubProfile; repos: GitHubRepo[] }> { 122 + const [profile, repos] = await Promise.all([ 123 + fetchGitHubProfile(username, fetchFn), 124 + fetchNotableRepos(username, fetchFn) 125 + ]); 126 + return { profile, repos }; 127 + }
+5
src/lib/services/github/index.ts
··· 1 + /** 2 + * GitHub service exports 3 + */ 4 + export { fetchGitHubProfile, fetchGitHubRepos, fetchRepoLanguages, fetchNotableRepos, fetchGitHubData } from './fetch'; 5 + export type { GitHubProfile, GitHubRepo, GitHubLanguageStats, GitHubRepoWithDetails } from './types';
+89
src/lib/services/github/types.ts
··· 1 + /** 2 + * GitHub API type definitions 3 + */ 4 + 5 + export interface GitHubProfile { 6 + login: string; 7 + id: number; 8 + node_id: string; 9 + avatar_url: string; 10 + gravatar_id: string; 11 + url: string; 12 + html_url: string; 13 + followers_url: string; 14 + following_url: string; 15 + gists_url: string; 16 + starred_url: string; 17 + subscriptions_url: string; 18 + organizations_url: string; 19 + repos_url: string; 20 + events_url: string; 21 + received_events_url: string; 22 + type: string; 23 + user_view_type: 'public' | 'private'; 24 + site_admin: boolean; 25 + name: string | null; 26 + company: string | null; 27 + blog: string | null; 28 + location: string | null; 29 + email: string | null; 30 + hireable: boolean | null; 31 + bio: string | null; 32 + twitter_username: string | null; 33 + public_repos: number; 34 + public_gists: number; 35 + followers: number; 36 + following: number; 37 + created_at: string; 38 + updated_at: string; 39 + } 40 + 41 + export interface GitHubRepo { 42 + id: number; 43 + node_id: string; 44 + name: string; 45 + full_name: string; 46 + private: boolean; 47 + owner: { 48 + login: string; 49 + id: number; 50 + avatar_url: string; 51 + html_url: string; 52 + }; 53 + html_url: string; 54 + description: string | null; 55 + fork: boolean; 56 + url: string; 57 + forks_url: string; 58 + homepage: string | null; 59 + language: string | null; 60 + forks_count: number; 61 + stargazers_count: number; 62 + watchers_count: number; 63 + size: number; 64 + default_branch: string; 65 + open_issues_count: number; 66 + is_template: boolean; 67 + topics: string[]; 68 + visibility: 'public' | 'private'; 69 + pushed_at: string; 70 + created_at: string; 71 + updated_at: string; 72 + archived: boolean; 73 + disabled: boolean; 74 + license: { 75 + key: string; 76 + name: string; 77 + spdx_id: string; 78 + url: string | null; 79 + } | null; 80 + } 81 + 82 + export interface GitHubLanguageStats { 83 + [key: string]: number; 84 + } 85 + 86 + export interface GitHubRepoWithDetails extends GitHubRepo { 87 + languages?: GitHubLanguageStats; 88 + totalBytes?: number; 89 + }
+262
src/routes/github/+page.svelte
··· 1 + <script lang="ts"> 2 + import { Card, NoiseImage } from '$lib/components/ui'; 3 + import { ExternalLink, MapPin, Link as LinkIcon, Calendar, Users, GitBranch, Star, Eye } from '@lucide/svelte'; 4 + import type { PageData } from './$types'; 5 + import { formatCompactNumber } from '$lib/utils/formatNumber'; 6 + import { formatLocalizedDate, getUserLocale } from '$lib/utils/locale'; 7 + import { createSiteMeta, defaultSiteMeta } from '$lib/helper/siteMeta'; 8 + 9 + let { data }: { data: PageData } = $props(); 10 + const { profile, repos } = data; 11 + 12 + const locale = getUserLocale(); 13 + 14 + const siteMeta = createSiteMeta({ 15 + ...defaultSiteMeta, 16 + title: `GitHub @${profile.login}`, 17 + description: profile.bio || `${profile.name || profile.login}'s GitHub profile`, 18 + url: `${defaultSiteMeta.url}/github` 19 + }); 20 + 21 + // Language colours for common languages 22 + const languageColors: Record<string, string> = { 23 + TypeScript: '#3178C6', 24 + JavaScript: '#F7DF1E', 25 + Python: '#3776AB', 26 + Svelte: '#FF3E00', 27 + HTML: '#E34F26', 28 + CSS: '#1572B6', 29 + Nix: '#5277C3', 30 + Shell: '#4EAA25', 31 + Rust: '#DEA584', 32 + 'Java': '#B07219', 33 + Go: '#00ADD8', 34 + Vue: '#4FC08D', 35 + Swift: '#FA7343', 36 + Kotlin: '#A97BFF', 37 + SCSS: '#C6538C', 38 + Less: '#1D365D', 39 + Dockerfile: '#384D54', 40 + Makefile: '#427819' 41 + }; 42 + 43 + function getLanguageColor(lang: string): string { 44 + return languageColors[lang] || '#8E8E8E'; 45 + } 46 + 47 + function formatDateSafe(dateStr: string): string { 48 + try { 49 + return formatLocalizedDate(new Date(dateStr), locale, { year: 'numeric', month: 'short', day: 'numeric' }); 50 + } catch { 51 + return dateStr; 52 + } 53 + } 54 + </script> 55 + 56 + <svelte:head> 57 + <title>{siteMeta.title}</title> 58 + <meta name="description" content={siteMeta.description} /> 59 + </svelte:head> 60 + 61 + <div class="mx-auto max-w-6xl space-y-8"> 62 + <!-- Profile Hero --> 63 + <Card variant="elevated" padding="none" class="overflow-hidden"> 64 + {#snippet children()} 65 + <!-- Banner pattern --> 66 + <div class="h-32 w-full bg-linear-to-r from-canvas-800 to-canvas-900 dark:from-canvas-700 dark:to-canvas-800 relative overflow-hidden"> 67 + <div class="absolute inset-0 opacity-20"> 68 + <svg class="h-full w-full" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none"> 69 + <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"> 70 + <path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" stroke-width="1" class="text-primary-500" /> 71 + </pattern> 72 + <rect width="100%" height="100%" fill="url(#grid)" /> 73 + </svg> 74 + </div> 75 + </div> 76 + 77 + <div class="relative px-6 pb-6"> 78 + <!-- Avatar --> 79 + <div class="absolute -top-16 left-6"> 80 + <div class="h-32 w-32 overflow-hidden rounded-full border-4 border-canvas-100 bg-canvas-200 dark:border-canvas-900"> 81 + <NoiseImage 82 + src={profile.avatar_url} 83 + seed={`${profile.login}|github|avatar`} 84 + class="h-full w-full object-cover" 85 + alt="{profile.name || profile.login}'s avatar" 86 + /> 87 + </div> 88 + </div> 89 + 90 + <!-- Profile info --> 91 + <div class="pt-20 sm:pt-4 sm:pl-36"> 92 + <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> 93 + <div> 94 + <h1 class="text-3xl font-bold text-ink-900 dark:text-ink-50"> 95 + {profile.name || profile.login} 96 + </h1> 97 + <p class="text-lg font-medium text-ink-600 dark:text-ink-300"> 98 + @{profile.login} 99 + </p> 100 + 101 + <!-- Meta info row --> 102 + <div class="mt-3 flex flex-wrap items-center gap-4 text-sm text-ink-700 dark:text-ink-200"> 103 + {#if profile.location} 104 + <span class="flex items-center gap-1"> 105 + <MapPin class="h-4 w-4" aria-hidden="true" /> 106 + {profile.location} 107 + </span> 108 + {/if} 109 + {#if profile.blog} 110 + <a 111 + href={profile.blog.startsWith('http') ? profile.blog : `https://${profile.blog}`} 112 + target="_blank" 113 + rel="noopener noreferrer" 114 + class="flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400" 115 + > 116 + <LinkIcon class="h-4 w-4" aria-hidden="true" /> 117 + {profile.blog.replace(/^https?:\/\//, '').replace(/\/$/, '')} 118 + </a> 119 + {/if} 120 + <span class="flex items-center gap-1"> 121 + <Calendar class="h-4 w-4" aria-hidden="true" /> 122 + Joined {formatDateSafe(profile.created_at)} 123 + </span> 124 + </div> 125 + 126 + {#if profile.bio} 127 + <p class="mt-4 max-w-2xl text-ink-700 dark:text-ink-200"> 128 + {profile.bio} 129 + </p> 130 + {/if} 131 + </div> 132 + 133 + <!-- Stats --> 134 + <div class="flex gap-6 text-sm"> 135 + <div class="text-center"> 136 + <div class="text-2xl font-bold text-ink-900 dark:text-ink-50"> 137 + {formatCompactNumber(profile.public_repos, locale)} 138 + </div> 139 + <div class="text-ink-600 dark:text-ink-300">Repos</div> 140 + </div> 141 + <div class="text-center"> 142 + <div class="text-2xl font-bold text-ink-900 dark:text-ink-50"> 143 + {formatCompactNumber(profile.followers, locale)} 144 + </div> 145 + <div class="text-ink-600 dark:text-ink-300">Followers</div> 146 + </div> 147 + <div class="text-center"> 148 + <div class="text-2xl font-bold text-ink-900 dark:text-ink-50"> 149 + {formatCompactNumber(profile.following, locale)} 150 + </div> 151 + <div class="text-ink-600 dark:text-ink-300">Following</div> 152 + </div> 153 + </div> 154 + </div> 155 + 156 + <!-- Link to GitHub --> 157 + <div class="mt-6"> 158 + <a 159 + href={profile.html_url} 160 + target="_blank" 161 + rel="noopener noreferrer" 162 + class="inline-flex items-center gap-2 rounded-lg bg-ink-900 px-4 py-2 font-medium text-white transition-colors hover:bg-ink-800 dark:bg-ink-100 dark:text-ink-900 dark:hover:bg-ink-200" 163 + > 164 + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"> 165 + <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.54.63-.02 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> 166 + </svg> 167 + View Profile 168 + <ExternalLink class="h-4 w-4" aria-hidden="true" /> 169 + </a> 170 + </div> 171 + </div> 172 + </div> 173 + {/snippet} 174 + </Card> 175 + 176 + <!-- Repositories Grid --> 177 + <section> 178 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Notable Repositories</h2> 179 + <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 180 + {#each repos as repo} 181 + <Card 182 + key={repo.id} 183 + variant="default" 184 + padding="md" 185 + class="flex flex-col" 186 + interactive={true} 187 + href={repo.html_url} 188 + showExternalIcon={true} 189 + > 190 + {#snippet children()} 191 + <div class="flex items-start gap-3"> 192 + <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 193 + <GitBranch class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 194 + </div> 195 + <div class="min-w-0 flex-1"> 196 + <h3 class="truncate font-semibold text-ink-900 dark:text-ink-50"> 197 + {repo.name} 198 + </h3> 199 + {#if repo.description} 200 + <p class="mt-1 line-clamp-2 text-sm text-ink-700 dark:text-ink-200"> 201 + {repo.description} 202 + </p> 203 + {/if} 204 + </div> 205 + </div> 206 + 207 + <!-- Repo stats --> 208 + <div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-ink-600 dark:text-ink-300"> 209 + {#if repo.language} 210 + <span class="flex items-center gap-1"> 211 + <span 212 + class="h-3 w-3 rounded-full" 213 + style="background-color: {getLanguageColor(repo.language)}" 214 + aria-hidden="true" 215 + ></span> 216 + {repo.language} 217 + </span> 218 + {/if} 219 + <span class="flex items-center gap-1"> 220 + <Star class="h-3.5 w-3.5 text-yellow-500" aria-hidden="true" /> 221 + {formatCompactNumber(repo.stargazers_count, locale)} 222 + </span> 223 + <span class="flex items-center gap-1"> 224 + <GitBranch class="h-3.5 w-3.5" aria-hidden="true" /> 225 + {formatCompactNumber(repo.forks_count, locale)} 226 + </span> 227 + {#if repo.homepage} 228 + <span class="flex items-center gap-1 text-primary-600 dark:text-primary-400"> 229 + <LinkIcon class="h-3.5 w-3.5" aria-hidden="true" /> 230 + demo 231 + </span> 232 + {/if} 233 + </div> 234 + 235 + <!-- Topics --> 236 + {#if repo.topics && repo.topics.length > 0} 237 + <div class="mt-3 flex flex-wrap gap-1.5"> 238 + {#each repo.topics.slice(0, 4) as topic} 239 + <span 240 + class="rounded-md bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900 dark:text-primary-200" 241 + > 242 + {topic} 243 + </span> 244 + {/each} 245 + {#if repo.topics.length > 4} 246 + <span class="text-xs text-ink-500">+{repo.topics.length - 4}</span> 247 + {/if} 248 + </div> 249 + {/if} 250 + 251 + <!-- License --> 252 + {#if repo.license} 253 + <div class="mt-2 text-xs text-ink-500 dark:text-ink-400"> 254 + {repo.license.spdx_id} 255 + </div> 256 + {/if} 257 + {/snippet} 258 + </Card> 259 + {/each} 260 + </div> 261 + </section> 262 + </div>
+9
src/routes/github/+page.ts
··· 1 + import type { PageLoad } from './$types'; 2 + import { fetchGitHubData } from '$lib/services/github'; 3 + 4 + const GITHUB_USERNAME = 'ewanc26'; 5 + 6 + export const load: PageLoad = async ({ fetch }) => { 7 + const { profile, repos } = await fetchGitHubData(GITHUB_USERNAME, fetch); 8 + return { profile, repos }; 9 + };