my website at ewancroft.uk
6
fork

Configure Feed

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

Refactor GitHub integration and update work page

- Removed the GitHub page and redirected to the work page.
- Integrated GitHub profile and contributions data into the work page.
- Updated metadata for the work page to include GitHub information.
- Enhanced the work page layout to display GitHub contributions and notable repositories.
- Cleaned up unused imports and code in the GitHub page.

+387 -390
-1
src/lib/data/navItems.ts
··· 6 6 { href: '/', label: 'Home', iconPath: 'Home' }, 7 7 { href: '/work', label: 'Work', iconPath: 'Briefcase' }, 8 8 { href: '/site/meta', label: 'Site Meta', iconPath: 'Info' }, 9 - { href: '/github', label: 'GitHub', iconPath: 'Github' }, 10 9 { href: '/archive', label: 'Archive', iconPath: 'Archive' } 11 10 ];
+11 -7
src/lib/helper/siteMeta.ts
··· 44 44 * ``` 45 45 */ 46 46 export interface DynamicSiteMetaOptions { 47 - title: string 48 - description?: string 49 - template?: 'default' | 'blog' | 'profile' 50 - url?: string 47 + title: string; 48 + description?: string; 49 + template?: 'default' | 'blog' | 'profile'; 50 + url?: string; 51 51 } 52 52 53 53 export function createDynamicSiteMeta(options: DynamicSiteMetaOptions): SiteMetadata { 54 - const siteUrl = options.url || PUBLIC_SITE_URL 54 + const siteUrl = options.url || PUBLIC_SITE_URL; 55 + const title = 56 + options.title === PUBLIC_SITE_TITLE 57 + ? PUBLIC_SITE_TITLE 58 + : `${options.title} | ${PUBLIC_SITE_TITLE}`; 55 59 56 60 return { 57 - title: options.title, 61 + title, 58 62 description: options.description || PUBLIC_SITE_DESCRIPTION, 59 63 keywords: PUBLIC_SITE_KEYWORDS, 60 64 url: siteUrl, ··· 65 69 })}`, 66 70 imageWidth: 1200, 67 71 imageHeight: 630 68 - } 72 + }; 69 73 }
+40 -17
src/routes/+error.svelte
··· 2 2 import { page } from '$app/stores'; 3 3 import { MetaTags } from '$lib/components/seo'; 4 4 import { Card } from '$lib/components/ui'; 5 - import { Home, RefreshCw, FileQuestion, Shield, ServerCrash, AlertTriangle } from '@lucide/svelte'; 5 + import { 6 + Home, 7 + RefreshCw, 8 + FileQuestion, 9 + Shield, 10 + ServerCrash, 11 + AlertTriangle 12 + } from '@lucide/svelte'; 6 13 7 14 // Get error details from page store 8 15 const status = $derived($page.status); ··· 29 36 return { 30 37 icon: Shield, 31 38 title: 'Access Denied', 32 - description: 'You don\'t have permission to access this resource. This could be due to authentication requirements or restricted access.', 39 + description: 40 + "You don't have permission to access this resource. This could be due to authentication requirements or restricted access.", 33 41 suggestions: [ 34 - 'Make sure you\'re logged in if required', 42 + "Make sure you're logged in if required", 35 43 'The content may be private or restricted', 36 44 'Contact the site owner if you believe this is an error' 37 45 ], ··· 42 50 return { 43 51 icon: ServerCrash, 44 52 title: 'Something Went Wrong', 45 - description: 'An internal error occurred while processing your request. This is usually temporary.', 53 + description: 54 + 'An internal error occurred while processing your request. This is usually temporary.', 46 55 suggestions: [ 47 56 'Try refreshing the page', 48 57 'Clear your browser cache', 49 58 'The issue has been logged and will be investigated' 50 59 ], 51 - primaryAction: { label: 'Try Again', href: null, icon: RefreshCw, action: () => window.location.reload() }, 60 + primaryAction: { 61 + label: 'Try Again', 62 + href: null, 63 + icon: RefreshCw, 64 + action: () => window.location.reload() 65 + }, 52 66 secondaryAction: { label: 'Go to Homepage', href: '/', icon: Home } 53 67 }; 54 68 case 503: 55 69 return { 56 70 icon: AlertTriangle, 57 71 title: 'Service Temporarily Unavailable', 58 - description: 'The server is currently unavailable, usually due to maintenance or high load. Please try again shortly.', 72 + description: 73 + 'The server is currently unavailable, usually due to maintenance or high load. Please try again shortly.', 59 74 suggestions: [ 60 75 'Wait a few moments and try again', 61 76 'The site may be undergoing maintenance', 62 77 'Check back in a minute or two' 63 78 ], 64 - primaryAction: { label: 'Try Again', href: null, icon: RefreshCw, action: () => window.location.reload() }, 79 + primaryAction: { 80 + label: 'Try Again', 81 + href: null, 82 + icon: RefreshCw, 83 + action: () => window.location.reload() 84 + }, 65 85 secondaryAction: { label: 'Go to Homepage', href: '/', icon: Home } 66 86 }; 67 87 default: ··· 75 95 'If the problem persists, please contact support' 76 96 ], 77 97 primaryAction: { label: 'Go to Homepage', href: '/', icon: Home }, 78 - secondaryAction: { label: 'Try Again', href: null, icon: RefreshCw, action: () => window.location.reload() } 98 + secondaryAction: { 99 + label: 'Try Again', 100 + href: null, 101 + icon: RefreshCw, 102 + action: () => window.location.reload() 103 + } 79 104 }; 80 105 } 81 106 }); ··· 98 123 <div class="text-center"> 99 124 <!-- Icon with status code --> 100 125 <div class="mb-6 flex flex-col items-center"> 101 - <div class="mb-4 rounded-full bg-primary-100 p-6 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"> 126 + <div 127 + class="mb-4 rounded-full bg-primary-100 p-6 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400" 128 + > 102 129 {#if errorConfig.icon === FileQuestion} 103 130 <FileQuestion class="h-16 w-16" /> 104 131 {:else if errorConfig.icon === Shield} ··· 127 154 <!-- Show additional error message if available and meaningful --> 128 155 {#if errorMessage && !errorMessage.includes('Internal Error') && status !== 404} 129 156 <div class="mb-6 rounded-lg bg-red-50 p-4 text-left dark:bg-red-900/20"> 130 - <p class="text-sm font-medium text-red-800 dark:text-red-200"> 131 - Error details: 132 - </p> 157 + <p class="text-sm font-medium text-red-800 dark:text-red-200">Error details:</p> 133 158 <p class="mt-1 font-mono text-sm text-red-700 dark:text-red-300"> 134 159 {errorMessage} 135 160 </p> ··· 139 164 <!-- Suggestions --> 140 165 {#if errorConfig.suggestions.length > 0} 141 166 <div class="mb-8 text-left"> 142 - <p class="mb-3 text-sm font-medium text-ink-600 dark:text-ink-400"> 143 - What you can try: 144 - </p> 167 + <p class="mb-3 text-sm font-medium text-ink-600 dark:text-ink-400">What you can try:</p> 145 168 <ul class="space-y-2"> 146 169 {#each errorConfig.suggestions as suggestion} 147 170 <li class="flex items-start gap-2 text-sm text-ink-700 dark:text-ink-300"> ··· 226 249 Archive 227 250 </a> 228 251 <a 229 - href="/github" 252 + href="/work" 230 253 class="rounded-lg bg-canvas-100 px-4 py-2 text-sm text-ink-700 transition-colors hover:bg-canvas-200 dark:bg-canvas-800 dark:text-ink-300 dark:hover:bg-canvas-700" 231 254 > 232 - GitHub 255 + Work 233 256 </a> 234 257 <a 235 258 href="/site/meta"
+12 -10
src/routes/+page.ts
··· 1 1 import type { PageLoad } from './$types'; 2 2 import { createDynamicSiteMeta } from '$lib/helper/siteMeta'; 3 + import { PUBLIC_SITE_TITLE } from '$env/static/public'; 3 4 import { 4 5 fetchMusicStatus, 5 6 fetchKibunStatus, ··· 13 14 export const load: PageLoad = async ({ fetch, parent }) => { 14 15 const { profile } = await parent(); 15 16 16 - const [musicStatus, kibunStatus, latestPost, tangledRepos, documents, supporters, popfeedReview] = await Promise.allSettled([ 17 - fetchMusicStatus(fetch), 18 - fetchKibunStatus(fetch), 19 - fetchLatestBlueskyPost(fetch), 20 - fetchTangledRepos(fetch), 21 - fetchRecentDocuments(5, fetch), 22 - fetchAllSupporters(), 23 - fetchRecentPopfeedReviews(fetch) 24 - ]); 17 + const [musicStatus, kibunStatus, latestPost, tangledRepos, documents, supporters, popfeedReview] = 18 + await Promise.allSettled([ 19 + fetchMusicStatus(fetch), 20 + fetchKibunStatus(fetch), 21 + fetchLatestBlueskyPost(fetch), 22 + fetchTangledRepos(fetch), 23 + fetchRecentDocuments(5, fetch), 24 + fetchAllSupporters(), 25 + fetchRecentPopfeedReviews(fetch) 26 + ]); 25 27 26 28 // Create page metadata with dynamic OG 27 29 const meta = createDynamicSiteMeta({ 28 - title: "Ewan's Corner", 30 + title: PUBLIC_SITE_TITLE, 29 31 description: 'personal site, blog, and digital garden' 30 32 }); 31 33
-315
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 { MetaTags } from '$lib/components/seo'; 5 - import type { PageData } from './$types'; 6 - import { formatCompactNumber } from '$lib/utils/formatNumber'; 7 - import { formatLocalizedDate, getUserLocale } from '$lib/utils/locale'; 8 - 9 - let { data }: { data: PageData } = $props(); 10 - 11 - const locale = getUserLocale(); 12 - const { profile, repos, contributions, meta } = $derived(data); 13 - 14 - // Language colours for common languages 15 - const languageColors: Record<string, string> = { 16 - TypeScript: '#3178C6', 17 - JavaScript: '#F7DF1E', 18 - Python: '#3776AB', 19 - Svelte: '#FF3E00', 20 - HTML: '#E34F26', 21 - CSS: '#1572B6', 22 - Nix: '#5277C3', 23 - Shell: '#4EAA25', 24 - Rust: '#DEA584', 25 - 'Java': '#B07219', 26 - Go: '#00ADD8', 27 - Vue: '#4FC08D', 28 - Swift: '#FA7343', 29 - Kotlin: '#A97BFF', 30 - SCSS: '#C6538C', 31 - Less: '#1D365D', 32 - Dockerfile: '#384D54', 33 - Makefile: '#427819' 34 - }; 35 - 36 - function getLanguageColor(lang: string): string { 37 - return languageColors[lang] || '#8E8E8E'; 38 - } 39 - 40 - function formatDateSafe(dateStr: string): string { 41 - try { 42 - return formatLocalizedDate(new Date(dateStr), locale, { year: 'numeric', month: 'short', day: 'numeric' }); 43 - } catch { 44 - return dateStr; 45 - } 46 - } 47 - </script> 48 - 49 - <MetaTags {meta} siteMeta={meta} /> 50 - 51 - <div class="mx-auto max-w-6xl space-y-8"> 52 - <!-- Profile Hero --> 53 - <Card variant="elevated" padding="none" class="overflow-hidden"> 54 - {#snippet children()} 55 - <!-- Banner pattern --> 56 - <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"> 57 - <div class="absolute inset-0 opacity-20"> 58 - <svg class="h-full w-full" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none"> 59 - <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"> 60 - <path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" stroke-width="1" class="text-primary-500" /> 61 - </pattern> 62 - <rect width="100%" height="100%" fill="url(#grid)" /> 63 - </svg> 64 - </div> 65 - </div> 66 - 67 - <div class="relative px-6 pb-6"> 68 - <!-- Avatar --> 69 - <div class="absolute -top-16 left-6"> 70 - <div class="h-32 w-32 overflow-hidden rounded-full border-4 border-canvas-100 bg-canvas-200 dark:border-canvas-900"> 71 - <NoiseImage 72 - src={profile.avatar_url} 73 - seed={`${profile.login}|github|avatar`} 74 - class="h-full w-full object-cover" 75 - alt="{profile.name || profile.login}'s avatar" 76 - /> 77 - </div> 78 - </div> 79 - 80 - <!-- Profile info --> 81 - <div class="pt-20 sm:pt-4 sm:pl-36"> 82 - <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> 83 - <div> 84 - <h1 class="text-3xl font-bold text-ink-900 dark:text-ink-50"> 85 - {profile.name || profile.login} 86 - </h1> 87 - <p class="text-lg font-medium text-ink-600 dark:text-ink-300"> 88 - @{profile.login} 89 - </p> 90 - 91 - <!-- Meta info row --> 92 - <div class="mt-3 flex flex-wrap items-center gap-4 text-sm text-ink-700 dark:text-ink-200"> 93 - {#if profile.location} 94 - <span class="flex items-center gap-1"> 95 - <MapPin class="h-4 w-4" aria-hidden="true" /> 96 - {profile.location} 97 - </span> 98 - {/if} 99 - {#if profile.blog} 100 - <a 101 - href={profile.blog.startsWith('http') ? profile.blog : `https://${profile.blog}`} 102 - target="_blank" 103 - rel="noopener noreferrer" 104 - class="flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400" 105 - > 106 - <LinkIcon class="h-4 w-4" aria-hidden="true" /> 107 - {profile.blog.replace(/^https?:\/\//, '').replace(/\/$/, '')} 108 - </a> 109 - {/if} 110 - <span class="flex items-center gap-1"> 111 - <Calendar class="h-4 w-4" aria-hidden="true" /> 112 - Joined {formatDateSafe(profile.created_at)} 113 - </span> 114 - </div> 115 - 116 - {#if profile.bio} 117 - <p class="mt-4 max-w-2xl text-ink-700 dark:text-ink-200"> 118 - {profile.bio} 119 - </p> 120 - {/if} 121 - </div> 122 - 123 - <!-- Stats --> 124 - <div class="flex gap-6 text-sm"> 125 - <div class="text-center"> 126 - <div class="text-2xl font-bold text-ink-900 dark:text-ink-50"> 127 - {formatCompactNumber(profile.public_repos, locale)} 128 - </div> 129 - <div class="text-ink-600 dark:text-ink-300">Repos</div> 130 - </div> 131 - <div class="text-center"> 132 - <div class="text-2xl font-bold text-ink-900 dark:text-ink-50"> 133 - {formatCompactNumber(profile.followers, locale)} 134 - </div> 135 - <div class="text-ink-600 dark:text-ink-300">Followers</div> 136 - </div> 137 - <div class="text-center"> 138 - <div class="text-2xl font-bold text-ink-900 dark:text-ink-50"> 139 - {formatCompactNumber(profile.following, locale)} 140 - </div> 141 - <div class="text-ink-600 dark:text-ink-300">Following</div> 142 - </div> 143 - </div> 144 - </div> 145 - 146 - <!-- Link to GitHub --> 147 - <div class="mt-6"> 148 - <a 149 - href={profile.html_url} 150 - target="_blank" 151 - rel="noopener noreferrer" 152 - 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" 153 - > 154 - <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"> 155 - <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"/> 156 - </svg> 157 - View Profile 158 - <ExternalLink class="h-4 w-4" aria-hidden="true" /> 159 - </a> 160 - </div> 161 - </div> 162 - </div> 163 - {/snippet} 164 - </Card> 165 - 166 - <!-- Contribution Graph --> 167 - <section> 168 - <Card variant="elevated" padding="lg"> 169 - {#snippet children()} 170 - <div class="flex items-center justify-between"> 171 - <h2 class="text-xl font-bold text-ink-900 dark:text-ink-50"> 172 - {formatCompactNumber(contributions.total, locale)} contributions 173 - </h2> 174 - <span class="text-sm text-ink-600 dark:text-ink-300">last 90 days</span> 175 - </div> 176 - 177 - <!-- Contribution grid --> 178 - <div class="mt-4 overflow-x-auto"> 179 - <div class="flex gap-[3px]" style="width: min-content;"> 180 - {#each contributions.weeks as week} 181 - <div class="flex flex-col gap-[3px]"> 182 - {#each week as day} 183 - <div 184 - class="h-3 w-3 rounded-sm transition-colors" 185 - class:bg-canvas-200={day.level === 0} 186 - class:dark:bg-canvas-700={day.level === 0} 187 - class:bg-primary-200={day.level === 1} 188 - class:dark:bg-primary-900={day.level === 1} 189 - class:bg-primary-300={day.level === 2} 190 - class:dark:bg-primary-700={day.level === 2} 191 - class:bg-primary-400={day.level === 3} 192 - class:dark:bg-primary-500={day.level === 3} 193 - class:bg-primary-600={day.level === 4} 194 - class:dark:bg-primary-400={day.level === 4} 195 - title={day.date ? `${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date}` : ''} 196 - role="img" 197 - aria-label={day.date ? `${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date}` : ''} 198 - ></div> 199 - {/each} 200 - </div> 201 - {/each} 202 - </div> 203 - </div> 204 - 205 - <!-- Legend --> 206 - <div class="mt-4 flex items-center justify-end gap-2 text-xs text-ink-600 dark:text-ink-300"> 207 - <span>Less</span> 208 - {#each [0, 1, 2, 3, 4] as level} 209 - <div 210 - class="h-3 w-3 rounded-sm" 211 - class:bg-canvas-200={level === 0} 212 - class:dark:bg-canvas-700={level === 0} 213 - class:bg-primary-200={level === 1} 214 - class:dark:bg-primary-900={level === 1} 215 - class:bg-primary-300={level === 2} 216 - class:dark:bg-primary-700={level === 2} 217 - class:bg-primary-400={level === 3} 218 - class:dark:bg-primary-500={level === 3} 219 - class:bg-primary-600={level === 4} 220 - class:dark:bg-primary-400={level === 4} 221 - ></div> 222 - {/each} 223 - <span>More</span> 224 - </div> 225 - {/snippet} 226 - </Card> 227 - </section> 228 - 229 - <!-- Repositories Grid --> 230 - <section> 231 - <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Notable Repositories</h2> 232 - <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 233 - {#each repos as repo} 234 - <Card 235 - key={repo.id} 236 - variant="default" 237 - padding="md" 238 - class="flex flex-col" 239 - interactive={true} 240 - href={repo.html_url} 241 - showExternalIcon={true} 242 - > 243 - {#snippet children()} 244 - <div class="flex items-start gap-3"> 245 - <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 246 - <GitBranch class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 247 - </div> 248 - <div class="min-w-0 flex-1"> 249 - <h3 class="truncate font-semibold text-ink-900 dark:text-ink-50"> 250 - {repo.name} 251 - </h3> 252 - {#if repo.description} 253 - <p class="mt-1 line-clamp-2 text-sm text-ink-700 dark:text-ink-200"> 254 - {repo.description} 255 - </p> 256 - {/if} 257 - </div> 258 - </div> 259 - 260 - <!-- Repo stats --> 261 - <div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-ink-600 dark:text-ink-300"> 262 - {#if repo.language} 263 - <span class="flex items-center gap-1"> 264 - <span 265 - class="h-3 w-3 rounded-full" 266 - style="background-color: {getLanguageColor(repo.language)}" 267 - aria-hidden="true" 268 - ></span> 269 - {repo.language} 270 - </span> 271 - {/if} 272 - <span class="flex items-center gap-1"> 273 - <Star class="h-3.5 w-3.5 text-yellow-500" aria-hidden="true" /> 274 - {formatCompactNumber(repo.stargazers_count, locale)} 275 - </span> 276 - <span class="flex items-center gap-1"> 277 - <GitBranch class="h-3.5 w-3.5" aria-hidden="true" /> 278 - {formatCompactNumber(repo.forks_count, locale)} 279 - </span> 280 - {#if repo.homepage} 281 - <span class="flex items-center gap-1 text-primary-600 dark:text-primary-400"> 282 - <LinkIcon class="h-3.5 w-3.5" aria-hidden="true" /> 283 - demo 284 - </span> 285 - {/if} 286 - </div> 287 - 288 - <!-- Topics --> 289 - {#if repo.topics && repo.topics.length > 0} 290 - <div class="mt-3 flex flex-wrap gap-1.5"> 291 - {#each repo.topics.slice(0, 4) as topic} 292 - <span 293 - 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" 294 - > 295 - {topic} 296 - </span> 297 - {/each} 298 - {#if repo.topics.length > 4} 299 - <span class="text-xs text-ink-500">+{repo.topics.length - 4}</span> 300 - {/if} 301 - </div> 302 - {/if} 303 - 304 - <!-- License --> 305 - {#if repo.license} 306 - <div class="mt-2 text-xs text-ink-500 dark:text-ink-400"> 307 - {repo.license.spdx_id} 308 - </div> 309 - {/if} 310 - {/snippet} 311 - </Card> 312 - {/each} 313 - </div> 314 - </section> 315 - </div>
+3 -17
src/routes/github/+page.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 1 2 import type { PageLoad } from './$types'; 2 - import { createDynamicSiteMeta } from '$lib/helper/siteMeta'; 3 - import { fetchGitHubData, fetchContributions } from '$lib/services/github'; 4 - 5 - const GITHUB_USERNAME = 'ewanc26'; 6 3 7 - export const load: PageLoad = async ({ fetch }) => { 8 - const [profileData, contributions] = await Promise.all([ 9 - fetchGitHubData(GITHUB_USERNAME, fetch), 10 - fetchContributions(GITHUB_USERNAME, fetch, 90) 11 - ]); 12 - 13 - // Create page metadata with dynamic OG 14 - const meta = createDynamicSiteMeta({ 15 - title: 'GitHub', 16 - description: `Ewan's GitHub profile and contributions` 17 - }); 18 - 19 - return { ...profileData, contributions, meta }; 4 + export const load: PageLoad = () => { 5 + throw redirect(301, '/work'); 20 6 };
+310 -21
src/routes/work/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { Card } from '$lib/components/ui'; 2 + import { Card, NoiseImage } from '$lib/components/ui'; 3 3 import { MetaTags } from '$lib/components/seo'; 4 4 import { 5 5 MapPin, ··· 16 16 Heart, 17 17 BookOpen, 18 18 FileText, 19 - Calendar 19 + Calendar, 20 + GitBranch, 21 + Star, 22 + ExternalLink 20 23 } from '@lucide/svelte'; 21 24 import type { PageData } from './$types'; 22 25 import type { SifaSkill } from '@ewanc26/atproto'; 26 + import { formatCompactNumber } from '$lib/utils/formatNumber'; 27 + import { formatLocalizedDate, getUserLocale } from '$lib/utils/locale'; 23 28 24 29 let { data }: { data: PageData } = $props(); 25 30 31 + const locale = getUserLocale(); 26 32 const { 27 33 profile, 28 34 skills, ··· 36 42 honors, 37 43 courses, 38 44 publications, 45 + github, 46 + contributions, 39 47 meta 40 48 } = $derived(data); 49 + 50 + // Language colours for common languages 51 + const languageColors: Record<string, string> = { 52 + TypeScript: '#3178C6', 53 + JavaScript: '#F7DF1E', 54 + Python: '#3776AB', 55 + Svelte: '#FF3E00', 56 + HTML: '#E34F26', 57 + CSS: '#1572B6', 58 + Nix: '#5277C3', 59 + Shell: '#4EAA25', 60 + Rust: '#DEA584', 61 + Java: '#B07219', 62 + Go: '#00ADD8', 63 + Vue: '#4FC08D', 64 + Swift: '#FA7343', 65 + Kotlin: '#A97BFF', 66 + SCSS: '#C6538C', 67 + Less: '#1D365D', 68 + Dockerfile: '#384D54', 69 + Makefile: '#427819' 70 + }; 71 + 72 + function getLanguageColor(lang: string): string { 73 + return languageColors[lang] || '#8E8E8E'; 74 + } 41 75 42 76 // Group skills by category 43 77 const skillsByCategory = $derived( ··· 149 183 <div class="mt-2 flex items-center gap-1 text-sm text-ink-600 dark:text-ink-300"> 150 184 <MapPin class="h-4 w-4" aria-hidden="true" /> 151 185 <span> 152 - {#if profile.location.city}{profile.location.city}, {/if} 153 - {#if profile.location.region}{profile.location.region}, {/if} 186 + {#if profile.location.city}{profile.location.city}, 187 + {/if} 188 + {#if profile.location.region}{profile.location.region}, 189 + {/if} 154 190 {profile.location.countryCode} 155 191 </span> 156 192 </div> ··· 179 215 <!-- Workplace preference --> 180 216 {#if profile.preferredWorkplace && profile.preferredWorkplace.length > 0} 181 217 <div class="mt-4"> 182 - <h2 class="mb-2 text-sm font-medium text-ink-600 dark:text-ink-300">Preferred workplace</h2> 218 + <h2 class="mb-2 text-sm font-medium text-ink-600 dark:text-ink-300"> 219 + Preferred workplace 220 + </h2> 183 221 <div class="flex gap-3"> 184 222 {#each formatWorkplace(profile.preferredWorkplace) as wp} 185 223 <span class="text-sm text-ink-700 dark:text-ink-200">{wp}</span> ··· 203 241 {#snippet children()} 204 242 <div class="flex items-start gap-3"> 205 243 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 206 - <Building2 class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 244 + <Building2 245 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 246 + aria-hidden="true" 247 + /> 207 248 </div> 208 249 <div class="flex-1"> 209 250 <div class="flex items-start justify-between gap-2"> ··· 212 253 <p class="text-sm text-ink-600 dark:text-ink-300">{position.company}</p> 213 254 </div> 214 255 {#if position.isPrimary} 215 - <span class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900 dark:text-primary-200">Current</span> 256 + <span 257 + class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900 dark:text-primary-200" 258 + >Current</span 259 + > 216 260 {/if} 217 261 </div> 218 262 <div class="mt-2 flex flex-wrap gap-2 text-xs text-ink-500 dark:text-ink-400"> ··· 221 265 {formatDateRange(position.startedAt, position.endedAt)} 222 266 </span> 223 267 {#if position.employmentType} 224 - <span>· {employmentTypeLabels[stripLexiconPrefix(position.employmentType)] || position.employmentType}</span> 268 + <span 269 + >· {employmentTypeLabels[stripLexiconPrefix(position.employmentType)] || 270 + position.employmentType}</span 271 + > 225 272 {/if} 226 273 {#if position.workplaceType} 227 - <span>· {workplaceTypeLabels[stripLexiconPrefix(position.workplaceType)] || position.workplaceType}</span> 274 + <span 275 + >· {workplaceTypeLabels[stripLexiconPrefix(position.workplaceType)] || 276 + position.workplaceType}</span 277 + > 228 278 {/if} 229 279 </div> 230 280 {#if position.description} 231 - <p class="mt-3 text-sm text-ink-700 dark:text-ink-200">{position.description}</p> 281 + <p class="mt-3 text-sm text-ink-700 dark:text-ink-200"> 282 + {position.description} 283 + </p> 232 284 {/if} 233 285 </div> 234 286 </div> ··· 249 301 {#snippet children()} 250 302 <div class="flex items-start gap-3"> 251 303 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 252 - <GraduationCap class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 304 + <GraduationCap 305 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 306 + aria-hidden="true" 307 + /> 253 308 </div> 254 309 <div class="flex-1"> 255 310 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{edu.institution}</h3> 256 311 {#if edu.degree || edu.fieldOfStudy} 257 312 <p class="text-sm text-ink-600 dark:text-ink-300"> 258 313 {#if edu.degree}{edu.degree}{/if} 259 - {#if edu.fieldOfStudy} in {edu.fieldOfStudy}{/if} 314 + {#if edu.fieldOfStudy} 315 + in {edu.fieldOfStudy}{/if} 260 316 </p> 261 317 {/if} 262 318 {#if edu.startedAt || edu.endedAt} ··· 317 373 {#snippet children()} 318 374 <div class="flex items-start gap-3"> 319 375 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 320 - <FolderGit2 class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 376 + <FolderGit2 377 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 378 + aria-hidden="true" 379 + /> 321 380 </div> 322 381 <div class="min-w-0 flex-1"> 323 382 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{project.name}</h3> ··· 350 409 {#snippet children()} 351 410 <div class="flex items-start gap-3"> 352 411 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 353 - <Heart class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 412 + <Heart 413 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 414 + aria-hidden="true" 415 + /> 354 416 </div> 355 417 <div class="flex-1"> 356 418 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{vol.organization}</h3> ··· 394 456 {#snippet children()} 395 457 <div class="flex items-start gap-3"> 396 458 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 397 - <FileText class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 459 + <FileText 460 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 461 + aria-hidden="true" 462 + /> 398 463 </div> 399 464 <div class="flex-1"> 400 465 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{pub.title}</h3> ··· 428 493 {#snippet children()} 429 494 <div class="flex items-start gap-3"> 430 495 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 431 - <Award class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 496 + <Award 497 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 498 + aria-hidden="true" 499 + /> 432 500 </div> 433 501 <div class="flex-1"> 434 502 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{honor.title}</h3> ··· 462 530 {#snippet children()} 463 531 <div class="flex items-start gap-3"> 464 532 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 465 - <Award class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 533 + <Award 534 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 535 + aria-hidden="true" 536 + /> 466 537 </div> 467 538 <div class="flex-1"> 468 539 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{cert.name}</h3> ··· 493 564 {#snippet children()} 494 565 <div class="flex items-start gap-3"> 495 566 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 496 - <BookOpen class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 567 + <BookOpen 568 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 569 + aria-hidden="true" 570 + /> 497 571 </div> 498 572 <div class="min-w-0 flex-1"> 499 573 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{course.name}</h3> ··· 522 596 {#snippet children()} 523 597 <div class="flex items-center gap-3"> 524 598 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 525 - <Languages class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 599 + <Languages 600 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 601 + aria-hidden="true" 602 + /> 526 603 </div> 527 604 <div> 528 605 <h3 class="font-semibold text-ink-900 dark:text-ink-50">{lang.name}</h3> 529 606 <p class="text-sm text-ink-600 dark:text-ink-300"> 530 - {proficiencyLabels[lang.proficiency.replace('id.sifa.defs#', '')] || lang.proficiency} 607 + {proficiencyLabels[lang.proficiency.replace('id.sifa.defs#', '')] || 608 + lang.proficiency} 531 609 </p> 532 610 </div> 533 611 </div> ··· 561 639 {/if} 562 640 {account.label || account.url} 563 641 {#if account.isPrimary} 564 - <span class="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 dark:bg-primary-900 dark:text-primary-200">Primary</span> 642 + <span 643 + class="rounded bg-primary-100 px-1.5 py-0.5 text-xs text-primary-700 dark:bg-primary-900 dark:text-primary-200" 644 + >Primary</span 645 + > 565 646 {/if} 566 647 </a> 648 + {/each} 649 + </div> 650 + </section> 651 + {/if} 652 + 653 + <!-- GitHub Contribution Graph --> 654 + {#if contributions} 655 + <section> 656 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">GitHub Activity</h2> 657 + <Card variant="elevated" padding="lg"> 658 + {#snippet children()} 659 + <div class="flex items-center justify-between"> 660 + <h3 class="text-lg font-medium text-ink-900 dark:text-ink-50"> 661 + {formatCompactNumber(contributions.total, locale)} contributions 662 + </h3> 663 + <span class="text-sm text-ink-600 dark:text-ink-300">last 90 days</span> 664 + </div> 665 + 666 + <!-- Contribution grid --> 667 + <div class="mt-4 overflow-x-auto"> 668 + <div class="flex gap-[3px]" style="width: min-content;"> 669 + {#each contributions.weeks as week} 670 + <div class="flex flex-col gap-[3px]"> 671 + {#each week as day} 672 + <div 673 + class="h-3 w-3 rounded-sm transition-colors" 674 + class:bg-canvas-200={day.level === 0} 675 + class:dark:bg-canvas-700={day.level === 0} 676 + class:bg-primary-200={day.level === 1} 677 + class:dark:bg-primary-900={day.level === 1} 678 + class:bg-primary-300={day.level === 2} 679 + class:dark:bg-primary-700={day.level === 2} 680 + class:bg-primary-400={day.level === 3} 681 + class:dark:bg-primary-500={day.level === 3} 682 + class:bg-primary-600={day.level === 4} 683 + class:dark:bg-primary-400={day.level === 4} 684 + title={day.date 685 + ? `${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date}` 686 + : ''} 687 + role="img" 688 + aria-label={day.date 689 + ? `${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date}` 690 + : ''} 691 + ></div> 692 + {/each} 693 + </div> 694 + {/each} 695 + </div> 696 + </div> 697 + 698 + <!-- Legend --> 699 + <div 700 + class="mt-4 flex items-center justify-end gap-2 text-xs text-ink-600 dark:text-ink-300" 701 + > 702 + <span>Less</span> 703 + {#each [0, 1, 2, 3, 4] as level} 704 + <div 705 + class="h-3 w-3 rounded-sm" 706 + class:bg-canvas-200={level === 0} 707 + class:dark:bg-canvas-700={level === 0} 708 + class:bg-primary-200={level === 1} 709 + class:dark:bg-primary-900={level === 1} 710 + class:bg-primary-300={level === 2} 711 + class:dark:bg-primary-700={level === 2} 712 + class:bg-primary-400={level === 3} 713 + class:dark:bg-primary-500={level === 3} 714 + class:bg-primary-600={level === 4} 715 + class:dark:bg-primary-400={level === 4} 716 + ></div> 717 + {/each} 718 + <span>More</span> 719 + </div> 720 + 721 + <!-- Link to GitHub profile --> 722 + {#if github?.profile} 723 + <div 724 + class="mt-4 flex items-center justify-between border-t border-canvas-200 pt-4 dark:border-canvas-700" 725 + > 726 + <div class="flex items-center gap-3"> 727 + <div class="h-8 w-8 overflow-hidden rounded-full bg-canvas-200 dark:bg-canvas-700"> 728 + <NoiseImage 729 + src={github.profile.avatar_url} 730 + seed={`${github.profile.login}|github|avatar`} 731 + class="h-full w-full object-cover" 732 + alt="{github.profile.name || github.profile.login}'s avatar" 733 + /> 734 + </div> 735 + <div> 736 + <span class="text-sm font-medium text-ink-900 dark:text-ink-50" 737 + >@{github.profile.login}</span 738 + > 739 + <span class="ml-2 text-xs text-ink-500 dark:text-ink-400"> 740 + {formatCompactNumber(github.profile.public_repos, locale)} repos · {formatCompactNumber( 741 + github.profile.followers, 742 + locale 743 + )} followers 744 + </span> 745 + </div> 746 + </div> 747 + <a 748 + href={github.profile.html_url} 749 + target="_blank" 750 + rel="noopener noreferrer" 751 + class="inline-flex items-center gap-1.5 rounded-lg bg-ink-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ink-800 dark:bg-ink-100 dark:text-ink-900 dark:hover:bg-ink-200" 752 + > 753 + <svg class="h-4 w-4" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"> 754 + <path 755 + 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" 756 + /> 757 + </svg> 758 + Profile 759 + <ExternalLink class="h-3 w-3" aria-hidden="true" /> 760 + </a> 761 + </div> 762 + {/if} 763 + {/snippet} 764 + </Card> 765 + </section> 766 + {/if} 767 + 768 + <!-- Notable Repositories --> 769 + {#if github?.repos && github.repos.length > 0} 770 + <section> 771 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Notable Repositories</h2> 772 + <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 773 + {#each github.repos as repo} 774 + <Card 775 + variant="default" 776 + padding="md" 777 + class="flex flex-col" 778 + interactive={true} 779 + href={repo.html_url} 780 + showExternalIcon={true} 781 + > 782 + {#snippet children()} 783 + <div class="flex items-start gap-3"> 784 + <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 785 + <GitBranch 786 + class="h-5 w-5 text-primary-600 dark:text-primary-400" 787 + aria-hidden="true" 788 + /> 789 + </div> 790 + <div class="min-w-0 flex-1"> 791 + <h3 class="truncate font-semibold text-ink-900 dark:text-ink-50"> 792 + {repo.name} 793 + </h3> 794 + {#if repo.description} 795 + <p class="mt-1 line-clamp-2 text-sm text-ink-700 dark:text-ink-200"> 796 + {repo.description} 797 + </p> 798 + {/if} 799 + </div> 800 + </div> 801 + 802 + <!-- Repo stats --> 803 + <div 804 + class="mt-4 flex flex-wrap items-center gap-4 text-xs text-ink-600 dark:text-ink-300" 805 + > 806 + {#if repo.language} 807 + <span class="flex items-center gap-1"> 808 + <span 809 + class="h-3 w-3 rounded-full" 810 + style="background-color: {getLanguageColor(repo.language)}" 811 + aria-hidden="true" 812 + ></span> 813 + {repo.language} 814 + </span> 815 + {/if} 816 + <span class="flex items-center gap-1"> 817 + <Star class="h-3.5 w-3.5 text-yellow-500" aria-hidden="true" /> 818 + {formatCompactNumber(repo.stargazers_count, locale)} 819 + </span> 820 + <span class="flex items-center gap-1"> 821 + <GitBranch class="h-3.5 w-3.5" aria-hidden="true" /> 822 + {formatCompactNumber(repo.forks_count, locale)} 823 + </span> 824 + {#if repo.homepage} 825 + <span class="flex items-center gap-1 text-primary-600 dark:text-primary-400"> 826 + <LinkIcon class="h-3.5 w-3.5" aria-hidden="true" /> 827 + demo 828 + </span> 829 + {/if} 830 + </div> 831 + 832 + <!-- Topics --> 833 + {#if repo.topics && repo.topics.length > 0} 834 + <div class="mt-3 flex flex-wrap gap-1.5"> 835 + {#each repo.topics.slice(0, 4) as topic} 836 + <span 837 + 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" 838 + > 839 + {topic} 840 + </span> 841 + {/each} 842 + {#if repo.topics.length > 4} 843 + <span class="text-xs text-ink-500">+{repo.topics.length - 4}</span> 844 + {/if} 845 + </div> 846 + {/if} 847 + 848 + <!-- License --> 849 + {#if repo.license} 850 + <div class="mt-2 text-xs text-ink-500 dark:text-ink-400"> 851 + {repo.license.spdx_id} 852 + </div> 853 + {/if} 854 + {/snippet} 855 + </Card> 567 856 {/each} 568 857 </div> 569 858 </section>
+11 -2
src/routes/work/+page.ts
··· 14 14 fetchSifaCourses, 15 15 fetchSifaPublications 16 16 } from '$lib/services/atproto'; 17 + import { fetchGitHubData, fetchContributions } from '$lib/services/github'; 18 + 19 + const GITHUB_USERNAME = 'ewanc26'; 17 20 18 21 export const load: PageLoad = async ({ fetch }) => { 19 22 const [ ··· 28 31 volunteering, 29 32 honors, 30 33 courses, 31 - publications 34 + publications, 35 + githubData, 36 + contributions 32 37 ] = await Promise.all([ 33 38 fetchSifaProfile(fetch), 34 39 fetchSifaSkills(fetch), ··· 41 46 fetchSifaVolunteering(fetch), 42 47 fetchSifaHonors(fetch), 43 48 fetchSifaCourses(fetch), 44 - fetchSifaPublications(fetch) 49 + fetchSifaPublications(fetch), 50 + fetchGitHubData(GITHUB_USERNAME, fetch), 51 + fetchContributions(GITHUB_USERNAME, fetch, 90) 45 52 ]); 46 53 47 54 const meta = createDynamicSiteMeta({ ··· 62 69 honors, 63 70 courses, 64 71 publications, 72 + github: githubData, 73 + contributions, 65 74 meta 66 75 }; 67 76 };