diff --git a/bun.lock b/bun.lock index fa52deb..f028bec 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@atproto/lex": "^0.3.2", "@atproto/oauth-client-node": "^0.5.1", "astro": "7.1.6", + "satteri": "^0.9.5", }, "devDependencies": { "prettier": "^3.8.1", diff --git a/lexicons.json b/lexicons.json index ae040e9..7033581 100644 --- a/lexicons.json +++ b/lexicons.json @@ -2,7 +2,14 @@ "version": 1, "lexicons": [ "app.bsky.actor.profile", + "id.sifa.profile.education", + "id.sifa.profile.involvement", + "id.sifa.profile.language", + "id.sifa.profile.location", + "id.sifa.profile.position", + "id.sifa.profile.project", "id.sifa.profile.self", + "id.sifa.profile.skill", "social.lexical.now.update" ], "resolutions": { @@ -26,10 +33,38 @@ "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.defs", "cid": "bafyreicwpzwmjb5y5ovsi6tftofgv6a2yevxw7g5vb3jrmpi6f6faeioea" }, + "id.sifa.profile.education": { + "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.education", + "cid": "bafyreif7ma3pa3acmm3w5jnwsptvbcjuhs74brj7g4ismlomgc76ckfku4" + }, + "id.sifa.profile.involvement": { + "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.involvement", + "cid": "bafyreidjgyyi63nldq33wyvxl3xqdnqzyvnnexprohvkjoktnhv42yafwa" + }, + "id.sifa.profile.language": { + "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.language", + "cid": "bafyreif7ekvxcfijv2n4kh4ytgyoihitnutaj6vixukvinwqsj52mrdlbq" + }, + "id.sifa.profile.location": { + "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.location", + "cid": "bafyreiaulgf23e3meel6pvfiwf7icfra3pguh7tncffthjrl4vmwtslnn4" + }, + "id.sifa.profile.position": { + "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.position", + "cid": "bafyreiag2sjil76sw3xcm7rghh7lm4bl3nowyozxckrneruhymhhbbpgku" + }, + "id.sifa.profile.project": { + "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.project", + "cid": "bafyreian34gija32v6tib6a75z3wgnd4q2sir4dg3zkwxyuujlegiwt7nq" + }, "id.sifa.profile.self": { "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.self", "cid": "bafyreigjpmsfm662tis3pihkfr3bvuebnsd7saof2fphk2mp6rhfperwyi" }, + "id.sifa.profile.skill": { + "uri": "at://did:plc:2f2ahswozqy4v5lvu676375y/com.atproto.lexicon.schema/id.sifa.profile.skill", + "cid": "bafyreibhhwbwdcz55v4yzp2br63hzhs2wxhd3kt6sfrbaropel2p7ba2xa" + }, "social.lexical.now.update": { "uri": "at://did:plc:k6xccphpraz5nylacjrdby3s/com.atproto.lexicon.schema/social.lexical.now.update", "cid": "bafyreic4p5h3ublkcixvxevg5b2ughdqkgr7tkqgyqw7574o3akl5bzjzi" diff --git a/lexicons/id/sifa/profile/education.json b/lexicons/id/sifa/profile/education.json new file mode 100644 index 0000000..f749e7b --- /dev/null +++ b/lexicons/id/sifa/profile/education.json @@ -0,0 +1,94 @@ +{ + "id": "id.sifa.profile.education", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "institution", + "createdAt" + ], + "properties": { + "grade": { + "type": "string", + "maxLength": 500, + "description": "Grade, GPA, or classification achieved.", + "maxGraphemes": 50 + }, + "degree": { + "type": "string", + "maxLength": 2560, + "description": "Degree name (e.g., 'Bachelor of Science', 'Master of Arts').", + "maxGraphemes": 256 + }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this education record." + }, + "endedAt": { + "type": "string", + "description": "End date (graduation or expected graduation) in YYYY-MM or YYYY-MM-DD format. Omit if currently enrolled." + }, + "location": { + "ref": "community.lexicon.location.address", + "type": "ref", + "description": "Institution location. Sifa enforces ISO 3166-1 alpha-2 country codes at the app layer." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was originally created." + }, + "entityRef": { + "type": "string", + "format": "uri", + "description": "Portable, app-neutral identifier for the educational institution the user selected from a resolver dropdown: a Wikidata Q-ID URI (http://www.wikidata.org/entity/Q...), a ROR URI, or an LEI URI. Enables deterministic resolution to a canonical entity even before the institution claims an ATproto account. Absent for free-text entries." + }, + "startedAt": { + "type": "string", + "description": "Start date in YYYY-MM or YYYY-MM-DD format." + }, + "activities": { + "type": "string", + "maxLength": 10000, + "description": "Activities, societies, and extracurriculars.", + "maxGraphemes": 1000 + }, + "description": { + "type": "string", + "maxLength": 50000, + "description": "Additional description or notes about the education.", + "maxGraphemes": 5000 + }, + "institution": { + "type": "string", + "maxLength": 2560, + "minLength": 1, + "description": "Name of the educational institution.", + "maxGraphemes": 256 + }, + "fieldOfStudy": { + "type": "string", + "maxLength": 2560, + "description": "Field of study or major.", + "maxGraphemes": 256 + }, + "institutionDid": { + "type": "string", + "format": "did", + "description": "DID of the institution's ATproto account, if one exists." + } + } + }, + "description": "Record representing a single education credential." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "description": "An education entry in the user's professional profile." +} diff --git a/lexicons/id/sifa/profile/involvement.json b/lexicons/id/sifa/profile/involvement.json new file mode 100644 index 0000000..6614808 --- /dev/null +++ b/lexicons/id/sifa/profile/involvement.json @@ -0,0 +1,123 @@ +{ + "id": "id.sifa.profile.involvement", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "kind", + "createdAt" + ], + "properties": { + "kind": { + "type": "string", + "description": "The nature of the involvement. Drives the display heading (Open Source / Community / Volunteering / Civic).", + "knownValues": [ + "id.sifa.defs#involvementOpenSource", + "id.sifa.defs#involvementCommunity", + "id.sifa.defs#involvementCharity", + "id.sifa.defs#involvementCivic", + "id.sifa.defs#involvementOther" + ] + }, + "role": { + "type": "string", + "maxLength": 2560, + "description": "The user's role (e.g. 'Maintainer', 'Translator', 'Organizer', 'Contributor').", + "maxGraphemes": 256 + }, + "links": { + "type": "array", + "items": { + "ref": "id.sifa.defs#artifactLink", + "type": "ref" + }, + "maxLength": 50, + "description": "Public artifacts proving the involvement (PRs, releases, talk recordings, published translations)." + }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this record." + }, + "sameAs": { + "ref": "id.sifa.defs#externalRecordRef", + "type": "ref", + "description": "The same involvement as recorded on another person's profile. Set when you keep your own entry for work someone else has also recorded, so consumers can tell the two describe one thing rather than showing it twice. Each side stays its own record: the other person cannot edit yours, and yours does not change theirs. Resolve by AT-URI, since they will keep editing their copy." + }, + "skills": { + "type": "array", + "items": { + "ref": "id.sifa.defs#skillRef", + "type": "ref" + }, + "maxLength": 50, + "description": "Skills demonstrated by this involvement. Each entry is a URI reference to an id.sifa.profile.skill record owned by the same DID." + }, + "endedAt": { + "type": "string", + "description": "End date in YYYY-MM or YYYY-MM-DD format. Omit if ongoing." + }, + "location": { + "ref": "community.lexicon.location.address", + "type": "ref", + "description": "Where the involvement takes place. Sifa enforces ISO 3166-1 alpha-2 country codes at the app layer." + }, + "upstream": { + "type": "string", + "maxLength": 2560, + "description": "Name of the project, community, event, or cause (e.g. 'atproto', 'Linux kernel', 'PyCon NL'). Optional — a one-off contribution need not name an organization.", + "maxGraphemes": 256 + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was originally created." + }, + "entityRef": { + "type": "string", + "format": "uri", + "description": "Portable, app-neutral identifier for the upstream the user selected from a resolver dropdown: a Wikidata Q-ID URI (http://www.wikidata.org/entity/Q...), a ROR URI, or an LEI URI. Enables deterministic resolution to a canonical entity even before the upstream claims an ATproto account. Absent for free-text or one-off entries." + }, + "startedAt": { + "type": "string", + "description": "Start date in YYYY-MM or YYYY-MM-DD format. Freeform (NOT strict datetime) to allow partial dates and importer writes." + }, + "description": { + "type": "string", + "maxLength": 50000, + "description": "What the user did and why it mattered.", + "maxGraphemes": 5000 + }, + "upstreamDid": { + "type": "string", + "format": "did", + "description": "DID of the upstream's ATproto account, if one exists." + }, + "upstreamUrl": { + "type": "string", + "format": "uri", + "description": "Primary URL of the upstream (project homepage, repo, event site)." + }, + "collaborators": { + "type": "array", + "items": { + "ref": "id.sifa.defs#projectMemberRef", + "type": "ref" + }, + "maxLength": 50, + "description": "Other people who worked on this alongside you. Each entry is a claim by the record author and is not self-verifying: the named person supplies the other half by publishing an id.sifa.confirmation record whose subject is this record and whose relation is id.sifa.defs#collaborator. Until they do, consumers must render the entry without their display name, avatar, or profile link. Confirming never puts this record on their profile: it lives in the author's repository and the author can rename it at any time." + } + } + }, + "description": "Record representing one involvement — an ongoing role and/or a body of discrete contributions to a single upstream." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "description": "Uncompensated involvement with an external project, community, event, or cause the user does not own or run — open-source, community, charity, or civic work. Distinct from paid roles (position), things you own (project), and paid employment." +} diff --git a/lexicons/id/sifa/profile/language.json b/lexicons/id/sifa/profile/language.json new file mode 100644 index 0000000..1b8fdcb --- /dev/null +++ b/lexicons/id/sifa/profile/language.json @@ -0,0 +1,45 @@ +{ + "id": "id.sifa.profile.language", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "name", + "createdAt" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 640, + "minLength": 1, + "description": "Language name (e.g., 'English', 'Nederlands', 'Deutsch').", + "maxGraphemes": 64 + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was originally created." + }, + "proficiency": { + "type": "string", + "description": "Proficiency level. Uses LinkedIn-compatible five-level scale.", + "knownValues": [ + "id.sifa.defs#elementary", + "id.sifa.defs#limitedWorking", + "id.sifa.defs#professionalWorking", + "id.sifa.defs#fullProfessional", + "id.sifa.defs#native" + ] + } + } + }, + "description": "Record representing a single language proficiency." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "description": "A language spoken by the user with proficiency level." +} diff --git a/lexicons/id/sifa/profile/location.json b/lexicons/id/sifa/profile/location.json new file mode 100644 index 0000000..5a54929 --- /dev/null +++ b/lexicons/id/sifa/profile/location.json @@ -0,0 +1,52 @@ +{ + "id": "id.sifa.profile.location", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "address", + "type", + "createdAt" + ], + "properties": { + "type": { + "type": "string", + "description": "The role of this location.", + "knownValues": [ + "id.sifa.defs#locationPrimary", + "id.sifa.defs#locationBusiness", + "id.sifa.defs#locationTravel" + ] + }, + "label": { + "type": "string", + "maxLength": 600, + "description": "Optional user-defined label for this location.", + "maxGraphemes": 60 + }, + "address": { + "ref": "community.lexicon.location.address", + "type": "ref", + "description": "Structured location address. Sifa enforces ISO 3166-1 alpha-2 country codes at the app layer." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was created." + }, + "isPrimary": { + "type": "boolean", + "description": "Whether this is the primary/display location." + } + } + }, + "description": "A single location entry in the user's profile." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "description": "A location associated with the user's professional profile. Supports multiple locations per user (e.g. residential, business, travel)." +} diff --git a/lexicons/id/sifa/profile/position.json b/lexicons/id/sifa/profile/position.json new file mode 100644 index 0000000..1bc9b8b --- /dev/null +++ b/lexicons/id/sifa/profile/position.json @@ -0,0 +1,119 @@ +{ + "id": "id.sifa.profile.position", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "title", + "startedAt", + "createdAt" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 2560, + "minLength": 1, + "description": "Job title or role name.", + "maxGraphemes": 256 + }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this position record." + }, + "skills": { + "type": "array", + "items": { + "ref": "id.sifa.defs#skillRef", + "type": "ref" + }, + "maxLength": 50, + "description": "Skills used in this position. Each entry is a URI reference to an id.sifa.profile.skill record owned by the same DID." + }, + "company": { + "type": "string", + "maxLength": 2560, + "minLength": 1, + "description": "Company or organization name. Optional: omit for self-employed, freelance, contract, or independent work that has no separately named or registered entity.", + "maxGraphemes": 256 + }, + "endedAt": { + "type": "string", + "description": "End date of the position in YYYY-MM or YYYY-MM-DD format. Omit if this is a current position." + }, + "location": { + "ref": "community.lexicon.location.address", + "type": "ref", + "description": "Work location. Sifa enforces ISO 3166-1 alpha-2 country codes at the app layer." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was originally created." + }, + "entityRef": { + "type": "string", + "format": "uri", + "description": "Portable, app-neutral identifier for the organization the user selected from a resolver dropdown: a Wikidata Q-ID URI (http://www.wikidata.org/entity/Q...), a ROR URI, or an LEI URI. Enables deterministic resolution to a canonical entity even before the org claims an ATproto account. Absent for free-text or independent/self-employed positions." + }, + "isPrimary": { + "type": "boolean", + "description": "Whether this is the user's primary current position, displayed on the identity card." + }, + "startedAt": { + "type": "string", + "description": "Start date of the position in YYYY-MM or YYYY-MM-DD format." + }, + "companyDid": { + "type": "string", + "format": "did", + "description": "DID of the company's ATproto account, if one exists. Enables verified company linking." + }, + "description": { + "type": "string", + "maxLength": 50000, + "description": "Description of responsibilities, achievements, and duties.", + "maxGraphemes": 5000 + }, + "workplaceType": { + "type": "string", + "description": "Workplace arrangement (on-site, remote, hybrid).", + "knownValues": [ + "id.sifa.defs#onSite", + "id.sifa.defs#remote", + "id.sifa.defs#hybrid" + ] + }, + "employmentType": { + "type": "string", + "description": "Type of employment (full-time, part-time, contract, etc.).", + "knownValues": [ + "id.sifa.defs#fullTime", + "id.sifa.defs#partTime", + "id.sifa.defs#temporary", + "id.sifa.defs#seasonal", + "id.sifa.defs#contract", + "id.sifa.defs#freelance", + "id.sifa.defs#selfEmployed", + "id.sifa.defs#independentWork", + "id.sifa.defs#internship", + "id.sifa.defs#apprenticeship", + "id.sifa.defs#fellowship", + "id.sifa.defs#trainee", + "id.sifa.defs#volunteer" + ] + } + } + }, + "description": "Record representing a single work position or role." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "description": "A work experience entry in the user's professional profile." +} diff --git a/lexicons/id/sifa/profile/project.json b/lexicons/id/sifa/profile/project.json new file mode 100644 index 0000000..ae7a07f --- /dev/null +++ b/lexicons/id/sifa/profile/project.json @@ -0,0 +1,84 @@ +{ + "id": "id.sifa.profile.project", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "name", + "createdAt" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL of the project (website, repository, demo, etc.)." + }, + "name": { + "type": "string", + "maxLength": 2560, + "minLength": 1, + "description": "Project name.", + "maxGraphemes": 256 + }, + "labels": { + "refs": [ + "com.atproto.label.defs#selfLabels" + ], + "type": "union", + "description": "Self-label values for this project record." + }, + "sameAs": { + "ref": "id.sifa.defs#externalRecordRef", + "type": "ref", + "description": "The same project as recorded on another person's profile. Set when you keep your own entry for work someone else has also recorded, so consumers can tell the two describe one thing rather than showing it twice. Each side stays its own record: the other person cannot edit yours, and yours does not change theirs. Resolve by AT-URI, since they will keep editing their copy." + }, + "endedAt": { + "type": "string", + "description": "Project end date in YYYY-MM or YYYY-MM-DD format. Omit if ongoing." + }, + "members": { + "type": "array", + "items": { + "ref": "id.sifa.defs#projectMemberRef", + "type": "ref" + }, + "maxLength": 50, + "description": "Other people who worked on this project. Each entry is a claim by the record author and is not self-verifying: the named person supplies the other half by publishing an id.sifa.confirmation record whose subject is this record and whose relation is id.sifa.defs#projectMember. Until they do, consumers must render the entry without their display name, avatar, or profile link. Confirming never puts this record on the named person's profile: it lives in the author's repository and the author can rename it at any time. A named person who wants the project on their profile keeps their own id.sifa.profile.project record and points its projectRef here." + }, + "position": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "Reference to the associated id.sifa.profile.position record, if applicable." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was originally created." + }, + "startedAt": { + "type": "string", + "description": "Project start date in YYYY-MM or YYYY-MM-DD format." + }, + "projectRef": { + "ref": "com.atproto.repo.strongRef", + "type": "ref", + "description": "Reference to the first-class id.sifa.project.self record this personal entry corresponds to, when there is one. A composition link, not a peer one: for the same project recorded on someone else's profile, see sameAs." + }, + "description": { + "type": "string", + "maxLength": 50000, + "description": "Description of the project, the user's role, and outcomes.", + "maxGraphemes": 5000 + } + } + }, + "description": "Record representing a single project." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "description": "A project the user has worked on." +} diff --git a/lexicons/id/sifa/profile/skill.json b/lexicons/id/sifa/profile/skill.json new file mode 100644 index 0000000..7d7635f --- /dev/null +++ b/lexicons/id/sifa/profile/skill.json @@ -0,0 +1,52 @@ +{ + "id": "id.sifa.profile.skill", + "defs": { + "main": { + "key": "tid", + "type": "record", + "record": { + "type": "object", + "required": [ + "name", + "createdAt" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 640, + "minLength": 1, + "description": "Skill name (e.g., 'JavaScript', 'Project Management').", + "maxGraphemes": 64 + }, + "category": { + "type": "string", + "description": "Broad skill category for grouping.", + "knownValues": [ + "id.sifa.defs#technical", + "id.sifa.defs#business", + "id.sifa.defs#creative", + "id.sifa.defs#interpersonal", + "id.sifa.defs#language", + "id.sifa.defs#industry" + ] + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this record was originally created." + }, + "subCategory": { + "type": "string", + "maxLength": 640, + "description": "Optional user-defined grouping label nested under category (e.g., 'Frontend', 'Backend', 'Databases'). Freeform: unlike category there are no known values, so renderers can present skills in the user's own groups. Renderers decide the display label.", + "maxGraphemes": 64 + } + } + }, + "description": "Record representing a single professional skill." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "description": "A skill claimed by the user. Can be endorsed by other users via id.sifa.endorsement." +} diff --git a/package.json b/package.json index 7046c8c..05990b7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "@astrojs/sitemap": "3.7.3", "@atproto/lex": "^0.3.2", "@atproto/oauth-client-node": "^0.5.1", - "astro": "7.1.6" + "astro": "7.1.6", + "satteri": "^0.9.5" }, "devDependencies": { "prettier": "^3.8.1", diff --git a/src/components/Social.astro b/src/components/Social.astro index 85b4fb6..fdef8e8 100644 --- a/src/components/Social.astro +++ b/src/components/Social.astro @@ -52,14 +52,19 @@ export { type Props as SocialProps }; { 'href' in social ? ( - + {social.name} - + {!social.href.startsWith('/') && } {social.description} diff --git a/src/i18n/langs/en.ts b/src/i18n/langs/en.ts index 5833c11..ce614a4 100644 --- a/src/i18n/langs/en.ts +++ b/src/i18n/langs/en.ts @@ -3,6 +3,7 @@ export default { now: 'Now', blog: 'Blog', contact: 'Contact', + resume: 'Résumé', copy_link: 'Copy link', other: 'Other', footer_legal_blurb: 'All rights reserved.', @@ -10,7 +11,7 @@ export default { 'intro.title': 'Clément Voisin is a visual developer you can count on.', 'intro.subtext': 'Computer science undergraduate from Toulouse, France. I mix bytes and arts to create high-class products, websites and apps.', - 'cta.read_cv': 'Read my CV', + 'cta.read_cv': 'Read my résumé', 'cta.contact_me': 'Contact me', section_work: 'Work & projects', section_projects: 'Personal projects', @@ -37,7 +38,7 @@ export default { 'langs.fr': 'French', /* AVAILABILITY */ - availability_cta: 'Read my CV', + availability_cta: 'Read my résumé', 'availability_cta.open_to': 'Open to work: ', 'availability_cta.fullTimeRoles': 'Full-time roles', 'availability_cta.partTimeRoles': 'Part-time roles', @@ -59,7 +60,6 @@ export default { 'contact.mail_sent.title': 'Letter sent successfully!', 'contact.mail_sent.description': "I might reply to you via email, if you've specified any :)", 'contact.mail_sent.cta': 'Write me another letter', - 'contact.cv.view_pdf': 'View PDF', 'contact.support': 'Support my work', /* BLOG */ @@ -79,6 +79,19 @@ export default { 'You are reading the original version of this article, as it was not adapted to English. Sorry for the inconvenience.', 'blog.toc': 'Table of contents', + /* RESUME */ + 'resume.title': 'Clément Voisin - Résumé', + 'resume.fromto': 'From {start} to {end}', + 'resume.present': 'Present', + 'resume.education': 'Education', + 'resume.career': 'Career', + 'resume.projects': 'Projects', + 'resume.involvement': 'Involvement', + 'resume.skills': 'Skills', + 'resume.languages': 'Languages', + 'resume.proficiency.full_professional': 'Professional', + 'resume.proficiency.native': 'Native', + /* PROJECTS */ 'projects.uniten.title': 'UNITEN', 'projects.uniten.about': diff --git a/src/i18n/langs/fr.ts b/src/i18n/langs/fr.ts index 96fd091..c23a564 100644 --- a/src/i18n/langs/fr.ts +++ b/src/i18n/langs/fr.ts @@ -5,6 +5,7 @@ export const fr: typeof en = { now: 'Maintenant', blog: 'Blog', contact: 'Contact', + resume: 'CV', copy_link: 'Copier le lien', other: 'Autre', footer_legal_blurb: 'Tous droits réservés.', @@ -61,7 +62,6 @@ export const fr: typeof en = { 'contact.mail_sent.description': 'Je peux vous répondre par courriel, si vous avez spécifié votre adresse :)', 'contact.mail_sent.cta': "M'écrire une autre lettre", - 'contact.cv.view_pdf': 'Voir le PDF', 'contact.support': 'Soutenez mon travail', /* BLOG */ @@ -81,6 +81,19 @@ export const fr: typeof en = { "Vous lisez cet article dans sa version originale, car il n'a pas été traduit en français. Désolé pour la gêne occasionnée.", 'blog.toc': 'Table des matières', + /* RESUME */ + 'resume.title': 'Clément Voisin - CV', + 'resume.fromto': 'De {start} au {end}', + 'resume.present': 'Présent', + 'resume.education': 'Formation', + 'resume.career': 'Expérience professionnelle', + 'resume.projects': 'Projets', + 'resume.involvement': 'Bénévolat et activités extrascolaires', + 'resume.skills': 'Compétences', + 'resume.languages': 'Langues', + 'resume.proficiency.full_professional': 'Professionnel', + 'resume.proficiency.native': 'Natif', + /* PROJECTS */ 'projects.uniten.title': 'UNITEN', 'projects.uniten.about': diff --git a/src/pages/[lang]/_Contact.astro b/src/pages/[lang]/_Contact.astro deleted file mode 100644 index 5add1f1..0000000 --- a/src/pages/[lang]/_Contact.astro +++ /dev/null @@ -1,93 +0,0 @@ ---- -import Bluesky from '@assets/icons/bluesky.svg'; -import Codeberg from '@assets/icons/codeberg.svg'; -import Email from '@assets/icons/email.svg'; -import GitHub from '@assets/icons/github.svg'; -import KoFi from '@assets/icons/ko-fi.svg'; -import Message from '@assets/icons/message.svg'; -import Resume from '@assets/icons/resume.svg'; -import Social, { type SocialProps } from '@components/Social.astro'; -import { getLocaleFromUrl, useTranslatedPaths, useTranslations } from '@i18n'; - -const lang = getLocaleFromUrl(Astro.url); -const t = useTranslations(lang); -const translatePath = useTranslatedPaths(lang); - -const socials: SocialProps[] = [ - { - name: 'Email', - description: 'clembs@clembs.com', - text: 'clembs@clembs.com', - icon: Email, - }, - { - name: 'CV', - description: t('contact.cv.view_pdf'), - href: translatePath('/cv'), - icon: Resume, - }, - { - name: 'Bluesky', - description: '@clembs.com', - href: 'https://bsky.app/profile/clembs.com', - icon: Bluesky, - }, - // { - // name: "Twitter", - // description: "@clembsv", - // href: "https://twitter.com/clembsv", - // icon: Twitter, - // }, - { - name: 'Codeberg', - description: 'clembs', - href: 'https://codeberg.org/clembs', - icon: Codeberg, - }, - { - name: 'GitHub', - description: 'clembs', - href: 'https://github.com/Clembs', - icon: GitHub, - }, - { - name: 'Ko-Fi', - description: t('contact.support'), - href: 'https://ko-fi.com/clembs', - icon: KoFi, - }, -]; ---- - - - - - - - {t('section_contact')} - - - - {t('section_contact.description')} - - - - - {socials.map((s) => )} - - - - diff --git a/src/pages/[lang]/contact/index.astro b/src/pages/[lang]/contact/index.astro index 126ad81..5cd409b 100644 --- a/src/pages/[lang]/contact/index.astro +++ b/src/pages/[lang]/contact/index.astro @@ -25,9 +25,9 @@ const socials: SocialProps[] = [ icon: Email, }, { - name: 'CV', - description: t('contact.cv.view_pdf'), - href: translatePath('/cv'), + name: t('resume'), + description: t('cta.read_cv'), + href: translatePath('/resume'), icon: Resume, }, { diff --git a/src/pages/[lang]/index.astro b/src/pages/[lang]/index.astro index 3c53bf3..d815bf7 100644 --- a/src/pages/[lang]/index.astro +++ b/src/pages/[lang]/index.astro @@ -7,20 +7,23 @@ import MetaImageFr from '@assets/meta-images/index-fr.webp'; import IconBooklet from '@assets/project-icons/booklet.png'; import IconCRBT from '@assets/project-icons/crbt.webp'; -import IconKilya from '@assets/project-icons/kilya.png'; import IconJamesHuang from '@assets/project-icons/jameshuang.png'; -import IconUniten from '@assets/project-icons/uniten.png'; -import IconPNS from '@assets/project-icons/pns.webp'; +import IconKilya from '@assets/project-icons/kilya.png'; import IconMecaNume from '@assets/project-icons/mecanume.png'; +import IconPNS from '@assets/project-icons/pns.webp'; +import IconUniten from '@assets/project-icons/uniten.png'; import BannerBooklet from '@assets/project-images/booklet.png'; import BannerCRBT from '@assets/project-images/crbt.webp'; -import BannerRM3K from '@assets/project-images/rm3k.png'; import BannerJamesHuang from '@assets/project-images/jameshuang.png'; +import BannerMecaNume from '@assets/project-images/mecanume.png'; import BannerPNS from '@assets/project-images/pns.png'; +import BannerRM3K from '@assets/project-images/rm3k.png'; import BannerUniten from '@assets/project-images/uniten.png'; -import BannerMecaNume from '@assets/project-images/mecanume.png'; +import { atpClient } from '@atp/client'; +import sifaProfile, { type Main as SifaProfile } from '@atp/lexicons/id/sifa/profile/self'; +import AvailabilityCTA from '@components/AvailabilityCTA.astro'; import Button from '@components/Button.astro'; import ProjectCard from '@components/ProjectCard.astro'; import { @@ -31,9 +34,6 @@ import { } from '@i18n'; import Layout from '../../layouts/Layout.astro'; import Avatar from './_Avatar.astro'; -import { atpClient } from '@atp/client'; -import sifaProfile, { type Main as SifaProfile } from '@atp/lexicons/id/sifa/profile/self'; -import AvailabilityCTA from '@components/AvailabilityCTA.astro'; const lang = getLocaleFromUrl(Astro.url); const t = useTranslations(lang); @@ -78,7 +78,7 @@ function constructOpenTo(listOT: Exclude) { } - + {t('cta.read_cv')} diff --git a/src/pages/[lang]/resume/_StandardListItem.astro b/src/pages/[lang]/resume/_StandardListItem.astro new file mode 100644 index 0000000..7243f7b --- /dev/null +++ b/src/pages/[lang]/resume/_StandardListItem.astro @@ -0,0 +1,141 @@ +--- +import { type Main as Location } from '@atp/lexicons/community/lexicon/location/address'; +import { getLocaleFromUrl, useTranslations } from '@i18n'; +import { markdownToHtml } from 'satteri'; + +interface Props { + heading: string; + startDate: Date; + endDate?: Date; + location?: Location; + description?: string; +} + +const lang = getLocaleFromUrl(Astro.url); +const t = useTranslations(lang); + +const { heading, startDate, endDate, location, description } = Astro.props; + +const fromISO = startDate.toISOString(); +const from = startDate.toLocaleString(lang, { month: 'short', year: 'numeric' }); +const to = endDate?.toLocaleString(lang, { month: 'short', year: 'numeric' }); +const toISO = endDate?.toISOString(); +const ariaLabel = t('resume.fromto') + .replace( + '{start}', + startDate.toLocaleString(lang, { + month: 'long', + year: 'numeric', + }), + ) + .replace( + '{end}', + endDate?.toLocaleString(lang, { + month: 'long', + year: 'numeric', + }) ?? 'Présent', + ); +--- + + + {heading} + + + + + + + {from} - + {to && toISO ? {to} : t('resume.present')} + + + { + location && ( + + {location.locality + (lang === 'en' ? `, ${location.country}` : '')} + + ) + } + + + { + description && ( + + + + ) + } + + + diff --git a/src/pages/[lang]/resume/index.astro b/src/pages/[lang]/resume/index.astro new file mode 100644 index 0000000..73de38c --- /dev/null +++ b/src/pages/[lang]/resume/index.astro @@ -0,0 +1,277 @@ +--- +export const prerender = false; + +import { atpClient } from '@atp/client'; +import { type Main as Location } from '@atp/lexicons/community/lexicon/location/address'; +import * as sifa from '@atp/lexicons/id/sifa/profile'; +import type { LexValue } from '@atproto/lex'; +import { getLocaleFromUrl, useTranslations } from '@i18n'; +import Layout from 'src/layouts/Layout.astro'; +import StandardListItem from './_StandardListItem.astro'; + +const lang = getLocaleFromUrl(Astro.url); +const t = useTranslations(lang); + +const profile = await atpClient.get(sifa.self, { repo: import.meta.env.PUBLIC_ATPROTO_DID }); +const location = (await atpClient.list(sifa.location, { repo: import.meta.env.PUBLIC_ATPROTO_DID })) + .records[0].value.address as Location; +const education = await atpClient.list(sifa.education, { + repo: import.meta.env.PUBLIC_ATPROTO_DID, +}); +const positions = await atpClient.list(sifa.position, { + repo: import.meta.env.PUBLIC_ATPROTO_DID, +}); +const projects = await atpClient.list(sifa.project, { + repo: import.meta.env.PUBLIC_ATPROTO_DID, +}); +const involvement = await atpClient.list(sifa.involvement, { + repo: import.meta.env.PUBLIC_ATPROTO_DID, +}); +const skills = await atpClient.list(sifa.skill, { + repo: import.meta.env.PUBLIC_ATPROTO_DID, +}); +const languages = await atpClient.list(sifa.language, { + repo: import.meta.env.PUBLIC_ATPROTO_DID, +}); + +const skillCategories = Array.from(new Set(skills.records.map(({ value: s }) => s.category))).map( + (c) => ({ + name: c, + skills: skills.records.filter(({ value: s }) => s.category === c).map(({ value: s }) => s.name), + }), +); + +function sortRecords( + recordA: { value: { startedAt?: LexValue; endedAt?: LexValue } }, + recordB: { value: { startedAt?: LexValue; endedAt?: LexValue } }, +) { + const time = (date: Exclude) => new Date(date.toString()).getTime(); + + // if both are ongoing, latest created comes first + if (!recordA.value.endedAt && !recordB.value.endedAt) + return time(recordB.value.startedAt!) - time(recordA.value.startedAt!); + + // if only recordA is ongoing, it comes first + if (!recordA.value.endedAt && recordB.value.endedAt) return -1; + + // if only recordB is ongoing, it comes first + if (recordA.value.endedAt && !recordB.value.endedAt) return 1; + + // if both are ended, latest ended comes first, or latest created comes first if ends tie + const endedAtDiff = time(recordB.value.endedAt!) - time(recordA.value.endedAt!); + if (endedAtDiff !== 0) return endedAtDiff; + return time(recordB.value.startedAt!) - time(recordA.value.startedAt!); +} +--- + + + + + {profile.value.displayName} + + + + {location.locality}, + {location.region}, + {location.country} + • + +33 6 64 57 15 69 • + clembs@clembs.com • + clembs.com/contact + + + + + {t('resume.education')} + + + { + education.records + .filter(({ value: ed }) => !!ed.institution && !!ed.startedAt) + .sort(sortRecords) + .map(({ value: ed }) => ( + + {ed.degree!} + + )) + } + + + + + {t('resume.career')} + + + { + positions.records + .filter(({ value: pos }) => !!pos.title && !!pos.startedAt && !!pos.company) + .sort(sortRecords) + .map(({ value: pos }) => ( + + {pos.company!} + + )) + } + + + + + {t('resume.projects')} + + + { + projects.records + .filter(({ value: pos }) => !!pos.name && !!pos.startedAt && !!pos.url) + .sort(sortRecords) + .map(({ value: pos }) => ( + + + + {pos.url!} + + + + )) + } + + + + + {t('resume.involvement')} + + + { + involvement.records + .filter(({ value: ed }) => !!ed.upstream && !!ed.startedAt && !!ed.role) + .sort(sortRecords) + .map(({ value: ed }) => ( + + {ed.upstream} + + )) + } + + + + + {t('resume.skills')} + + + { + skillCategories.map((s) => ( + + {s.name} : + + + {s.skills.map((s) => ( + {s} + ))} + + + )) + } + + + + + {t('resume.languages')} + + + { + languages.records.map(({ value: l }) => ( + + {l.name} : + {t(`resume.proficiency.${l.proficiency}`)} + + )) + } + + + + + + diff --git a/src/redirects.ts b/src/redirects.ts index 43424f9..a0cfbbd 100644 --- a/src/redirects.ts +++ b/src/redirects.ts @@ -5,15 +5,8 @@ const redirectList = [ // resume - { - source: '/fr/cv', - destination: 'https://sifa.id/p/clembs.com', - }, - { - source: '/en/cv', - // TODO: update to the english one ASAP - destination: 'https://sifa.id/p/clembs.com', - }, + { source: '/cv', destination: '/resume' }, + { source: '/cv/fallback', destination: 'https://sifa.id/p/clembs.com' }, // shortlinks to creation platforms { source: '/donate', destination: 'https://ko-fi.com/clembs' }, diff --git a/src/styles/typography.scss b/src/styles/typography.scss index c91d799..ebfc0ca 100644 --- a/src/styles/typography.scss +++ b/src/styles/typography.scss @@ -64,7 +64,7 @@ h2, h3, .heading-3 { font-family: var(--font-body); - font-size: clamp(1.125rem, 5vw, 1.5rem); + font-size: clamp(1.125rem, 5vw, 1.325rem); font-weight: 500; line-height: 1.25; text-wrap: balance;
- {t('section_contact.description')} -
+ +