diff --git a/apps/api/src/main/java/dev/jamii/api/auth/SecuriteConfig.java b/apps/api/src/main/java/dev/jamii/api/auth/SecuriteConfig.java index 4b54bab..e065d6e 100644 --- a/apps/api/src/main/java/dev/jamii/api/auth/SecuriteConfig.java +++ b/apps/api/src/main/java/dev/jamii/api/auth/SecuriteConfig.java @@ -37,7 +37,9 @@ SecurityFilterChain securite(HttpSecurity http, ConnexionGithub connexionGithub, // Lectures publiques, sans connexion (le jury, un visiteur) : uniquement ces routes et seulement // en GET. Elles ne montrent que des chiffres agrégés et ce que les projets montrent déjà à tous // (contracts/systalink.yaml et contracts/vitrine.yaml, `security: []`). Toute autre route reste fermée. - .requestMatchers(HttpMethod.GET, "/api/systalink/tableau-de-bord", "/api/systalink/tendances", "/api/vitrine") + // /api/profils/* : un profil publié, pour le lien partagé (#159). La recherche (/api/profils) reste réservée. + .requestMatchers(HttpMethod.GET, "/api/systalink/tableau-de-bord", "/api/systalink/tendances", "/api/vitrine", + "/api/profils/*") .permitAll() .anyRequest().authenticated()) .oauth2Login(oauth -> oauth diff --git a/apps/api/src/main/java/dev/jamii/api/profils/ModificationProfil.java b/apps/api/src/main/java/dev/jamii/api/profils/ModificationProfil.java index 0c12da7..cdd25dc 100644 --- a/apps/api/src/main/java/dev/jamii/api/profils/ModificationProfil.java +++ b/apps/api/src/main/java/dev/jamii/api/profils/ModificationProfil.java @@ -16,7 +16,21 @@ final class ModificationProfil { static final Set DISPONIBILITES = Set.of("disponible", "occupe", "indisponible"); private static final Set CHAMPS = Set.of("bio", "pays", "ville", "disponibilite", "heuresParSemaine", - "competencesDeclarees", "competencesIaRetenues", "afficherResumeIa", "version"); + "competencesDeclarees", "competencesIaRetenues", "afficherResumeIa", "version", "titre", "linkedin", "siteWeb", + "langues", "competencesPhares", "realisations"); + + static final int LANGUES_MAX = 8; + + static final int PHARES_MAX = 3; + + static final int REALISATIONS_MAX = 6; + + static final int TECHNOLOGIES_MAX = 8; + + private static final Set CHAMPS_REALISATION = Set.of("titre", "role", "description", "technologies", "url", + "annee"); + + private static final Set CODES_LANGUES = Set.of(Locale.getISOLanguages()); private static final Set CODES_PAYS = Set.of(Locale.getISOCountries()); @@ -48,6 +62,38 @@ else if (version != profil.version()) { } }); + texte(patch, "titre", 80, erreurs).ifPresent(v -> changements.add(() -> profil.titre(v.valeur()))); + texte(patch, "linkedin", 300, erreurs).ifPresent(v -> { + if (v.valeur() != null && !lienValide(v.valeur(), true)) { + erreurs.add(champ("linkedin", "format_invalide")); + } + else { + changements.add(() -> profil.linkedin(v.valeur())); + } + }); + texte(patch, "siteWeb", 300, erreurs).ifPresent(v -> { + if (v.valeur() != null && !lienValide(v.valeur(), false)) { + erreurs.add(champ("siteWeb", "format_invalide")); + } + else { + changements.add(() -> profil.siteWeb(v.valeur())); + } + }); + + if (patch.containsKey("langues")) { + langues(patch.get("langues"), erreurs).ifPresent(l -> changements.add(() -> profil.langues(l))); + } + + if (patch.containsKey("competencesPhares")) { + listeDeCompetences(patch.get("competencesPhares"), "competencesPhares", PHARES_MAX, erreurs) + .ifPresent(c -> changements.add(() -> profil.competencesPhares(List.copyOf(c)))); + } + + if (patch.containsKey("realisations")) { + realisations(patch.get("realisations"), erreurs) + .ifPresent(r -> changements.add(() -> profil.remplacerRealisations(r))); + } + if (patch.containsKey("disponibilite")) { if (patch.get("disponibilite") instanceof String d && DISPONIBILITES.contains(d)) { changements.add(() -> profil.disponibilite(d)); @@ -121,12 +167,17 @@ private static java.util.Optional texte(Map patch, Strin } private static java.util.Optional> competences(Object brut, List erreurs) { + return listeDeCompetences(brut, "competencesDeclarees", 20, erreurs); + } + + private static java.util.Optional> listeDeCompetences(Object brut, String nom, int max, + List erreurs) { if (!(brut instanceof List liste)) { - erreurs.add(champ("competencesDeclarees", "format_invalide")); + erreurs.add(champ(nom, "format_invalide")); return java.util.Optional.empty(); } - if (liste.size() > 20) { - erreurs.add(champ("competencesDeclarees", "trop_d_elements")); + if (liste.size() > max) { + erreurs.add(champ(nom, "trop_d_elements")); return java.util.Optional.empty(); } Set propres = new LinkedHashSet<>(); @@ -134,7 +185,7 @@ private static java.util.Optional> competences(Object brut, Listempty(); if (normalisee.isEmpty()) { - erreurs.add(champ("competencesDeclarees[" + i + "]", "format_invalide")); + erreurs.add(champ(nom + "[" + i + "]", "format_invalide")); } else { propres.add(normalisee.get()); @@ -168,6 +219,122 @@ private static java.util.Optional> retenues(Object brut, Profil prof return erreurs.size() == avant ? java.util.Optional.of(gardees) : java.util.Optional.empty(); } + /** + * Un lien affichable sans risque : http(s) seulement (jamais javascript: ni data:), avec un vrai nom d'hôte. + * `linkedin` : l'hôte doit être linkedin.com ou l'un de ses sous-domaines. + */ + static boolean lienValide(String lien, boolean linkedin) { + try { + java.net.URI uri = new java.net.URI(lien); + String schema = uri.getScheme(); + String hote = uri.getHost(); + if (schema == null || hote == null || uri.getUserInfo() != null || !hote.contains(".")) { + return false; + } + schema = schema.toLowerCase(Locale.ROOT); + hote = hote.toLowerCase(Locale.ROOT); + if (linkedin) { + return schema.equals("https") && (hote.equals("linkedin.com") || hote.endsWith(".linkedin.com")); + } + return schema.equals("https") || schema.equals("http"); + } + catch (java.net.URISyntaxException e) { + return false; + } + } + + private static java.util.Optional> langues(Object brut, List erreurs) { + if (!(brut instanceof List liste)) { + erreurs.add(champ("langues", "format_invalide")); + return java.util.Optional.empty(); + } + if (liste.size() > LANGUES_MAX) { + erreurs.add(champ("langues", "trop_d_elements")); + return java.util.Optional.empty(); + } + Set propres = new LinkedHashSet<>(); + int avant = erreurs.size(); + for (int i = 0; i < liste.size(); i++) { + if (liste.get(i) instanceof String code && CODES_LANGUES.contains(code)) { + propres.add(code); + } + else { + erreurs.add(champ("langues[" + i + "]", "format_invalide")); + } + } + return erreurs.size() == avant ? java.util.Optional.of(List.copyOf(propres)) : java.util.Optional.empty(); + } + + /** Remplace la liste entière. Chaque réalisation est validée champ par champ, comme le profil. */ + private static java.util.Optional> realisations(Object brut, + List erreurs) { + if (!(brut instanceof List liste)) { + erreurs.add(champ("realisations", "format_invalide")); + return java.util.Optional.empty(); + } + if (liste.size() > REALISATIONS_MAX) { + erreurs.add(champ("realisations", "trop_d_elements")); + return java.util.Optional.empty(); + } + int avant = erreurs.size(); + List propres = new ArrayList<>(); + for (int i = 0; i < liste.size(); i++) { + String prefixe = "realisations[" + i + "]."; + if (!(liste.get(i) instanceof Map brute)) { + erreurs.add(champ("realisations[" + i + "]", "format_invalide")); + continue; + } + @SuppressWarnings("unchecked") + Map r = (Map) brute; + r.keySet().stream().filter(c -> !CHAMPS_REALISATION.contains(c)) + .forEach(c -> erreurs.add(champ(prefixe + c, "inconnu"))); + String titre = texteDe(r, "titre", 80, prefixe, erreurs); + if (titre == null || titre.length() < 2) { + erreurs.add(champ(prefixe + "titre", "obligatoire")); + } + String role = texteDe(r, "role", 60, prefixe, erreurs); + String description = texteDe(r, "description", 300, prefixe, erreurs); + String url = texteDe(r, "url", 300, prefixe, erreurs); + if (url != null && !lienValide(url, false)) { + erreurs.add(champ(prefixe + "url", "format_invalide")); + } + Integer annee = null; + Object a = r.get("annee"); + if (a instanceof Integer n && n >= 1990 && n <= java.time.Year.now().getValue() + 1) { + annee = n; + } + else if (a != null) { + erreurs.add(champ(prefixe + "annee", "hors_limites")); + } + String technologies = null; + if (r.get("technologies") != null) { + var techs = listeDeCompetences(r.get("technologies"), prefixe + "technologies", TECHNOLOGIES_MAX, erreurs); + technologies = techs.filter(t -> !t.isEmpty()).map(t -> String.join(",", t)).orElse(null); + } + propres.add(new Profil.Realisation(titre, role, description, technologies, url, annee)); + } + return erreurs.size() == avant ? java.util.Optional.of(propres) : java.util.Optional.empty(); + } + + /** Un texte facultatif d'un objet imbriqué : vide ou null donne null, les espaces autour sont retirés. */ + private static String texteDe(Map objet, String nom, int max, String prefixe, + List erreurs) { + Object brut = objet.get(nom); + if (brut == null) { + return null; + } + if (!(brut instanceof String s)) { + erreurs.add(champ(prefixe + nom, "format_invalide")); + return null; + } + String propre = s.strip(); + if (propre.length() > max) { + erreurs.add(champ(prefixe + nom, "trop_long")); + return null; + } + return propre.isEmpty() ? null : propre; + } + private static ErreurProfil.Champ champ(String nom, String code) { return new ErreurProfil.Champ(nom, code); } diff --git a/apps/api/src/main/java/dev/jamii/api/profils/Profil.java b/apps/api/src/main/java/dev/jamii/api/profils/Profil.java index 22d1961..5e7ceaf 100644 --- a/apps/api/src/main/java/dev/jamii/api/profils/Profil.java +++ b/apps/api/src/main/java/dev/jamii/api/profils/Profil.java @@ -56,6 +56,18 @@ class Profil { /** La dernière carte proposée par l'IA, en JSON (CartesIa.Stockage). Privée tant que la personne n'a rien gardé. */ private String competencesProposees; + private String titre; + + private String linkedin; + + private String siteWeb; + + /** Codes ISO 639-1 séparés par des virgules (voir V20). */ + private String langues; + + /** Compétences mises en avant, séparées par des virgules. */ + private String competencesPhares; + @Version private int version; @@ -76,6 +88,11 @@ class Profil { @OrderColumn(name = "rang") private List depots = new ArrayList<>(); + @ElementCollection + @CollectionTable(name = "profil_realisation", joinColumns = @JoinColumn(name = "utilisateur_id")) + @OrderColumn(name = "rang") + private List realisations = new ArrayList<>(); + protected Profil() { } @@ -107,6 +124,16 @@ record DepotGithub(String nom, String url, String description, String langage, i @Column(name = "mis_a_jour_le") Instant misAJourLe) { } + /** Une réalisation déclarée hors de Jamii. `technologies` : compétences séparées par des virgules. */ + @Embeddable + record Realisation(String titre, String role, String description, String technologies, String url, Integer annee) { + + List listeTechnologies() { + return technologies == null ? List.of() : List.of(technologies.split(",")); + } + + } + boolean generationEnCours(Instant maintenant) { return "en_cours".equals(generationStatut) && generationLanceeLe != null && generationLanceeLe.plus(GENERATION_PERDUE).isAfter(maintenant); @@ -194,6 +221,55 @@ void heuresParSemaine(Integer heures) { this.heuresParSemaine = heures; } + void titre(String titre) { + this.titre = titre; + } + + void linkedin(String linkedin) { + this.linkedin = linkedin; + } + + void siteWeb(String siteWeb) { + this.siteWeb = siteWeb; + } + + void langues(List langues) { + this.langues = langues.isEmpty() ? null : String.join(",", langues); + } + + void competencesPhares(List phares) { + this.competencesPhares = phares.isEmpty() ? null : String.join(",", phares); + } + + void remplacerRealisations(List nouvelles) { + realisations.clear(); + realisations.addAll(nouvelles); + } + + String titre() { + return titre; + } + + String linkedin() { + return linkedin; + } + + String siteWeb() { + return siteWeb; + } + + List langues() { + return langues == null ? List.of() : List.of(langues.split(",")); + } + + List competencesPhares() { + return competencesPhares == null ? List.of() : List.of(competencesPhares.split(",")); + } + + List realisations() { + return List.copyOf(realisations); + } + void afficherResumeIa(boolean afficher) { this.afficherResumeIa = afficher; } diff --git a/apps/api/src/main/java/dev/jamii/api/profils/ProfilsController.java b/apps/api/src/main/java/dev/jamii/api/profils/ProfilsController.java index 4b83b73..391e70d 100644 --- a/apps/api/src/main/java/dev/jamii/api/profils/ProfilsController.java +++ b/apps/api/src/main/java/dev/jamii/api/profils/ProfilsController.java @@ -25,12 +25,15 @@ class ProfilsController { this.comptes = comptes; } - /** Un profil non publié répond 404 aux autres : on ne révèle pas qu'il existe (api-conventions.md §5). */ + /** + * Un profil non publié répond 404 aux autres : on ne révèle pas qu'il existe (api-conventions.md §5). Un profil + * publié se lit sans compte (#159) : c'est le lien partagé et le QR code de la carte. Jamais l'e-mail. + */ @GetMapping("/api/profils/{utilisateurId}") @Transactional(readOnly = true) Vues.ProfilPublic lire(@AuthenticationPrincipal UtilisateurConnecte moi, @PathVariable UUID utilisateurId) { Profil profil = profils.findById(utilisateurId) - .filter(p -> p.publie() || p.utilisateurId().equals(moi.id())) + .filter(p -> p.publie() || (moi != null && p.utilisateurId().equals(moi.id()))) .orElseThrow(ErreurProfil::introuvable); Compte compte = comptes.lire(utilisateurId).orElseThrow(ErreurProfil::introuvable); return Vues.ProfilPublic.depuis(profil, compte); diff --git a/apps/api/src/main/java/dev/jamii/api/profils/Vues.java b/apps/api/src/main/java/dev/jamii/api/profils/Vues.java index 43bf092..2a94191 100644 --- a/apps/api/src/main/java/dev/jamii/api/profils/Vues.java +++ b/apps/api/src/main/java/dev/jamii/api/profils/Vues.java @@ -36,16 +36,29 @@ record CompetenceProfil(String competence, String source, String niveau, List technologies, String url, + Integer annee) { + } + /** Schéma Profil : ce que les autres voient d'un profil publié. Jamais l'e-mail. */ - record ProfilPublic(UUID utilisateurId, String nom, String avatarUrl, String loginGithub, String bio, String resumeIa, - String pays, String ville, String disponibilite, Integer heuresParSemaine, List langages, - List competences, List projets, List depotsGithub, - List fiabilite, Instant misAJourLe) { + record ProfilPublic(UUID utilisateurId, String nom, String avatarUrl, String loginGithub, String titre, String bio, + String resumeIa, String pays, String ville, String disponibilite, Integer heuresParSemaine, + String linkedin, String siteWeb, List langues, List competencesPhares, + List realisations, List langages, List competences, + List projets, List depotsGithub, List fiabilite, Instant misAJourLe) { static ProfilPublic depuis(dev.jamii.api.profils.Profil p, Compte compte) { - return new ProfilPublic(p.utilisateurId(), compte.nom(), compte.avatarUrl(), compte.loginGithub(), p.bio(), + return new ProfilPublic(p.utilisateurId(), compte.nom(), compte.avatarUrl(), compte.loginGithub(), p.titre(), + p.bio(), p.afficherResumeIa() ? p.resumeIa() : null, p.pays(), p.ville(), p.disponibilite(), - p.heuresParSemaine(), p.langages().stream().map(l -> new Langage(l.nom(), l.part())).toList(), + p.heuresParSemaine(), p.linkedin(), p.siteWeb(), p.langues(), p.competencesPhares(), + p.realisations() + .stream() + .map(r -> new Realisation(r.titre(), r.role(), r.description(), r.listeTechnologies(), r.url(), + r.annee())) + .toList(), + p.langages().stream().map(l -> new Langage(l.nom(), l.part())).toList(), competences(p), List.of(), p.depots().stream() .map(d -> new DepotGithub(d.nom(), d.url(), d.description(), d.langage(), d.etoiles(), diff --git a/apps/api/src/main/resources/db/migration/V20261114_1725__profil_pro.sql b/apps/api/src/main/resources/db/migration/V20261114_1725__profil_pro.sql new file mode 100644 index 0000000..3792713 --- /dev/null +++ b/apps/api/src/main/resources/db/migration/V20261114_1725__profil_pro.sql @@ -0,0 +1,24 @@ +-- Profil professionnel (#159, contracts/profils.yaml) : ce que la personne déclare d'elle-même, en plus de GitHub. +ALTER TABLE profil + ADD COLUMN titre VARCHAR(80), + -- Liens http(s) vérifiés par l'API (ModificationProfil) : jamais de javascript: ni de data:. + ADD COLUMN linkedin VARCHAR(300), + ADD COLUMN site_web VARCHAR(300), + -- Codes ISO 639-1 séparés par des virgules (fr,wo,en), 8 au plus. + ADD COLUMN langues VARCHAR(30) CHECK (langues ~ '^[a-z]{2}(,[a-z]{2}){0,7}$'), + -- Compétences mises en avant, séparées par des virgules, 3 au plus (forme de Competences.java). + ADD COLUMN competences_phares VARCHAR(160); + +-- Réalisations déclarées hors de Jamii : affichées « Déclaré », jamais comptées comme preuve. +CREATE TABLE profil_realisation ( + utilisateur_id UUID NOT NULL REFERENCES profil (utilisateur_id) ON DELETE CASCADE, + rang INTEGER NOT NULL CHECK (rang BETWEEN 0 AND 5), + titre VARCHAR(80) NOT NULL, + role VARCHAR(60), + description VARCHAR(300), + -- Compétences séparées par des virgules, 8 au plus. + technologies VARCHAR(420), + url VARCHAR(300), + annee INTEGER CHECK (annee BETWEEN 1990 AND 2100), + PRIMARY KEY (utilisateur_id, rang) +); diff --git a/apps/api/src/test/java/dev/jamii/api/profils/ProfilsTest.java b/apps/api/src/test/java/dev/jamii/api/profils/ProfilsTest.java index 1e07e29..d37b4a0 100644 --- a/apps/api/src/test/java/dev/jamii/api/profils/ProfilsTest.java +++ b/apps/api/src/test/java/dev/jamii/api/profils/ProfilsTest.java @@ -163,6 +163,68 @@ void entree_invalide_liste_tous_les_champs_et_ne_change_rien() { assertThat(mvc.get().uri("/api/me/profil").with(aicha)).bodyJson().extractingPath("$.pays").isNull(); } + // ---------- Profil professionnel (#159) ---------- + + @Test + void declare_titre_liens_langues_et_realisations() { + var ines = profilGenere("ines"); + MvcTestResult reponse = patch(ines, """ + {"titre":" Développeuse backend · Java ","linkedin":"https://www.linkedin.com/in/ines", + "siteWeb":"https://ines.dev","langues":["fr","wo","en"],"competencesPhares":["Spring Boot","PostgreSQL"], + "realisations":[{"titre":"Caisse pour boutique","role":"Développeuse","description":"Paiement Wave", + "technologies":["Java","React.js"],"url":"https://caisse.exemple.sn","annee":2025}, + {"titre":"Bot WhatsApp"}], + "version":%d}""".formatted(version(ines))); + + assertThat(reponse).hasStatusOk(); + assertThat(reponse).bodyJson().extractingPath("$.titre").isEqualTo("Développeuse backend · Java"); + assertThat(reponse).bodyJson().extractingPath("$.langues").asArray().containsExactly("fr", "wo", "en"); + assertThat(reponse).bodyJson().extractingPath("$.competencesPhares").asArray() + .containsExactly("spring-boot", "postgresql"); + assertThat(reponse).bodyJson().extractingPath("$.realisations[0].technologies").asArray() + .containsExactly("java", "react"); + assertThat(reponse).bodyJson().extractingPath("$.realisations[1].titre").isEqualTo("Bot WhatsApp"); + + // La régénération depuis GitHub ne touche jamais à ce que la personne a déclaré. + mvc.post().uri("/api/me/profil/generation").with(ines).with(csrf()).exchange(); + String apres = attendreFinGeneration(ines); + assertThat(JsonPath.read(apres, "$.linkedin")).isEqualTo("https://www.linkedin.com/in/ines"); + assertThat(JsonPath.>read(apres, "$.realisations")).hasSize(2); + + MvcTestResult vide = patch(ines, "{\"realisations\":[],\"langues\":[],\"version\":%d}".formatted(version(ines))); + assertThat(vide).bodyJson().extractingPath("$.realisations").asArray().isEmpty(); + assertThat(vide).bodyJson().extractingPath("$.langues").asArray().isEmpty(); + } + + @Test + void refuse_les_liens_dangereux_et_les_listes_trop_longues() { + var omar = profilGenere("omar"); + MvcTestResult reponse = patch(omar, """ + {"linkedin":"https://linkedin.com.pirate.io/in/omar","siteWeb":"javascript:alert(1)", + "langues":["fr","xx"],"competencesPhares":["a","b","c","d"], + "realisations":[{"role":"seul"},{"titre":"Site","url":"data:text/html,x","annee":1800,"pirate":1}], + "version":%d}""".formatted(version(omar))); + + assertThat(reponse).hasStatus(HttpStatus.BAD_REQUEST); + assertThat(reponse).bodyJson().extractingPath("$.erreurs[*].champ").asArray().containsExactlyInAnyOrder( + "linkedin", "siteWeb", "langues[1]", "competencesPhares", "realisations[0].titre", + "realisations[1].url", "realisations[1].annee", "realisations[1].pirate"); + assertThat(mvc.get().uri("/api/me/profil").with(omar)).bodyJson().extractingPath("$.realisations").asArray() + .isEmpty(); + } + + @Test + void un_profil_publie_se_lit_sans_compte_mais_pas_la_recherche() { + UUID idLeila = creerCompte("Leila", "leila"); + var leila = connecte(idLeila); + profilGenere(leila); + + assertThat(mvc.get().uri("/api/profils/{id}", idLeila)).hasStatus(HttpStatus.NOT_FOUND); + mvc.post().uri("/api/me/profil/publier").with(leila).with(csrf()).exchange(); + assertThat(mvc.get().uri("/api/profils/{id}", idLeila)).hasStatusOk().bodyText().doesNotContain("email"); + assertThat(mvc.get().uri("/api/profils")).hasStatus(HttpStatus.UNAUTHORIZED); + } + // ---------- Carte de compétences proposée par l'IA ---------- @Test diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 7653c88..6ec464c 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@fontsource/familjen-grotesk": "5.3.0", "@fontsource/noto-sans": "5.3.0", + "flag-icons": "7.6.0", "openapi-fetch": "^0.17.0", + "uqr": "0.1.3", "vue": "^3.5.45", "vue-i18n": "^11.4.18", "vue-router": "^5.5.0" @@ -97,6 +99,7 @@ "version": "2.0.0-beta.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-beta.2.tgz", "integrity": "sha512-MzCVoWtcg4Pm2B3je5mMvYMvj3MmBKw+fU7vbsJc9/iT8ip6/eYIHo/Dsm/mr+wsHB6SEyJDuxB09Yy6r/8+4A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -108,6 +111,7 @@ "version": "2.0.0-beta.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-beta.2.tgz", "integrity": "sha512-JcrSPzPvZuNZvJqPTF9Fw25LbOJ8avzFVlaf2grmH4Cku0oGP47tRgqPZadcfj3YoZMHkFVnCYFbQM8I+i5QEw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -118,6 +122,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.2.0.tgz", "integrity": "sha512-JP7OYH38Q7+pyTAe14raZHLQIK7BDKbdwji+Xi3pcd/6QkJYQH3SX4wu6EplGrGJWRepoNzedfm0G3B9Z4e9Rw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -382,6 +387,7 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -509,6 +515,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -525,6 +532,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -541,6 +549,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -557,6 +566,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -573,6 +583,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -589,6 +600,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -605,9 +617,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -624,9 +634,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,9 +651,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -662,9 +668,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -681,9 +685,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -700,9 +702,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -719,6 +719,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -735,6 +736,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -753,6 +755,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -769,6 +772,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -788,6 +792,7 @@ "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -839,7 +844,7 @@ "version": "26.6.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -1695,10 +1700,17 @@ } } }, + "node_modules/flag-icons": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.6.0.tgz", + "integrity": "sha512-hir/PsXwr0EESyizKrXJzmkzpLIMf+1vlNKLqAfvdh+bsU94a7LYWTiubkHKqptGoCPzOUGcm6c4PfC9/BaGSQ==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2016,6 +2028,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2036,6 +2049,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2056,6 +2070,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2076,6 +2091,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2096,6 +2112,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2116,9 +2133,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2139,9 +2154,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2162,9 +2175,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2185,9 +2196,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2208,6 +2217,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2228,6 +2238,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2997,6 +3008,7 @@ "version": "2.9.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "dev": true, "license": "0BSD", "optional": true }, @@ -3050,7 +3062,7 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unplugin": { @@ -3138,6 +3150,12 @@ "url": "https://github.com/sponsors/kettanaito" } }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "license": "MIT" + }, "node_modules/uri-js-replace": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.2.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index a99277c..c7d7295 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,9 @@ "dependencies": { "@fontsource/familjen-grotesk": "5.3.0", "@fontsource/noto-sans": "5.3.0", + "flag-icons": "7.6.0", "openapi-fetch": "^0.17.0", + "uqr": "0.1.3", "vue": "^3.5.45", "vue-i18n": "^11.4.18", "vue-router": "^5.5.0" diff --git a/apps/web/src/api/generated/profils.ts b/apps/web/src/api/generated/profils.ts index 8bc4260..6d00ebf 100644 --- a/apps/web/src/api/generated/profils.ts +++ b/apps/web/src/api/generated/profils.ts @@ -120,7 +120,10 @@ export interface paths { }; cookie?: never; }; - /** Un profil publié */ + /** + * Un profil publié + * @description Lisible sans compte (#159) : c'est le lien partagé et le QR code de la carte. Un profil non publié répond 404, sauf à son propriétaire. + */ get: operations["lireProfil"]; put?: never; post?: never; @@ -183,6 +186,20 @@ export interface components { Pays: string; /** @enum {string} */ Disponibilite: "disponible" | "occupe" | "indisponible"; + /** @description Code ISO 639-1 (`fr`, `en`, `wo`, `sw`, `ha`). */ + Langue: string; + Realisation: { + titre: string; + role?: string | null; + description?: string | null; + technologies?: components["schemas"]["Competence"][]; + /** + * Format: uri + * @description http ou https seulement. + */ + url?: string | null; + annee?: number | null; + }; /** @enum {string} */ TypePreuve: "tache_livree_a_temps" | "avis_coequipier" | "reponse_acceptee" | "projet_en_ligne"; /** @description Une ligne de la recherche de coéquipiers. */ @@ -209,6 +226,8 @@ export interface components { /** Format: uri */ avatarUrl?: string | null; loginGithub?: string | null; + /** @description Titre professionnel écrit par la personne (« Développeuse backend · Java »). */ + titre?: string | null; bio?: string | null; /** @description Résumé proposé par l'IA à partir des données publiques. Le front l'étiquette « Proposé par l'IA ». `null` si l'utilisateur l'a masqué ou si l'IA n'a pas répondu. */ resumeIa?: string | null; @@ -216,6 +235,22 @@ export interface components { ville?: string | null; disponibilite: components["schemas"]["Disponibilite"]; heuresParSemaine?: number | null; + /** + * Format: uri + * @description https, sur linkedin.com. + */ + linkedin?: string | null; + /** + * Format: uri + * @description http ou https seulement. + */ + siteWeb?: string | null; + /** @description Langues parlées, codes ISO 639-1 (`fr`, `wo`, `sw`, `en`). Le front affiche leur nom. */ + langues: components["schemas"]["Langue"][]; + /** @description Les compétences que la personne met en avant, dans son ordre. */ + competencesPhares: components["schemas"]["Competence"][]; + /** @description Réalisations déclarées hors de Jamii. Affichées « Déclaré » ; seuls les `projets` Jamii sont vérifiés. */ + realisations: components["schemas"]["Realisation"][]; /** @description Langages des dépôts publics GitHub (hors forks), par part décroissante. */ langages: components["schemas"]["Langage"][]; competences: components["schemas"]["CompetenceProfil"][]; @@ -249,6 +284,15 @@ export interface components { ville?: string | null; disponibilite?: components["schemas"]["Disponibilite"]; heuresParSemaine?: number | null; + titre?: string | null; + /** Format: uri */ + linkedin?: string | null; + /** Format: uri */ + siteWeb?: string | null; + langues?: components["schemas"]["Langue"][]; + competencesPhares?: components["schemas"]["Competence"][]; + /** @description Remplace la liste entière. */ + realisations?: components["schemas"]["Realisation"][]; competencesDeclarees?: components["schemas"]["Competence"][]; afficherResumeIa?: boolean; /** @description Les compétences de `competencesProposees` que la personne garde. Remplace la liste entière. */ diff --git a/apps/web/src/features/profils/FicheProfil.vue b/apps/web/src/features/profils/FicheProfil.vue new file mode 100644 index 0000000..39a1453 --- /dev/null +++ b/apps/web/src/features/profils/FicheProfil.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/apps/web/src/features/profils/MonProfilPage.vue b/apps/web/src/features/profils/MonProfilPage.vue index 1d70824..35a63f9 100644 --- a/apps/web/src/features/profils/MonProfilPage.vue +++ b/apps/web/src/features/profils/MonProfilPage.vue @@ -1,13 +1,23 @@ + + + + diff --git a/apps/web/src/features/profils/ProfilPublicPage.vue b/apps/web/src/features/profils/ProfilPublicPage.vue new file mode 100644 index 0000000..bb7018f --- /dev/null +++ b/apps/web/src/features/profils/ProfilPublicPage.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/web/src/features/profils/carteProfil.ts b/apps/web/src/features/profils/carteProfil.ts new file mode 100644 index 0000000..8269c7c --- /dev/null +++ b/apps/web/src/features/profils/carteProfil.ts @@ -0,0 +1,246 @@ +import { encode } from 'uqr' +import { competencesPhares, nomLangue, urlDrapeau, type Profil } from './profils' +import { nomPays } from '../systalink/calculs' + +/** + * La carte de profil à partager (#159) : une image 1200 × 630, le format des aperçus LinkedIn, WhatsApp et X. + * Dessinée dans un canvas, sans bibliothèque : photo, nom, titre, pays, 3 compétences, chiffres, QR code du profil. + */ +export const LARGEUR = 1200 +export const HAUTEUR = 630 + +// Les couleurs de Jamii (design-system/tokens.css, thème sombre) : la carte a la même allure partout. +const C = { + fond: '#071017', + surface: '#0F1C24', + ligne: '#1E3340', + texte: '#EEF4F7', + discret: '#9DB0BC', + action: '#0297DE', + preuve: '#FC734D', +} +const TITRE = '"Familjen Grotesk", "Noto Sans", system-ui, sans-serif' +const TEXTE = '"Noto Sans", system-ui, sans-serif' + +export interface TextesCarte { + langue: string + competences: string + projetsVerifies: (n: number) => string + realisations: (n: number) => string + scanner: string +} + +function chargerImage(src: string): Promise { + return new Promise((resolve) => { + const image = new Image() + // Les avatars GitHub autorisent le CORS : sans cet attribut, le canvas serait « sali » et l'export refusé. + image.crossOrigin = 'anonymous' + image.onload = () => resolve(image) + image.onerror = () => resolve(null) + image.src = src + }) +} + +function arrondi(ctx: CanvasRenderingContext2D, x: number, y: number, l: number, h: number, r: number) { + ctx.beginPath() + ctx.roundRect(x, y, l, h, r) +} + +/** Coupe le texte avec « … » s'il dépasse la largeur donnée. */ +function ajuster(ctx: CanvasRenderingContext2D, texte: string, largeur: number): string { + if (ctx.measureText(texte).width <= largeur) return texte + let t = texte + while (t.length > 1 && ctx.measureText(`${t}…`).width > largeur) t = t.slice(0, -1) + return `${t.trimEnd()}…` +} + +function initiales(nom: string): string { + return nom.split(/\s+/).filter(Boolean).slice(0, 2).map((m) => m[0]!.toUpperCase()).join('') +} + +function dessinerQr(ctx: CanvasRenderingContext2D, lien: string, x: number, y: number, taille: number) { + const qr = encode(lien, { ecc: 'M', border: 0 }) + const marge = 18 + ctx.fillStyle = '#FFFFFF' + arrondi(ctx, x, y, taille, taille, 20) + ctx.fill() + const module = (taille - 2 * marge) / qr.size + ctx.fillStyle = '#02070A' + qr.data.forEach((ligne, i) => + ligne.forEach((plein, j) => { + // +0.5 : pas de fines lignes blanches entre deux modules voisins. + if (plein) ctx.fillRect(x + marge + j * module, y + marge + i * module, module + 0.5, module + 0.5) + }), + ) +} + +export async function dessinerCarte(profil: Profil, lien: string, textes: TextesCarte): Promise { + const canvas = document.createElement('canvas') + canvas.width = LARGEUR + canvas.height = HAUTEUR + const ctx = canvas.getContext('2d')! + await Promise.allSettled([ + document.fonts?.load(`700 64px ${TITRE}`), + document.fonts?.load(`400 28px ${TEXTE}`), + document.fonts?.load(`600 24px ${TEXTE}`), + ]) + const [avatar, drapeau] = await Promise.all([ + profil.avatarUrl ? chargerImage(profil.avatarUrl) : Promise.resolve(null), + urlDrapeau(profil.pays) ? chargerImage(urlDrapeau(profil.pays)!) : Promise.resolve(null), + ]) + + // Fond : une lueur bleue discrète en haut à gauche, comme sur le site. + ctx.fillStyle = C.fond + ctx.fillRect(0, 0, LARGEUR, HAUTEUR) + const lueur = ctx.createRadialGradient(160, 120, 0, 160, 120, 700) + lueur.addColorStop(0, 'rgba(2, 151, 222, 0.22)') + lueur.addColorStop(1, 'rgba(2, 151, 222, 0)') + ctx.fillStyle = lueur + ctx.fillRect(0, 0, LARGEUR, HAUTEUR) + ctx.fillStyle = C.action + ctx.fillRect(0, HAUTEUR - 8, LARGEUR, 8) + + // Marque + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = C.texte + ctx.font = `700 34px ${TITRE}` + ctx.fillText('jamii', 72, 92) + ctx.fillStyle = C.action + ctx.beginPath() + ctx.arc(72 + ctx.measureText('jamii').width + 10, 86, 6, 0, Math.PI * 2) + ctx.fill() + + // Photo ronde, avec un anneau de la couleur d'action. + const ax = 72 + const ay = 150 + const at = 176 + ctx.save() + ctx.beginPath() + ctx.arc(ax + at / 2, ay + at / 2, at / 2, 0, Math.PI * 2) + ctx.clip() + if (avatar) { + ctx.drawImage(avatar, ax, ay, at, at) + } else { + ctx.fillStyle = C.surface + ctx.fillRect(ax, ay, at, at) + ctx.fillStyle = C.texte + ctx.font = `700 64px ${TITRE}` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(initiales(profil.nom), ax + at / 2, ay + at / 2 + 4) + ctx.textAlign = 'start' + ctx.textBaseline = 'alphabetic' + } + ctx.restore() + ctx.strokeStyle = C.action + ctx.lineWidth = 5 + ctx.beginPath() + ctx.arc(ax + at / 2, ay + at / 2, at / 2 + 6, 0, Math.PI * 2) + ctx.stroke() + + // Identité + const tx = 300 + const largeurTexte = 520 + ctx.fillStyle = C.texte + ctx.font = `700 60px ${TITRE}` + ctx.fillText(ajuster(ctx, profil.nom, largeurTexte), tx, 212) + let y = 260 + if (profil.titre) { + ctx.fillStyle = C.discret + ctx.font = `400 30px ${TEXTE}` + ctx.fillText(ajuster(ctx, profil.titre, largeurTexte), tx, y) + y += 50 + } + const lieu = [profil.ville, profil.pays && nomPays(profil.pays, textes.langue)].filter(Boolean).join(', ') + if (lieu) { + let lx = tx + if (drapeau) { + ctx.save() + arrondi(ctx, lx, y - 24, 40, 30, 4) + ctx.clip() + ctx.drawImage(drapeau, lx, y - 24, 40, 30) + ctx.restore() + lx += 54 + } + ctx.fillStyle = C.texte + ctx.font = `600 26px ${TEXTE}` + ctx.fillText(ajuster(ctx, lieu, largeurTexte - (lx - tx)), lx, y) + } + + // Compétences mises en avant, en pastilles. + const phares = competencesPhares(profil) + let cy = 420 + if (phares.length) { + ctx.fillStyle = C.discret + ctx.font = `600 20px ${TEXTE}` + ctx.fillText(textes.competences.toUpperCase(), 72, cy) + cy += 22 + let px = 72 + ctx.font = `600 26px ${TEXTE}` + for (const c of phares) { + const l = Math.min(ctx.measureText(c).width + 44, 300) + if (px + l > 820) break + ctx.fillStyle = C.surface + arrondi(ctx, px, cy, l, 52, 26) + ctx.fill() + ctx.strokeStyle = C.ligne + ctx.lineWidth = 2 + ctx.stroke() + ctx.fillStyle = C.texte + ctx.fillText(ajuster(ctx, c, l - 44), px + 22, cy + 35) + px += l + 14 + } + } + + // Ce qui se vérifie : projets en ligne sur Jamii, puis réalisations déclarées et langues. + const verifies = profil.projets.filter((p) => p.enLigne).length + const faits = [ + verifies ? textes.projetsVerifies(verifies) : null, + profil.realisations.length ? textes.realisations(profil.realisations.length) : null, + profil.langues.length ? profil.langues.slice(0, 3).map((l) => nomLangue(l, textes.langue)).join(' · ') : null, + ].filter(Boolean) as string[] + if (faits.length) { + ctx.font = `400 24px ${TEXTE}` + ctx.fillStyle = C.discret + ctx.fillText(ajuster(ctx, faits.join(' • '), 760), 72, 568) + if (verifies) { + ctx.fillStyle = C.preuve + ctx.beginPath() + ctx.arc(60, 560, 5, 0, Math.PI * 2) + ctx.fill() + } + } + + // QR code vers le profil public. + const qx = 880 + const qy = 150 + const qt = 248 + dessinerQr(ctx, lien, qx, qy, qt) + ctx.fillStyle = C.texte + ctx.font = `600 22px ${TEXTE}` + ctx.textAlign = 'center' + ctx.fillText(textes.scanner, qx + qt / 2, qy + qt + 42) + ctx.fillStyle = C.discret + ctx.font = `400 18px ${TEXTE}` + ctx.fillText(ajuster(ctx, lien.replace(/^https?:\/\//, ''), qt + 40), qx + qt / 2, qy + qt + 70) + ctx.textAlign = 'start' + + return canvas +} + +/** L'image en PNG. `null` si le navigateur refuse l'export (photo sans CORS) : on redessine alors sans la photo. */ +export async function carteEnPng(profil: Profil, lien: string, textes: TextesCarte): Promise { + const versBlob = (c: HTMLCanvasElement) => + new Promise((resolve) => { + try { + c.toBlob(resolve, 'image/png') + } catch { + resolve(null) + } + }) + const blob = await versBlob(await dessinerCarte(profil, lien, textes)) + if (blob) return blob + const sansPhoto = await versBlob(await dessinerCarte({ ...profil, avatarUrl: null }, lien, textes)) + if (!sansPhoto) throw new Error('export impossible') + return sansPhoto +} diff --git a/apps/web/src/features/profils/profils.test.ts b/apps/web/src/features/profils/profils.test.ts index 50928e5..c228fae 100644 --- a/apps/web/src/features/profils/profils.test.ts +++ b/apps/web/src/features/profils/profils.test.ts @@ -7,13 +7,13 @@ import { i18n } from '../../i18n' import { creerRouteur } from '../../router' import { chargerSession } from '../../session' import App from '../../App.vue' -import { lireCompetences, pourcentage } from './profils' +import { competencesPhares, hoteLisible, lienProfil, lireCompetences, pourcentage, urlDrapeau } from './profils' -async function ouvrir() { +async function ouvrir(chemin = '/profil') { ;(i18n.global.locale as unknown as { value: string }).value = 'fr' await chargerSession() const routeur = creerRouteur(createMemoryHistory()) - await routeur.push('/profil') + await routeur.push(chemin) const page = mount(App, { global: { plugins: [i18n, routeur] } }) await vi.waitFor(() => expect(page.text()).not.toContain('Chargement')) return page @@ -98,6 +98,7 @@ describe('Mon profil', () => { http.get('/api/me/profil', () => HttpResponse.json({ utilisateurId: 'x', nom: 'Aïcha Kouassi', disponibilite: 'disponible', langages: [{ nom: 'Dart', part: 1 }], + langues: [], competencesPhares: [], realisations: [], competences: [], depotsGithub: [], misAJourLe: '2026-11-11T10:00:00Z', publie: true, afficherResumeIa: true, version: 2, generation: { statut: 'terminee' }, projets: [{ projetId: 'p1', titre: 'Marché Facile', role: 'porteur', paysEquipe: ['CI', 'KE'], enLigne: true, hebergeur: 'datacloud' }], @@ -123,6 +124,7 @@ describe('Mon profil', () => { http.get('/api/me/profil', () => HttpResponse.json({ utilisateurId: 'x', nom: 'Awa Diop', disponibilite: 'disponible', langages: [{ nom: 'Dart', part: 1 }], + langues: [], competencesPhares: [], realisations: [], competences: [], projets: [], depotsGithub: [], fiabilite: [], misAJourLe: '2026-11-11T10:00:00Z', publie: false, afficherResumeIa: true, version: 3, generation: { statut: 'echec', erreur: 'github_indisponible' }, @@ -135,6 +137,46 @@ describe('Mon profil', () => { }) }) +describe('profil professionnel (#159)', () => { + const pro = { + utilisateurId: 'b2c3', nom: 'Kofi Mensah', titre: 'Développeur backend · Java', pays: 'GH', ville: 'Accra', + disponibilite: 'disponible', linkedin: 'https://www.linkedin.com/in/kofi', siteWeb: 'https://kofi.dev', + langues: ['en', 'ak'], competencesPhares: ['spring-boot'], loginGithub: 'kofi', + realisations: [{ titre: 'Caisse en ligne', role: 'Développeur', annee: 2025, technologies: ['java'], url: 'https://caisse.exemple.gh' }], + langages: [], competences: [{ competence: 'java', source: 'github' }], projets: [], depotsGithub: [], fiabilite: [], + misAJourLe: '2026-11-11T10:00:00Z', + } + + it('montre le titre, le drapeau, les liens, les langues et les réalisations « Déclaré »', async () => { + serveur.use(http.get('/api/profils/:id', () => HttpResponse.json(pro))) + const page = await ouvrir('/p/b2c3') + await vi.waitFor(() => expect(page.find('[data-test="identite"]').exists()).toBe(true)) + expect(page.get('[data-test="titre"]').text()).toBe('Développeur backend · Java') + expect(page.get('[data-test="drapeau"]').attributes('src')).toContain('gh.svg') + expect(page.get('[data-test="liens"]').text()).toContain('LinkedIn') + expect(page.get('[data-test="liens"]').text()).toContain('kofi.dev') + expect(page.get('[data-test="langues"]').text()).toContain('Anglais') + const realisations = page.get('[data-test="realisations"]').text() + expect(realisations).toContain('Caisse en ligne') + expect(realisations).toContain('Déclaré') + expect(page.get('[data-test="phares"]').text()).toContain('spring-boot') + }) + + it('un profil privé ou inconnu le dit, sans rien révéler', async () => { + serveur.use(http.get('/api/profils/:id', () => probleme(404, 'introuvable'))) + const page = await ouvrir('/p/inconnu') + await vi.waitFor(() => expect(page.find('[data-test="introuvable"]').exists()).toBe(true)) + }) + + it('le lien public et le drapeau se construisent sans erreur', () => { + expect(lienProfil('abc', 'https://jamii.exemple')).toBe('https://jamii.exemple/p/abc') + expect(urlDrapeau('SN')).toContain('sn.svg') + expect(urlDrapeau('xx')).toBeNull() + expect(hoteLisible('https://www.kofi.dev/')).toBe('kofi.dev') + expect(competencesPhares({ competencesPhares: [], competences: [{ competence: 'dart', source: 'github' }] })).toEqual(['dart']) + }) +}) + describe('outils', () => { it('la part d’un langage s’écrit en pourcentage arrondi', () => { expect(pourcentage(0.745, 'en')).toBe('75%') diff --git a/apps/web/src/features/profils/profils.ts b/apps/web/src/features/profils/profils.ts index 4549297..4b65dc5 100644 --- a/apps/web/src/features/profils/profils.ts +++ b/apps/web/src/features/profils/profils.ts @@ -21,3 +21,59 @@ export function pourcentage(part: number, langue: string): string { export function lireCompetences(saisie: string): string[] { return [...new Set(saisie.split(',').map((c) => c.trim()).filter(Boolean))] } + +export type Profil = components['schemas']['Profil'] +export type Realisation = components['schemas']['Realisation'] + +/** + * Langues proposées dans le formulaire (codes ISO 639-1) : les grandes langues de travail du continent, puis les + * langues africaines les plus parlées. Le nom s'affiche dans la langue de l'interface (Intl.DisplayNames). + */ +export const LANGUES_PROPOSEES = [ + 'fr', 'en', 'ar', 'pt', 'sw', 'ha', 'yo', 'ig', 'am', 'om', 'so', 'wo', 'ff', 'bm', 'ln', 'rw', 'mg', 'ak', 'ee', + 'zu', 'xh', 'sn', 'ti', +] as const + +export function nomLangue(code: string, langue: string): string { + try { + const nom = new Intl.DisplayNames([langue], { type: 'language' }).of(code) + return nom ? nom.charAt(0).toLocaleUpperCase(langue) + nom.slice(1) : code + } catch { + return code + } +} + +// Drapeaux en SVG (flag-icons, MIT) : Windows n'affiche pas les drapeaux en emoji. Seules des adresses sont importées, +// le navigateur ne télécharge que le drapeau affiché. +const DRAPEAUX = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { + query: '?url&no-inline', + import: 'default', + eager: true, +}) as Record + +export function urlDrapeau(pays: string | null | undefined): string | null { + if (!pays || !/^[A-Z]{2}$/.test(pays)) return null + return DRAPEAUX[`/node_modules/flag-icons/flags/4x3/${pays.toLowerCase()}.svg`] ?? null +} + +/** Les compétences à mettre en avant : celles choisies par la personne, sinon les premières de son profil. */ +export function competencesPhares(profil: Pick, nombre = 3): string[] { + if (profil.competencesPhares.length) return profil.competencesPhares.slice(0, nombre) + return profil.competences.slice(0, nombre).map((c) => c.competence) +} + +/** L'adresse publique d'un profil : celle du lien partagé et du QR code. */ +export function lienProfil(utilisateurId: string, origine = window.location.origin): string { + return `${origine}/p/${utilisateurId}` +} + +/** Le nom d'hôte d'un lien, pour l'afficher sans « https://www. ». */ +export function hoteLisible(lien: string): string { + try { + const url = new URL(lien) + const chemin = url.pathname.replace(/\/$/, '') + return url.host.replace(/^www\./, '') + (chemin.length > 1 && chemin.length < 30 ? chemin : '') + } catch { + return lien + } +} diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json index 3597e18..7979800 100644 --- a/apps/web/src/i18n/en.json +++ b/apps/web/src/i18n/en.json @@ -111,9 +111,10 @@ "heures": "{n} h per week", "visibilite": { "prive": "🔒 Your profile is private: only you can see it.", - "publie": "✅ Your profile is published: signed-in members can see it and invite you to a team.", + "publie": "✅ Your profile is public: members and anyone with your link can see it.", "publier": "Publish my profile", - "retirer": "Make my profile private" + "retirer": "Make my profile private", + "voirPublic": "See my public profile" }, "modifier": { "bouton": "Edit", @@ -130,7 +131,27 @@ "annuler": "Cancel", "enregistre": "Your profile is saved.", "conflit": "Your profile changed in another tab. Here is the latest version: please make your change again.", - "invalide": "This value is not accepted." + "invalide": "This value is not accepted.", + "titrePro": "Professional title", + "titreProAide": "Example: Mobile developer · Flutter, or Product designer", + "linkedin": "LinkedIn profile", + "linkedinAide": "Starts with https://www.linkedin.com/in/", + "site": "Website or portfolio", + "siteAide": "Starts with https://", + "langues": "Languages you speak", + "phares": "Skills to feature ({n} at most)", + "realisations": "Work outside Jamii", + "realisationsAide": "6 at most. Shown as “Self-reported”: only projects launched on Jamii are verified.", + "realisation": { + "titre": "Project name", + "role": "Your role", + "description": "In two sentences", + "technologies": "Technologies (comma-separated)", + "lien": "Link", + "annee": "Year", + "retirer": "Remove this project", + "ajouter": "Add a project" + } }, "langages": { "titre": "Languages", @@ -185,6 +206,45 @@ "intermediaire": "Intermediate", "confirme": "Experienced" } + }, + "liens": { + "site": "Website" + }, + "langues": { + "parle": "Speaks:" + }, + "phares": { + "titre": "Featured skills" + }, + "realisations": { + "titre": "Work", + "declare": "Self-reported", + "declareAide": "Added by the person. Only projects on Jamii are verified." + }, + "partage": { + "bouton": "Share my card", + "titre": "Your profile card", + "fermer": "Close", + "nonPublie": "Your profile is private: the QR code won’t lead anywhere until you make it public.", + "alt": "Profile card of {nom}", + "preparation": "Preparing your card…", + "telecharger": "Save the image", + "envoyer": "Send", + "copier": "Copy profile link", + "copie": "Link copied", + "competences": "Skills", + "projetsVerifies": "no verified project | {n} verified project on Jamii | {n} verified projects on Jamii", + "realisations": "no work | {n} project | {n} projects", + "scanner": "See my profile", + "message": "My profile on Jamii: {lien}" + }, + "public": { + "introuvable": "This profile is not public", + "introuvableAide": "The link may be wrong, or the person made their profile private.", + "decouvrir": "Discover Jamii", + "invitationTitre": "Show what you can do, too.", + "invitationTexte": "On Jamii, projects you launch with a team become proof.", + "rejoindre": "Create my profile" } }, "etat": { diff --git a/apps/web/src/i18n/fr.json b/apps/web/src/i18n/fr.json index 442bd7c..0c39ed6 100644 --- a/apps/web/src/i18n/fr.json +++ b/apps/web/src/i18n/fr.json @@ -111,9 +111,10 @@ "heures": "{n} h par semaine", "visibilite": { "prive": "🔒 Ton profil est privé : toi seul le vois.", - "publie": "✅ Ton profil est publié : les membres connectés le voient et peuvent te proposer une équipe.", + "publie": "✅ Ton profil est publié : il est visible par les membres et par toute personne qui a ton lien.", "publier": "Publier mon profil", - "retirer": "Rendre mon profil privé" + "retirer": "Rendre mon profil privé", + "voirPublic": "Voir mon profil public" }, "modifier": { "bouton": "Modifier", @@ -130,7 +131,27 @@ "annuler": "Annuler", "enregistre": "Ton profil est enregistré.", "conflit": "Ton profil a changé dans un autre onglet. Voici la dernière version : refais ta modification.", - "invalide": "Cette valeur n'est pas acceptée." + "invalide": "Cette valeur n'est pas acceptée.", + "titrePro": "Titre professionnel", + "titreProAide": "Exemple : Développeuse mobile · Flutter, ou Product designer", + "linkedin": "Profil LinkedIn", + "linkedinAide": "Commence par https://www.linkedin.com/in/", + "site": "Site ou portfolio", + "siteAide": "Commence par https://", + "langues": "Langues que tu parles", + "phares": "Compétences à mettre en avant ({n} au plus)", + "realisations": "Réalisations hors de Jamii", + "realisationsAide": "6 au plus. Elles s’affichent « Déclaré » : seuls les projets mis en ligne sur Jamii sont vérifiés.", + "realisation": { + "titre": "Nom du projet", + "role": "Ton rôle", + "description": "En deux phrases", + "technologies": "Technologies (séparées par des virgules)", + "lien": "Lien", + "annee": "Année", + "retirer": "Retirer cette réalisation", + "ajouter": "Ajouter une réalisation" + } }, "langages": { "titre": "Langages", @@ -185,6 +206,45 @@ "intermediaire": "Intermédiaire", "confirme": "Confirmé" } + }, + "liens": { + "site": "Site" + }, + "langues": { + "parle": "Parle :" + }, + "phares": { + "titre": "Compétences mises en avant" + }, + "realisations": { + "titre": "Réalisations", + "declare": "Déclaré", + "declareAide": "Ajouté par la personne. Seuls les projets sur Jamii sont vérifiés." + }, + "partage": { + "bouton": "Partager ma carte", + "titre": "Ta carte de profil", + "fermer": "Fermer", + "nonPublie": "Ton profil est privé : le QR code ne mènera à rien tant que tu ne l’as pas publié.", + "alt": "Carte de profil de {nom}", + "preparation": "Préparation de ta carte…", + "telecharger": "Enregistrer l’image", + "envoyer": "Envoyer", + "copier": "Copier le lien du profil", + "copie": "Lien copié", + "competences": "Compétences", + "projetsVerifies": "aucun projet vérifié | {n} projet vérifié sur Jamii | {n} projets vérifiés sur Jamii", + "realisations": "aucune réalisation | {n} réalisation | {n} réalisations", + "scanner": "Voir mon profil", + "message": "Mon profil sur Jamii : {lien}" + }, + "public": { + "introuvable": "Ce profil n’est pas public", + "introuvableAide": "Le lien est peut-être incorrect, ou la personne a rendu son profil privé.", + "decouvrir": "Découvrir Jamii", + "invitationTitre": "Toi aussi, montre ce que tu sais faire.", + "invitationTexte": "Sur Jamii, tes projets mis en ligne avec une équipe deviennent des preuves.", + "rejoindre": "Créer mon profil" } }, "etat": { diff --git a/apps/web/src/mocks/profils.ts b/apps/web/src/mocks/profils.ts index 280dc1f..41712de 100644 --- a/apps/web/src/mocks/profils.ts +++ b/apps/web/src/mocks/profils.ts @@ -25,6 +25,12 @@ function profilGenere(): MonProfil { ville: null, disponibilite: 'disponible', heuresParSemaine: null, + titre: null, + linkedin: null, + siteWeb: null, + langues: [], + competencesPhares: [], + realisations: [], langages: [ { nom: 'Dart', part: 0.745 }, { nom: 'TypeScript', part: 0.223 }, @@ -86,6 +92,8 @@ function declare(p: MonProfil): Partial { return { bio: p.bio, pays: p.pays, ville: p.ville, disponibilite: p.disponibilite, heuresParSemaine: p.heuresParSemaine, publie: p.publie, afficherResumeIa: p.afficherResumeIa, + titre: p.titre, linkedin: p.linkedin, siteWeb: p.siteWeb, langues: p.langues, + competencesPhares: p.competencesPhares, realisations: p.realisations, competences: [...profilGenere().competences, ...p.competences.filter((c) => c.source === 'declaree')], } } @@ -125,6 +133,13 @@ export const handlersProfils = [ profil = { ...p, ...champs, competences, version: p.version + 1, misAJourLe: maintenant() } return HttpResponse.json(profil) }), + // Le profil publié se lit sans compte (#159) : la démo n'a qu'un profil, celui d'Awa. + http.get('/api/profils/:id', ({ params }) => { + const p = lire() + if (!p || params.id !== p.utilisateurId || !p.publie) return probleme(404, 'introuvable', 'Profil introuvable') + const { publie: _p, afficherResumeIa: _a, resumeIaPropose: _r, competencesProposees: _c, generation: _g, version: _v, ...publicProfil } = p + return HttpResponse.json(publicProfil) + }), http.post('/api/me/profil/publier', () => { const p = lire() if (!p) return probleme(404, 'profil_non_genere', 'Aucun profil pour l’instant') diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index b30dc5b..9985515 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -13,6 +13,8 @@ export const routes: RouteRecordRaw[] = [ component: () => import('./features/profils/MonProfilPage.vue'), meta: { connexionRequise: true }, }, + // Le profil publié de quelqu'un : la page du lien partagé et du QR code, lisible sans compte (#159). + { path: '/p/:id', name: 'profil-public', component: () => import('./features/profils/ProfilPublicPage.vue') }, // Communautés : connexion requise (contracts/communautes.yaml). { path: '/communautes', diff --git a/contracts/profils.yaml b/contracts/profils.yaml index 4cdb35f..a936988 100644 --- a/contracts/profils.yaml +++ b/contracts/profils.yaml @@ -187,6 +187,8 @@ paths: tags: [profils] operationId: lireProfil summary: Un profil publié + description: 'Lisible sans compte (#159) : c''est le lien partagé et le QR code de la carte. Un profil non publié répond 404, sauf à son propriétaire.' + security: [] responses: '200': description: Le profil. @@ -273,6 +275,21 @@ components: Disponibilite: type: string enum: [disponible, occupe, indisponible] + Langue: + type: string + description: Code ISO 639-1 (`fr`, `en`, `wo`, `sw`, `ha`). + pattern: '^[a-z]{2}$' + Realisation: + type: object + additionalProperties: false + required: [titre] + properties: + titre: { type: string, minLength: 2, maxLength: 80 } + role: { type: [string, 'null'], maxLength: 60 } + description: { type: [string, 'null'], maxLength: 300 } + technologies: { type: array, maxItems: 8, items: { $ref: './projets.yaml#/components/schemas/Competence' } } + url: { type: [string, 'null'], format: uri, maxLength: 300, description: 'http ou https seulement.' } + annee: { type: [integer, 'null'], minimum: 1990 } TypePreuve: type: string enum: [tache_livree_a_temps, avis_coequipier, reponse_acceptee, projet_en_ligne] @@ -295,12 +312,13 @@ components: Profil: type: object description: Ce que les autres voient d'un profil publié. Jamais l'e-mail. - required: [utilisateurId, nom, disponibilite, langages, competences, projets, depotsGithub, fiabilite, misAJourLe] + required: [utilisateurId, nom, disponibilite, langues, competencesPhares, realisations, langages, competences, projets, depotsGithub, fiabilite, misAJourLe] properties: utilisateurId: { type: string, format: uuid } nom: { type: string } avatarUrl: { type: [string, 'null'], format: uri } loginGithub: { type: [string, 'null'] } + titre: { type: [string, 'null'], maxLength: 80, description: 'Titre professionnel écrit par la personne (« Développeuse backend · Java »).' } bio: { type: [string, 'null'], maxLength: 500 } resumeIa: type: [string, 'null'] @@ -310,6 +328,23 @@ components: ville: { type: [string, 'null'], maxLength: 80 } disponibilite: { $ref: '#/components/schemas/Disponibilite' } heuresParSemaine: { type: [integer, 'null'], minimum: 1, maximum: 60 } + linkedin: { type: [string, 'null'], format: uri, maxLength: 300, description: 'https, sur linkedin.com.' } + siteWeb: { type: [string, 'null'], format: uri, maxLength: 300, description: 'http ou https seulement.' } + langues: + type: array + maxItems: 8 + description: Langues parlées, codes ISO 639-1 (`fr`, `wo`, `sw`, `en`). Le front affiche leur nom. + items: { $ref: '#/components/schemas/Langue' } + competencesPhares: + type: array + maxItems: 3 + description: Les compétences que la personne met en avant, dans son ordre. + items: { $ref: './projets.yaml#/components/schemas/Competence' } + realisations: + type: array + maxItems: 6 + description: Réalisations déclarées hors de Jamii. Affichées « Déclaré » ; seuls les `projets` Jamii sont vérifiés. + items: { $ref: '#/components/schemas/Realisation' } langages: type: array description: Langages des dépôts publics GitHub (hors forks), par part décroissante. @@ -361,6 +396,16 @@ components: ville: { type: [string, 'null'], maxLength: 80 } disponibilite: { $ref: '#/components/schemas/Disponibilite' } heuresParSemaine: { type: [integer, 'null'], minimum: 1, maximum: 60 } + titre: { type: [string, 'null'], maxLength: 80 } + linkedin: { type: [string, 'null'], format: uri, maxLength: 300 } + siteWeb: { type: [string, 'null'], format: uri, maxLength: 300 } + langues: { type: array, maxItems: 8, uniqueItems: true, items: { $ref: '#/components/schemas/Langue' } } + competencesPhares: { type: array, maxItems: 3, uniqueItems: true, items: { $ref: './projets.yaml#/components/schemas/Competence' } } + realisations: + type: array + maxItems: 6 + description: Remplace la liste entière. + items: { $ref: '#/components/schemas/Realisation' } competencesDeclarees: type: array maxItems: 20