my website at ewancroft.uk
6
fork

Configure Feed

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

feat: support all Sifa profile lexicons on /work route

Now displays:
- Positions (work experience with employment type, workplace type)
- Education (degrees, fields of study)
- Volunteering (organizations, causes)
- Publications (titles, publishers)
- Honors & Awards
- Courses
- Languages
- Skills (by category)
- Projects
- Certifications
- External accounts/links

Updated to @ewanc26/atproto@0.2.10

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

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

+392 -39
+1 -1
README.md
··· 2 2 3 3 Personal website powered by AT Protocol, built with SvelteKit 2 and Tailwind CSS 4. 4 4 5 - **Version**: 11.7.1 5 + **Version**: 11.7.2 6 6 7 7 Live at [ewancroft.uk](https://ewancroft.uk). 8 8
+2 -2
package.json
··· 1 1 { 2 2 "name": "website", 3 3 "private": true, 4 - "version": "11.7.1", 4 + "version": "11.7.2", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev", ··· 38 38 }, 39 39 "dependencies": { 40 40 "@atproto/api": "^0.18.21", 41 - "@ewanc26/atproto": "^0.2.9", 41 + "@ewanc26/atproto": "^0.2.10", 42 42 "@ewanc26/noise-avatar": "^0.2.3", 43 43 "@ewanc26/og": "^0.1.8", 44 44 "@ewanc26/supporters": "^0.3.0",
+6 -6
pnpm-lock.yaml
··· 12 12 specifier: ^0.18.21 13 13 version: 0.18.21 14 14 '@ewanc26/atproto': 15 - specifier: ^0.2.9 16 - version: 0.2.9(@atproto/api@0.18.21) 15 + specifier: ^0.2.10 16 + version: 0.2.10(@atproto/api@0.18.21) 17 17 '@ewanc26/noise-avatar': 18 18 specifier: ^0.2.3 19 19 version: 0.2.3 ··· 611 611 cpu: [x64] 612 612 os: [win32] 613 613 614 - '@ewanc26/atproto@0.2.9': 615 - resolution: {integrity: sha512-iia673Wvn2u+gSdSaRkyFSfcS3ve2bVl2RFUyyZQIMa6q7iCq+dmNHsiQ91ZuE4jUPV3hOVBSK2Q1V3uSrw9lA==} 614 + '@ewanc26/atproto@0.2.10': 615 + resolution: {integrity: sha512-81wd0jqiJ4NM/Y8E1tF5PMi1vQXdh5Th8qA1iqLX77TriHLREyzMOsCbKw7fM6nORwNSh1oP59fRIk2BTOA89g==} 616 616 peerDependencies: 617 617 '@atproto/api': '>=0.13.0' 618 618 ··· 3105 3105 '@esbuild/win32-x64@0.27.4': 3106 3106 optional: true 3107 3107 3108 - '@ewanc26/atproto@0.2.9(@atproto/api@0.18.21)': 3108 + '@ewanc26/atproto@0.2.10(@atproto/api@0.18.21)': 3109 3109 dependencies: 3110 3110 '@atproto/api': 0.18.21 3111 3111 ··· 3139 3139 svelte: 5.54.1 3140 3140 tailwindcss: 4.2.2 3141 3141 optionalDependencies: 3142 - '@ewanc26/atproto': 0.2.9(@atproto/api@0.18.21) 3142 + '@ewanc26/atproto': 0.2.10(@atproto/api@0.18.21) 3143 3143 transitivePeerDependencies: 3144 3144 - '@atproto/api' 3145 3145
+31 -1
src/lib/services/atproto/fetch.ts
··· 14 14 fetchSifaProjects as _fetchSifaProjects, 15 15 fetchSifaLanguages as _fetchSifaLanguages, 16 16 fetchSifaCertifications as _fetchSifaCertifications, 17 - fetchSifaExternalAccounts as _fetchSifaExternalAccounts 17 + fetchSifaExternalAccounts as _fetchSifaExternalAccounts, 18 + fetchSifaPositions as _fetchSifaPositions, 19 + fetchSifaEducation as _fetchSifaEducation, 20 + fetchSifaVolunteering as _fetchSifaVolunteering, 21 + fetchSifaHonors as _fetchSifaHonors, 22 + fetchSifaCourses as _fetchSifaCourses, 23 + fetchSifaPublications as _fetchSifaPublications 18 24 } from '@ewanc26/atproto'; 19 25 20 26 export async function fetchProfile(fetchFn?: typeof fetch) { ··· 68 74 export async function fetchSifaExternalAccounts(fetchFn?: typeof fetch) { 69 75 return _fetchSifaExternalAccounts(PUBLIC_ATPROTO_DID, fetchFn); 70 76 } 77 + 78 + export async function fetchSifaPositions(fetchFn?: typeof fetch) { 79 + return _fetchSifaPositions(PUBLIC_ATPROTO_DID, fetchFn); 80 + } 81 + 82 + export async function fetchSifaEducation(fetchFn?: typeof fetch) { 83 + return _fetchSifaEducation(PUBLIC_ATPROTO_DID, fetchFn); 84 + } 85 + 86 + export async function fetchSifaVolunteering(fetchFn?: typeof fetch) { 87 + return _fetchSifaVolunteering(PUBLIC_ATPROTO_DID, fetchFn); 88 + } 89 + 90 + export async function fetchSifaHonors(fetchFn?: typeof fetch) { 91 + return _fetchSifaHonors(PUBLIC_ATPROTO_DID, fetchFn); 92 + } 93 + 94 + export async function fetchSifaCourses(fetchFn?: typeof fetch) { 95 + return _fetchSifaCourses(PUBLIC_ATPROTO_DID, fetchFn); 96 + } 97 + 98 + export async function fetchSifaPublications(fetchFn?: typeof fetch) { 99 + return _fetchSifaPublications(PUBLIC_ATPROTO_DID, fetchFn); 100 + }
+14 -2
src/lib/services/atproto/index.ts
··· 46 46 SifaLanguage, 47 47 SifaCertification, 48 48 SifaExternalAccount, 49 - SifaLocation 49 + SifaLocation, 50 + SifaPosition, 51 + SifaEducation, 52 + SifaVolunteering, 53 + SifaHonor, 54 + SifaCourse, 55 + SifaPublication 50 56 } from './types'; 51 57 52 58 // Export fetch functions ··· 63 69 fetchSifaProjects, 64 70 fetchSifaLanguages, 65 71 fetchSifaCertifications, 66 - fetchSifaExternalAccounts 72 + fetchSifaExternalAccounts, 73 + fetchSifaPositions, 74 + fetchSifaEducation, 75 + fetchSifaVolunteering, 76 + fetchSifaHonors, 77 + fetchSifaCourses, 78 + fetchSifaPublications 67 79 } from './fetch'; 68 80 69 81 // Export Standard.site document functions
+8 -1
src/lib/services/atproto/types.ts
··· 41 41 SifaLanguage, 42 42 SifaCertification, 43 43 SifaExternalAccount, 44 - SifaLocation 44 + SifaLocation, 45 + SifaPosition, 46 + SifaEducation, 47 + SifaVolunteering, 48 + SifaHonor, 49 + SifaCourse, 50 + SifaPublication, 51 + SifaPublicationAuthor 45 52 } from '@ewanc26/atproto';
+287 -22
src/routes/work/+page.svelte
··· 10 10 Link as LinkIcon, 11 11 Globe, 12 12 Github, 13 - Rss 13 + Rss, 14 + Building2, 15 + GraduationCap, 16 + Heart, 17 + BookOpen, 18 + FileText, 19 + Calendar 14 20 } from '@lucide/svelte'; 15 21 import type { PageData } from './$types'; 16 - import type { 17 - SifaSkill, 18 - SifaProject, 19 - SifaLanguage, 20 - SifaCertification, 21 - SifaExternalAccount 22 - } from '@ewanc26/atproto'; 22 + import type { SifaSkill } from '@ewanc26/atproto'; 23 23 24 24 let { data }: { data: PageData } = $props(); 25 25 26 - const { profile, skills, projects, languages, certifications, externalAccounts, meta } = $derived(data); 26 + const { 27 + profile, 28 + skills, 29 + projects, 30 + languages, 31 + certifications, 32 + externalAccounts, 33 + positions, 34 + education, 35 + volunteering, 36 + honors, 37 + courses, 38 + publications, 39 + meta 40 + } = $derived(data); 27 41 28 42 // Group skills by category 29 43 const skillsByCategory = $derived( ··· 55 69 native: 'Native' 56 70 }; 57 71 72 + const employmentTypeLabels: Record<string, string> = { 73 + fullTime: 'Full-time', 74 + partTime: 'Part-time', 75 + contract: 'Contract', 76 + freelance: 'Freelance', 77 + internship: 'Internship', 78 + apprenticeship: 'Apprenticeship', 79 + volunteer: 'Volunteer', 80 + selfEmployed: 'Self-employed' 81 + }; 82 + 83 + const workplaceTypeLabels: Record<string, string> = { 84 + onSite: 'On-site', 85 + remote: 'Remote', 86 + hybrid: 'Hybrid' 87 + }; 88 + 58 89 function formatOpenTo(openTo: string[]): string[] { 59 90 return openTo 60 91 .map((o) => o.replace('id.sifa.defs#', '')) ··· 87 118 } catch { 88 119 return dateStr; 89 120 } 121 + } 122 + 123 + function formatDateRange(start: string, end?: string): string { 124 + const startFormatted = formatDate(start); 125 + if (!end) return `${startFormatted} — Present`; 126 + return `${startFormatted} — ${formatDate(end)}`; 127 + } 128 + 129 + function stripLexiconPrefix(value: string): string { 130 + return value.replace('id.sifa.defs#', ''); 90 131 } 91 132 </script> 92 133 ··· 152 193 </Card> 153 194 {/if} 154 195 196 + <!-- Positions Section --> 197 + {#if positions.length > 0} 198 + <section> 199 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Experience</h2> 200 + <div class="space-y-4"> 201 + {#each positions as position} 202 + <Card key={position.uri} variant="default" padding="md"> 203 + {#snippet children()} 204 + <div class="flex items-start gap-3"> 205 + <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" /> 207 + </div> 208 + <div class="flex-1"> 209 + <div class="flex items-start justify-between gap-2"> 210 + <div> 211 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{position.title}</h3> 212 + <p class="text-sm text-ink-600 dark:text-ink-300">{position.company}</p> 213 + </div> 214 + {#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> 216 + {/if} 217 + </div> 218 + <div class="mt-2 flex flex-wrap gap-2 text-xs text-ink-500 dark:text-ink-400"> 219 + <span class="flex items-center gap-1"> 220 + <Calendar class="h-3 w-3" aria-hidden="true" /> 221 + {formatDateRange(position.startedAt, position.endedAt)} 222 + </span> 223 + {#if position.employmentType} 224 + <span>· {employmentTypeLabels[stripLexiconPrefix(position.employmentType)] || position.employmentType}</span> 225 + {/if} 226 + {#if position.workplaceType} 227 + <span>· {workplaceTypeLabels[stripLexiconPrefix(position.workplaceType)] || position.workplaceType}</span> 228 + {/if} 229 + </div> 230 + {#if position.description} 231 + <p class="mt-3 text-sm text-ink-700 dark:text-ink-200">{position.description}</p> 232 + {/if} 233 + </div> 234 + </div> 235 + {/snippet} 236 + </Card> 237 + {/each} 238 + </div> 239 + </section> 240 + {/if} 241 + 242 + <!-- Education Section --> 243 + {#if education.length > 0} 244 + <section> 245 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Education</h2> 246 + <div class="space-y-4"> 247 + {#each education as edu} 248 + <Card key={edu.uri} variant="default" padding="md"> 249 + {#snippet children()} 250 + <div class="flex items-start gap-3"> 251 + <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" /> 253 + </div> 254 + <div class="flex-1"> 255 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{edu.institution}</h3> 256 + {#if edu.degree || edu.fieldOfStudy} 257 + <p class="text-sm text-ink-600 dark:text-ink-300"> 258 + {#if edu.degree}{edu.degree}{/if} 259 + {#if edu.fieldOfStudy} in {edu.fieldOfStudy}{/if} 260 + </p> 261 + {/if} 262 + {#if edu.startedAt || edu.endedAt} 263 + <p class="mt-1 text-xs text-ink-500 dark:text-ink-400"> 264 + {formatDateRange(edu.startedAt || '', edu.endedAt)} 265 + </p> 266 + {/if} 267 + {#if edu.description} 268 + <p class="mt-2 text-sm text-ink-700 dark:text-ink-200">{edu.description}</p> 269 + {/if} 270 + </div> 271 + </div> 272 + {/snippet} 273 + </Card> 274 + {/each} 275 + </div> 276 + </section> 277 + {/if} 278 + 155 279 <!-- Skills Section --> 156 280 {#if skills.length > 0} 157 281 <section> ··· 216 340 </section> 217 341 {/if} 218 342 219 - <!-- Languages Section --> 220 - {#if languages.length > 0} 343 + <!-- Volunteering Section --> 344 + {#if volunteering.length > 0} 345 + <section> 346 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Volunteering</h2> 347 + <div class="space-y-4"> 348 + {#each volunteering as vol} 349 + <Card key={vol.uri} variant="default" padding="md"> 350 + {#snippet children()} 351 + <div class="flex items-start gap-3"> 352 + <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" /> 354 + </div> 355 + <div class="flex-1"> 356 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{vol.organization}</h3> 357 + {#if vol.role} 358 + <p class="text-sm text-ink-600 dark:text-ink-300">{vol.role}</p> 359 + {/if} 360 + {#if vol.cause} 361 + <p class="text-xs text-ink-500 dark:text-ink-400">{vol.cause}</p> 362 + {/if} 363 + {#if vol.startedAt} 364 + <p class="mt-1 text-xs text-ink-500 dark:text-ink-400"> 365 + {formatDateRange(vol.startedAt, vol.endedAt)} 366 + </p> 367 + {/if} 368 + {#if vol.description} 369 + <p class="mt-2 text-sm text-ink-700 dark:text-ink-200">{vol.description}</p> 370 + {/if} 371 + </div> 372 + </div> 373 + {/snippet} 374 + </Card> 375 + {/each} 376 + </div> 377 + </section> 378 + {/if} 379 + 380 + <!-- Publications Section --> 381 + {#if publications.length > 0} 221 382 <section> 222 - <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Languages</h2> 223 - <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 224 - {#each languages as lang} 225 - <Card key={lang.uri} variant="default" padding="md"> 383 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Publications</h2> 384 + <div class="space-y-4"> 385 + {#each publications as pub} 386 + <Card 387 + key={pub.uri} 388 + variant="default" 389 + padding="md" 390 + interactive={!!pub.url} 391 + href={pub.url || undefined} 392 + showExternalIcon={!!pub.url} 393 + > 226 394 {#snippet children()} 227 - <div class="flex items-center gap-3"> 395 + <div class="flex items-start gap-3"> 228 396 <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 229 - <Languages class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 397 + <FileText class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 230 398 </div> 231 - <div> 232 - <h3 class="font-semibold text-ink-900 dark:text-ink-50">{lang.name}</h3> 233 - <p class="text-sm text-ink-600 dark:text-ink-300"> 234 - {proficiencyLabels[lang.proficiency.replace('id.sifa.defs#', '')] || lang.proficiency} 235 - </p> 399 + <div class="flex-1"> 400 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{pub.title}</h3> 401 + {#if pub.publisher} 402 + <p class="text-sm text-ink-600 dark:text-ink-300">{pub.publisher}</p> 403 + {/if} 404 + {#if pub.publishedAt} 405 + <p class="mt-1 text-xs text-ink-500 dark:text-ink-400"> 406 + Published {formatDate(pub.publishedAt)} 407 + </p> 408 + {/if} 409 + {#if pub.description} 410 + <p class="mt-2 text-sm text-ink-700 dark:text-ink-200">{pub.description}</p> 411 + {/if} 412 + </div> 413 + </div> 414 + {/snippet} 415 + </Card> 416 + {/each} 417 + </div> 418 + </section> 419 + {/if} 420 + 421 + <!-- Honors Section --> 422 + {#if honors.length > 0} 423 + <section> 424 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Honors & Awards</h2> 425 + <div class="space-y-4"> 426 + {#each honors as honor} 427 + <Card key={honor.uri} variant="default" padding="md"> 428 + {#snippet children()} 429 + <div class="flex items-start gap-3"> 430 + <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" /> 432 + </div> 433 + <div class="flex-1"> 434 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{honor.title}</h3> 435 + {#if honor.issuer} 436 + <p class="text-sm text-ink-600 dark:text-ink-300">{honor.issuer}</p> 437 + {/if} 438 + {#if honor.awardedAt} 439 + <p class="mt-1 text-xs text-ink-500 dark:text-ink-400"> 440 + Awarded {formatDate(honor.awardedAt)} 441 + </p> 442 + {/if} 443 + {#if honor.description} 444 + <p class="mt-2 text-sm text-ink-700 dark:text-ink-200">{honor.description}</p> 445 + {/if} 236 446 </div> 237 447 </div> 238 448 {/snippet} ··· 264 474 Issued {formatDate(cert.issuedAt)} 265 475 </p> 266 476 {/if} 477 + </div> 478 + </div> 479 + {/snippet} 480 + </Card> 481 + {/each} 482 + </div> 483 + </section> 484 + {/if} 485 + 486 + <!-- Courses Section --> 487 + {#if courses.length > 0} 488 + <section> 489 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Courses</h2> 490 + <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 491 + {#each courses as course} 492 + <Card key={course.uri} variant="default" padding="md"> 493 + {#snippet children()} 494 + <div class="flex items-start gap-3"> 495 + <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" /> 497 + </div> 498 + <div class="min-w-0 flex-1"> 499 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{course.name}</h3> 500 + {#if course.institution} 501 + <p class="text-sm text-ink-600 dark:text-ink-300">{course.institution}</p> 502 + {/if} 503 + {#if course.number} 504 + <p class="text-xs text-ink-500 dark:text-ink-400">{course.number}</p> 505 + {/if} 506 + </div> 507 + </div> 508 + {/snippet} 509 + </Card> 510 + {/each} 511 + </div> 512 + </section> 513 + {/if} 514 + 515 + <!-- Languages Section --> 516 + {#if languages.length > 0} 517 + <section> 518 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Languages</h2> 519 + <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 520 + {#each languages as lang} 521 + <Card key={lang.uri} variant="default" padding="md"> 522 + {#snippet children()} 523 + <div class="flex items-center gap-3"> 524 + <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" /> 526 + </div> 527 + <div> 528 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{lang.name}</h3> 529 + <p class="text-sm text-ink-600 dark:text-ink-300"> 530 + {proficiencyLabels[lang.proficiency.replace('id.sifa.defs#', '')] || lang.proficiency} 531 + </p> 267 532 </div> 268 533 </div> 269 534 {/snippet}
+43 -4
src/routes/work/+page.ts
··· 6 6 fetchSifaProjects, 7 7 fetchSifaLanguages, 8 8 fetchSifaCertifications, 9 - fetchSifaExternalAccounts 9 + fetchSifaExternalAccounts, 10 + fetchSifaPositions, 11 + fetchSifaEducation, 12 + fetchSifaVolunteering, 13 + fetchSifaHonors, 14 + fetchSifaCourses, 15 + fetchSifaPublications 10 16 } from '$lib/services/atproto'; 11 17 12 18 export const load: PageLoad = async ({ fetch }) => { 13 - const [profile, skills, projects, languages, certifications, externalAccounts] = await Promise.all([ 19 + const [ 20 + profile, 21 + skills, 22 + projects, 23 + languages, 24 + certifications, 25 + externalAccounts, 26 + positions, 27 + education, 28 + volunteering, 29 + honors, 30 + courses, 31 + publications 32 + ] = await Promise.all([ 14 33 fetchSifaProfile(fetch), 15 34 fetchSifaSkills(fetch), 16 35 fetchSifaProjects(fetch), 17 36 fetchSifaLanguages(fetch), 18 37 fetchSifaCertifications(fetch), 19 - fetchSifaExternalAccounts(fetch) 38 + fetchSifaExternalAccounts(fetch), 39 + fetchSifaPositions(fetch), 40 + fetchSifaEducation(fetch), 41 + fetchSifaVolunteering(fetch), 42 + fetchSifaHonors(fetch), 43 + fetchSifaCourses(fetch), 44 + fetchSifaPublications(fetch) 20 45 ]); 21 46 22 47 const meta = createDynamicSiteMeta({ ··· 24 49 description: profile?.headline || 'Professional profile, skills, and projects' 25 50 }); 26 51 27 - return { profile, skills, projects, languages, certifications, externalAccounts, meta }; 52 + return { 53 + profile, 54 + skills, 55 + projects, 56 + languages, 57 + certifications, 58 + externalAccounts, 59 + positions, 60 + education, 61 + volunteering, 62 + honors, 63 + courses, 64 + publications, 65 + meta 66 + }; 28 67 };