my website at ewancroft.uk
6
fork

Configure Feed

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

feat: add /work route displaying Sifa professional profile

New route showcasing Sifa ID data:
- Profile hero with headline, about, location, open to
- Skills grid grouped by category
- Projects grid with dates
- Languages with proficiency levels
- Certifications timeline
- External accounts links

Uses linked local @ewanc26/atproto@0.2.9 for Sifa fetch functions.

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

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

+392 -7
+1 -1
package.json
··· 38 38 }, 39 39 "dependencies": { 40 40 "@atproto/api": "^0.18.21", 41 - "@ewanc26/atproto": "^0.2.8", 41 + "@ewanc26/atproto": "link:../pkgs/packages/atproto", 42 42 "@ewanc26/noise-avatar": "^0.2.3", 43 43 "@ewanc26/og": "^0.1.8", 44 44 "@ewanc26/supporters": "^0.3.0",
+3 -2
pnpm-lock.yaml
··· 12 12 specifier: ^0.18.21 13 13 version: 0.18.21 14 14 '@ewanc26/atproto': 15 - specifier: ^0.2.8 16 - version: 0.2.8(@atproto/api@0.18.21) 15 + specifier: link:../pkgs/packages/atproto 16 + version: link:../pkgs/packages/atproto 17 17 '@ewanc26/noise-avatar': 18 18 specifier: ^0.2.3 19 19 version: 0.2.3 ··· 3108 3108 '@ewanc26/atproto@0.2.8(@atproto/api@0.18.21)': 3109 3109 dependencies: 3110 3110 '@atproto/api': 0.18.21 3111 + optional: true 3111 3112 3112 3113 '@ewanc26/noise-avatar@0.2.3': 3113 3114 dependencies:
+31 -1
src/lib/services/atproto/fetch.ts
··· 8 8 fetchMusicStatus as _fetchMusicStatus, 9 9 fetchKibunStatus as _fetchKibunStatus, 10 10 fetchTangledRepos as _fetchTangledRepos, 11 - fetchRecentPopfeedReviews as _fetchRecentPopfeedReviews 11 + fetchRecentPopfeedReviews as _fetchRecentPopfeedReviews, 12 + fetchSifaProfile as _fetchSifaProfile, 13 + fetchSifaSkills as _fetchSifaSkills, 14 + fetchSifaProjects as _fetchSifaProjects, 15 + fetchSifaLanguages as _fetchSifaLanguages, 16 + fetchSifaCertifications as _fetchSifaCertifications, 17 + fetchSifaExternalAccounts as _fetchSifaExternalAccounts 12 18 } from '@ewanc26/atproto'; 13 19 14 20 export async function fetchProfile(fetchFn?: typeof fetch) { ··· 38 44 export async function fetchRecentPopfeedReviews(fetchFn?: typeof fetch) { 39 45 return _fetchRecentPopfeedReviews(PUBLIC_ATPROTO_DID, 5, fetchFn); 40 46 } 47 + 48 + export async function fetchSifaProfile(fetchFn?: typeof fetch) { 49 + return _fetchSifaProfile(PUBLIC_ATPROTO_DID, fetchFn); 50 + } 51 + 52 + export async function fetchSifaSkills(fetchFn?: typeof fetch) { 53 + return _fetchSifaSkills(PUBLIC_ATPROTO_DID, fetchFn); 54 + } 55 + 56 + export async function fetchSifaProjects(fetchFn?: typeof fetch) { 57 + return _fetchSifaProjects(PUBLIC_ATPROTO_DID, fetchFn); 58 + } 59 + 60 + export async function fetchSifaLanguages(fetchFn?: typeof fetch) { 61 + return _fetchSifaLanguages(PUBLIC_ATPROTO_DID, fetchFn); 62 + } 63 + 64 + export async function fetchSifaCertifications(fetchFn?: typeof fetch) { 65 + return _fetchSifaCertifications(PUBLIC_ATPROTO_DID, fetchFn); 66 + } 67 + 68 + export async function fetchSifaExternalAccounts(fetchFn?: typeof fetch) { 69 + return _fetchSifaExternalAccounts(PUBLIC_ATPROTO_DID, fetchFn); 70 + }
+15 -2
src/lib/services/atproto/index.ts
··· 39 39 StandardSiteDocument, 40 40 StandardSiteDocumentsData, 41 41 StandardSiteBasicTheme, 42 - StandardSiteThemeColor 42 + StandardSiteThemeColor, 43 + SifaProfileData, 44 + SifaSkill, 45 + SifaProject, 46 + SifaLanguage, 47 + SifaCertification, 48 + SifaExternalAccount, 49 + SifaLocation 43 50 } from './types'; 44 51 45 52 // Export fetch functions ··· 50 57 fetchMusicStatus, 51 58 fetchKibunStatus, 52 59 fetchTangledRepos, 53 - fetchRecentPopfeedReviews 60 + fetchRecentPopfeedReviews, 61 + fetchSifaProfile, 62 + fetchSifaSkills, 63 + fetchSifaProjects, 64 + fetchSifaLanguages, 65 + fetchSifaCertifications, 66 + fetchSifaExternalAccounts 54 67 } from './fetch'; 55 68 56 69 // Export Standard.site document functions
+8 -1
src/lib/services/atproto/types.ts
··· 34 34 StandardSiteDocument, 35 35 StandardSiteDocumentsData, 36 36 StandardSiteBasicTheme, 37 - StandardSiteThemeColor 37 + StandardSiteThemeColor, 38 + SifaProfileData, 39 + SifaSkill, 40 + SifaProject, 41 + SifaLanguage, 42 + SifaCertification, 43 + SifaExternalAccount, 44 + SifaLocation 38 45 } from '@ewanc26/atproto';
+306
src/routes/work/+page.svelte
··· 1 + <script lang="ts"> 2 + import { Card } from '$lib/components/ui'; 3 + import { MetaTags } from '$lib/components/seo'; 4 + import { 5 + MapPin, 6 + Briefcase, 7 + FolderGit2, 8 + Languages, 9 + Award, 10 + Link as LinkIcon, 11 + Globe, 12 + Github, 13 + Rss 14 + } from '@lucide/svelte'; 15 + import type { PageData } from './$types'; 16 + import type { 17 + SifaSkill, 18 + SifaProject, 19 + SifaLanguage, 20 + SifaCertification, 21 + SifaExternalAccount 22 + } from '@ewanc26/atproto'; 23 + 24 + let { data }: { data: PageData } = $props(); 25 + 26 + const { profile, skills, projects, languages, certifications, externalAccounts, meta } = $derived(data); 27 + 28 + // Group skills by category 29 + const skillsByCategory = $derived( 30 + skills.reduce( 31 + (acc, skill) => { 32 + const category = skill.category.replace('id.sifa.defs#', ''); 33 + if (!acc[category]) acc[category] = []; 34 + acc[category].push(skill); 35 + return acc; 36 + }, 37 + {} as Record<string, SifaSkill[]> 38 + ) 39 + ); 40 + 41 + const categoryLabels: Record<string, string> = { 42 + technical: 'Technical', 43 + creative: 'Creative', 44 + industry: 'Industry', 45 + business: 'Business', 46 + interpersonal: 'Interpersonal', 47 + language: 'Language' 48 + }; 49 + 50 + const proficiencyLabels: Record<string, string> = { 51 + elementary: 'Elementary', 52 + limitedWorking: 'Limited Working', 53 + professionalWorking: 'Professional Working', 54 + fullProfessional: 'Full Professional', 55 + native: 'Native' 56 + }; 57 + 58 + function formatOpenTo(openTo: string[]): string[] { 59 + return openTo 60 + .map((o) => o.replace('id.sifa.defs#', '')) 61 + .map((o) => { 62 + const labels: Record<string, string> = { 63 + fullTimeRoles: 'Full-time roles', 64 + partTimeRoles: 'Part-time roles', 65 + contractRoles: 'Contract work', 66 + boardPositions: 'Board positions', 67 + mentoringOthers: 'Mentoring', 68 + beingMentored: 'Being mentored', 69 + collaborations: 'Collaborations' 70 + }; 71 + return labels[o] || o; 72 + }); 73 + } 74 + 75 + function formatWorkplace(workplace: string[]): string[] { 76 + return workplace 77 + .map((w) => w.replace('id.sifa.defs#', '')) 78 + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)); 79 + } 80 + 81 + function formatDate(dateStr: string): string { 82 + try { 83 + return new Date(dateStr).toLocaleDateString('en-GB', { 84 + year: 'numeric', 85 + month: 'long' 86 + }); 87 + } catch { 88 + return dateStr; 89 + } 90 + } 91 + </script> 92 + 93 + <MetaTags {meta} siteMeta={meta} /> 94 + 95 + <div class="mx-auto max-w-6xl space-y-8"> 96 + <!-- Hero Section --> 97 + {#if profile} 98 + <Card variant="elevated" padding="lg"> 99 + {#snippet children()} 100 + <div class="flex items-start gap-4"> 101 + <div class="rounded-xl bg-primary-100 p-3 dark:bg-primary-900"> 102 + <Briefcase class="h-8 w-8 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 103 + </div> 104 + <div class="flex-1"> 105 + <h1 class="text-2xl font-bold text-ink-900 dark:text-ink-50">{profile.headline}</h1> 106 + 107 + {#if profile.location} 108 + <div class="mt-2 flex items-center gap-1 text-sm text-ink-600 dark:text-ink-300"> 109 + <MapPin class="h-4 w-4" aria-hidden="true" /> 110 + <span> 111 + {#if profile.location.city}{profile.location.city}, {/if} 112 + {#if profile.location.region}{profile.location.region}, {/if} 113 + {profile.location.countryCode} 114 + </span> 115 + </div> 116 + {/if} 117 + 118 + {#if profile.about} 119 + <p class="mt-4 whitespace-pre-line text-ink-700 dark:text-ink-200">{profile.about}</p> 120 + {/if} 121 + 122 + <!-- Open to --> 123 + {#if profile.openTo && profile.openTo.length > 0} 124 + <div class="mt-6"> 125 + <h2 class="mb-2 text-sm font-medium text-ink-600 dark:text-ink-300">Open to</h2> 126 + <div class="flex flex-wrap gap-2"> 127 + {#each formatOpenTo(profile.openTo) as item} 128 + <span 129 + class="rounded-full bg-primary-100 px-3 py-1 text-sm font-medium text-primary-700 dark:bg-primary-900 dark:text-primary-200" 130 + > 131 + {item} 132 + </span> 133 + {/each} 134 + </div> 135 + </div> 136 + {/if} 137 + 138 + <!-- Workplace preference --> 139 + {#if profile.preferredWorkplace && profile.preferredWorkplace.length > 0} 140 + <div class="mt-4"> 141 + <h2 class="mb-2 text-sm font-medium text-ink-600 dark:text-ink-300">Preferred workplace</h2> 142 + <div class="flex gap-3"> 143 + {#each formatWorkplace(profile.preferredWorkplace) as wp} 144 + <span class="text-sm text-ink-700 dark:text-ink-200">{wp}</span> 145 + {/each} 146 + </div> 147 + </div> 148 + {/if} 149 + </div> 150 + </div> 151 + {/snippet} 152 + </Card> 153 + {/if} 154 + 155 + <!-- Skills Section --> 156 + {#if skills.length > 0} 157 + <section> 158 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Skills</h2> 159 + {#each Object.entries(skillsByCategory) as [category, categorySkills]} 160 + <div class="mb-6"> 161 + <h3 class="mb-3 text-lg font-medium text-ink-600 dark:text-ink-300"> 162 + {categoryLabels[category] || category} 163 + </h3> 164 + <div class="flex flex-wrap gap-2"> 165 + {#each categorySkills as skill} 166 + <span 167 + class="rounded-lg bg-canvas-200 px-3 py-1.5 text-sm font-medium text-ink-700 dark:bg-canvas-700 dark:text-ink-200" 168 + > 169 + {skill.name} 170 + </span> 171 + {/each} 172 + </div> 173 + </div> 174 + {/each} 175 + </section> 176 + {/if} 177 + 178 + <!-- Projects Section --> 179 + {#if projects.length > 0} 180 + <section> 181 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Projects</h2> 182 + <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 183 + {#each projects as project} 184 + <Card 185 + key={project.uri} 186 + variant="default" 187 + padding="md" 188 + class="flex flex-col" 189 + interactive={!!project.url} 190 + href={project.url || undefined} 191 + showExternalIcon={!!project.url} 192 + > 193 + {#snippet children()} 194 + <div class="flex items-start gap-3"> 195 + <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 196 + <FolderGit2 class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 197 + </div> 198 + <div class="min-w-0 flex-1"> 199 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{project.name}</h3> 200 + {#if project.description} 201 + <p class="mt-1 line-clamp-2 text-sm text-ink-700 dark:text-ink-200"> 202 + {project.description} 203 + </p> 204 + {/if} 205 + {#if project.startedAt} 206 + <p class="mt-2 text-xs text-ink-500 dark:text-ink-400"> 207 + Started {formatDate(project.startedAt)} 208 + </p> 209 + {/if} 210 + </div> 211 + </div> 212 + {/snippet} 213 + </Card> 214 + {/each} 215 + </div> 216 + </section> 217 + {/if} 218 + 219 + <!-- Languages Section --> 220 + {#if languages.length > 0} 221 + <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"> 226 + {#snippet children()} 227 + <div class="flex items-center gap-3"> 228 + <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" /> 230 + </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> 236 + </div> 237 + </div> 238 + {/snippet} 239 + </Card> 240 + {/each} 241 + </div> 242 + </section> 243 + {/if} 244 + 245 + <!-- Certifications Section --> 246 + {#if certifications.length > 0} 247 + <section> 248 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Certifications</h2> 249 + <div class="space-y-4"> 250 + {#each certifications as cert} 251 + <Card key={cert.uri} variant="default" padding="md"> 252 + {#snippet children()} 253 + <div class="flex items-start gap-3"> 254 + <div class="rounded-lg bg-canvas-200 p-2 dark:bg-canvas-700"> 255 + <Award class="h-5 w-5 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 256 + </div> 257 + <div class="flex-1"> 258 + <h3 class="font-semibold text-ink-900 dark:text-ink-50">{cert.name}</h3> 259 + {#if cert.authority} 260 + <p class="text-sm text-ink-600 dark:text-ink-300">{cert.authority}</p> 261 + {/if} 262 + {#if cert.issuedAt} 263 + <p class="mt-1 text-xs text-ink-500 dark:text-ink-400"> 264 + Issued {formatDate(cert.issuedAt)} 265 + </p> 266 + {/if} 267 + </div> 268 + </div> 269 + {/snippet} 270 + </Card> 271 + {/each} 272 + </div> 273 + </section> 274 + {/if} 275 + 276 + <!-- External Accounts Section --> 277 + {#if externalAccounts.length > 0} 278 + <section> 279 + <h2 class="mb-6 text-2xl font-bold text-ink-900 dark:text-ink-50">Links</h2> 280 + <div class="flex flex-wrap gap-3"> 281 + {#each externalAccounts as account} 282 + <a 283 + href={account.url} 284 + target="_blank" 285 + rel="noopener noreferrer" 286 + class="inline-flex items-center gap-2 rounded-lg bg-canvas-200 px-4 py-2 text-sm font-medium text-ink-700 transition-colors hover:bg-canvas-300 dark:bg-canvas-700 dark:text-ink-200 dark:hover:bg-canvas-600" 287 + > 288 + {#if account.platform === 'id.sifa.defs#platformGithub'} 289 + <Github class="h-4 w-4" aria-hidden="true" /> 290 + {:else if account.platform === 'id.sifa.defs#platformWebsite'} 291 + <Globe class="h-4 w-4" aria-hidden="true" /> 292 + {:else if account.platform === 'id.sifa.defs#platformRss'} 293 + <Rss class="h-4 w-4" aria-hidden="true" /> 294 + {:else} 295 + <LinkIcon class="h-4 w-4" aria-hidden="true" /> 296 + {/if} 297 + {account.label || account.url} 298 + {#if account.isPrimary} 299 + <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> 300 + {/if} 301 + </a> 302 + {/each} 303 + </div> 304 + </section> 305 + {/if} 306 + </div>
+28
src/routes/work/+page.ts
··· 1 + import type { PageLoad } from './$types'; 2 + import { createDynamicSiteMeta } from '$lib/helper/siteMeta'; 3 + import { 4 + fetchSifaProfile, 5 + fetchSifaSkills, 6 + fetchSifaProjects, 7 + fetchSifaLanguages, 8 + fetchSifaCertifications, 9 + fetchSifaExternalAccounts 10 + } from '$lib/services/atproto'; 11 + 12 + export const load: PageLoad = async ({ fetch }) => { 13 + const [profile, skills, projects, languages, certifications, externalAccounts] = await Promise.all([ 14 + fetchSifaProfile(fetch), 15 + fetchSifaSkills(fetch), 16 + fetchSifaProjects(fetch), 17 + fetchSifaLanguages(fetch), 18 + fetchSifaCertifications(fetch), 19 + fetchSifaExternalAccounts(fetch) 20 + ]); 21 + 22 + const meta = createDynamicSiteMeta({ 23 + title: 'Work', 24 + description: profile?.headline || 'Professional profile, skills, and projects' 25 + }); 26 + 27 + return { profile, skills, projects, languages, certifications, externalAccounts, meta }; 28 + };