Files
Portfolio-Game/docs/implementation-artifacts/2-4-page-competences-affichage-categories.md
skycel 4db96a0ded Add skills page with category grouping (Story 2.4)
- Enhance Skill model with getCurrentLevel() and ordered scope
- Update SkillController to group by category with translated labels
- Add level and project_count to SkillResource
- Create skill.ts types (Skill, SkillCategory, SkillsResponse)
- Create useFetchSkills composable
- Create SkillCard component with animated progress bar
- Implement competences.vue with:
  - Responsive grid (2/3/4 columns)
  - Category sections with icons
  - Stagger animations (respects prefers-reduced-motion)
  - Loading/error/empty states
  - Placeholder for vis.js skill tree (Epic 3)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:37:10 +01:00

18 KiB

Story 2.4: Page Compétences - Affichage par catégories

Status: review

Story

As a visiteur, I want voir les compétences du développeur organisées par catégorie, so that je comprends son profil technique global.

Acceptance Criteria

  1. Given le visiteur accède à /competences (FR) ou /en/skills (EN) When la page se charge Then les compétences sont affichées groupées par catégorie (Frontend, Backend, Tools, Soft skills)
  2. And chaque compétence affiche : icône, nom traduit, niveau actuel (représentation visuelle)
  3. And les données sont chargées depuis l'API /api/skills avec le contenu traduit
  4. And une animation d'entrée des éléments est présente (respectant prefers-reduced-motion)
  5. And sur desktop : préparé pour accueillir le skill tree vis.js (Epic 3)
  6. And sur mobile : liste groupée par catégorie avec design adapté
  7. And les meta tags SEO sont dynamiques pour cette page
  8. And chaque compétence est visuellement cliquable (affordance)

Tasks / Subtasks

  • Task 1: Créer l'endpoint API Laravel pour les skills (AC: #3)

    • Créer app/Http/Controllers/Api/SkillController.php
    • Créer la méthode index() pour lister toutes les compétences
    • Grouper les compétences par catégorie
    • Joindre les traductions selon Accept-Language
    • Créer app/Http/Resources/SkillResource.php
    • Ajouter la route GET /api/skills dans routes/api.php
  • Task 2: Créer le composable useFetchSkills (AC: #3)

    • Créer frontend/app/composables/useFetchSkills.ts
    • Gérer les états loading, error, data
    • Typer la réponse avec interface Skill[]
  • Task 3: Créer le composant SkillCard (AC: #2, #8)

    • Créer frontend/app/components/feature/SkillCard.vue
    • Props : skill (avec name, icon, level, maxLevel)
    • Afficher l'icône (si présente) ou un placeholder
    • Afficher le nom traduit
    • Afficher le niveau avec une barre de progression
    • Style cliquable (hover effect, cursor pointer)
  • Task 4: Créer la page competences.vue (AC: #1, #6)

    • Créer frontend/app/pages/competences.vue
    • Charger les données avec useFetchSkills()
    • Grouper les skills par catégorie côté frontend
    • Afficher chaque catégorie comme une section avec titre
    • Grille de SkillCard dans chaque section
  • Task 5: Implémenter l'animation d'entrée (AC: #4)

    • Animation stagger pour les SkillCards (comme ProjectCard)
    • Animation fade-in pour les titres de catégories
    • Respecter prefers-reduced-motion
  • Task 6: Design responsive (AC: #5, #6)

    • Mobile : 2 colonnes de SkillCards par catégorie
    • Desktop : 4 colonnes, espace réservé pour vis.js (Epic 3)
    • Catégories empilées verticalement
  • Task 7: Représentation visuelle du niveau (AC: #2)

    • Créer une barre de progression stylisée (style RPG/XP)
    • Utiliser sky-accent pour la partie remplie
    • Afficher le ratio (ex: 4/5 ou 80%)
    • Animation subtile au chargement (remplissage progressif)
  • Task 8: Meta tags SEO (AC: #7)

    • Titre dynamique : "Compétences | Skycel"
    • Description : compétences de Célian
    • og:title et og:description
  • Task 9: Tests et validation

    • Tester en FR et EN
    • Vérifier le groupement par catégorie
    • Valider les animations
    • Tester le responsive
    • Vérifier que les skills sont cliquables (préparation Story 2.5)

Dev Notes

Endpoint API Laravel

<?php
// api/app/Http/Controllers/Api/SkillController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\SkillResource;
use App\Models\Skill;
use Illuminate\Http\Request;

class SkillController extends Controller
{
    public function index(Request $request)
    {
        $lang = $request->header('Accept-Language', 'fr');

        $skills = Skill::with('projects')
            ->orderBy('category')
            ->orderBy('display_order')
            ->get();

        // Grouper par catégorie
        $grouped = $skills->groupBy('category');

        return response()->json([
            'data' => $grouped->map(function ($categorySkills, $category) use ($lang) {
                return [
                    'category' => $category,
                    'categoryLabel' => $this->getCategoryLabel($category, $lang),
                    'skills' => SkillResource::collection($categorySkills),
                ];
            })->values(),
            'meta' => [
                'lang' => $lang,
                'total' => $skills->count(),
            ],
        ]);
    }

    private function getCategoryLabel(string $category, string $lang): string
    {
        $labels = [
            'frontend' => ['fr' => 'Frontend', 'en' => 'Frontend'],
            'backend' => ['fr' => 'Backend', 'en' => 'Backend'],
            'tools' => ['fr' => 'Outils', 'en' => 'Tools'],
            'soft_skills' => ['fr' => 'Soft Skills', 'en' => 'Soft Skills'],
        ];

        return $labels[strtolower($category)][$lang] ?? $category;
    }
}
<?php
// api/app/Http/Resources/SkillResource.php

namespace App\Http\Resources;

use App\Models\Translation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SkillResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        $lang = $request->header('Accept-Language', 'fr');

        return [
            'id' => $this->id,
            'slug' => $this->slug,
            'name' => Translation::getTranslation($this->name_key, $lang),
            'description' => Translation::getTranslation($this->description_key, $lang),
            'icon' => $this->icon,
            'category' => $this->category,
            'level' => $this->getCurrentLevel(),
            'maxLevel' => $this->max_level,
            'displayOrder' => $this->display_order,
            'projectCount' => $this->whenLoaded('projects', fn () => $this->projects->count()),
        ];
    }
}
// api/app/Models/Skill.php - Ajouter méthode
public function getCurrentLevel(): int
{
    // Retourne le niveau max atteint dans tous les projets
    $maxLevelAfter = $this->projects()
        ->max('skill_project.level_after');

    return $maxLevelAfter ?? 1;
}
// api/routes/api.php
Route::get('/skills', [SkillController::class, 'index']);

Composable useFetchSkills

// frontend/app/composables/useFetchSkills.ts
import type { Skill, SkillCategory } from '~/types/skill'

interface SkillsResponse {
  data: SkillCategory[]
  meta: { lang: string; total: number }
}

export function useFetchSkills() {
  const config = useRuntimeConfig()
  const { locale } = useI18n()

  return useFetch<SkillsResponse>('/skills', {
    baseURL: config.public.apiUrl,
    headers: {
      'X-API-Key': config.public.apiKey,
      'Accept-Language': locale.value,
    },
  })
}

Types TypeScript

// frontend/app/types/skill.ts
export interface Skill {
  id: number
  slug: string
  name: string
  description: string
  icon: string | null
  category: string
  level: number
  maxLevel: number
  displayOrder: number
  projectCount?: number
}

export interface SkillCategory {
  category: string
  categoryLabel: string
  skills: Skill[]
}

Composant SkillCard

<!-- frontend/app/components/feature/SkillCard.vue -->
<script setup lang="ts">
import type { Skill } from '~/types/skill'

const props = defineProps<{
  skill: Skill
}>()

const emit = defineEmits<{
  click: [skill: Skill]
}>()

const progressPercent = computed(() =>
  Math.round((props.skill.level / props.skill.maxLevel) * 100)
)
</script>

<template>
  <button
    type="button"
    class="skill-card group w-full text-left bg-sky-dark-50 rounded-lg p-4 hover:bg-sky-dark-100 transition-colors cursor-pointer"
    @click="emit('click', skill)"
  >
    <div class="flex items-center gap-3 mb-3">
      <!-- Icône -->
      <div class="w-10 h-10 flex items-center justify-center bg-sky-dark rounded-lg">
        <span v-if="skill.icon" class="text-2xl">{{ skill.icon }}</span>
        <span v-else class="text-sky-text-muted">💻</span>
      </div>

      <!-- Nom -->
      <div class="flex-1 min-w-0">
        <h3 class="font-ui font-medium text-sky-text truncate group-hover:text-sky-accent transition-colors">
          {{ skill.name }}
        </h3>
        <p v-if="skill.projectCount" class="text-xs text-sky-text-muted">
          {{ skill.projectCount }} {{ skill.projectCount > 1 ? 'projets' : 'projet' }}
        </p>
      </div>
    </div>

    <!-- Barre de progression -->
    <div class="relative h-2 bg-sky-dark rounded-full overflow-hidden">
      <div
        class="skill-progress absolute left-0 top-0 h-full bg-sky-accent rounded-full"
        :style="{ width: `${progressPercent}%` }"
      ></div>
    </div>

    <!-- Niveau -->
    <div class="flex justify-between items-center mt-2">
      <span class="text-xs text-sky-text-muted">Niveau</span>
      <span class="text-sm font-medium text-sky-accent">
        {{ skill.level }}/{{ skill.maxLevel }}
      </span>
    </div>
  </button>
</template>

<style scoped>
.skill-card:focus-visible {
  outline: 2px solid theme('colors.sky-accent.DEFAULT');
  outline-offset: 2px;
}

.skill-progress {
  animation: fillProgress 0.8s ease-out forwards;
}

@keyframes fillProgress {
  from {
    width: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .skill-progress {
    animation: none;
  }
}
</style>

Page competences.vue

<!-- frontend/app/pages/competences.vue -->
<script setup lang="ts">
import type { Skill } from '~/types/skill'

const { t } = useI18n()
const { data, pending, error, refresh } = useFetchSkills()

const categories = computed(() => data.value?.data ?? [])

// SEO
useHead({
  title: () => t('skills.pageTitle'),
})

useSeoMeta({
  title: () => t('skills.pageTitle'),
  description: () => t('skills.pageDescription'),
  ogTitle: () => t('skills.pageTitle'),
  ogDescription: () => t('skills.pageDescription'),
})

// Gestion du clic sur une compétence (préparation Story 2.5)
function handleSkillClick(skill: Skill) {
  // Sera implémenté en Story 2.5 - modal avec projets liés
  console.log('Skill clicked:', skill.slug)
}
</script>

<template>
  <div class="container mx-auto px-4 py-8">
    <h1 class="text-3xl font-ui font-bold text-sky-text mb-8">
      {{ t('skills.title') }}
    </h1>

    <!-- Loading -->
    <div v-if="pending" class="space-y-8">
      <div v-for="i in 4" :key="i">
        <div class="bg-sky-dark-50 h-8 rounded w-32 mb-4"></div>
        <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
          <div v-for="j in 4" :key="j" class="bg-sky-dark-50 rounded-lg h-24 animate-pulse"></div>
        </div>
      </div>
    </div>

    <!-- Error -->
    <div v-else-if="error" class="text-center py-12">
      <p class="text-sky-text-muted mb-4">{{ t('skills.loadError') }}</p>
      <button
        @click="refresh()"
        class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
      >
        {{ t('common.retry') }}
      </button>
    </div>

    <!-- Skills par catégorie -->
    <div v-else class="space-y-12">
      <section
        v-for="(category, categoryIndex) in categories"
        :key="category.category"
        class="category-section"
        :style="{ '--category-delay': `${categoryIndex * 150}ms` }"
      >
        <h2 class="text-xl font-ui font-semibold text-sky-text mb-4">
          {{ category.categoryLabel }}
        </h2>

        <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
          <SkillCard
            v-for="(skill, skillIndex) in category.skills"
            :key="skill.id"
            :skill="skill"
            class="skill-card-animated"
            :style="{ '--animation-delay': `${categoryIndex * 150 + skillIndex * 50}ms` }"
            @click="handleSkillClick"
          />
        </div>
      </section>

      <!-- Placeholder pour vis.js (Epic 3) - Desktop only -->
      <div class="hidden lg:block mt-12 p-8 border-2 border-dashed border-sky-dark-100 rounded-lg text-center">
        <p class="text-sky-text-muted">
          {{ t('skills.skillTreePlaceholder') }}
        </p>
      </div>
    </div>
  </div>
</template>

<style scoped>
.category-section {
  animation: fadeIn 0.5s ease-out forwards;
  animation-delay: var(--category-delay, 0ms);
  opacity: 0;
}

.skill-card-animated {
  animation: fadeInUp 0.4s ease-out forwards;
  animation-delay: var(--animation-delay, 0ms);
  opacity: 0;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .category-section,
  .skill-card-animated {
    animation: none;
    opacity: 1;
  }
}
</style>

Clés i18n nécessaires

fr.json :

{
  "skills": {
    "title": "Mes Compétences",
    "pageTitle": "Compétences | Skycel",
    "pageDescription": "Découvrez les compétences techniques et soft skills de Célian, développeur web full-stack.",
    "loadError": "Impossible de charger les compétences...",
    "skillTreePlaceholder": "Arbre de compétences interactif (bientôt disponible)",
    "level": "Niveau",
    "projects": "projets"
  }
}

en.json :

{
  "skills": {
    "title": "My Skills",
    "pageTitle": "Skills | Skycel",
    "pageDescription": "Discover the technical skills and soft skills of Célian, full-stack web developer.",
    "loadError": "Unable to load skills...",
    "skillTreePlaceholder": "Interactive skill tree (coming soon)",
    "level": "Level",
    "projects": "projects"
  }
}

Catégories de compétences

Catégorie Couleur de zone (Future) Exemples
Frontend Teinte bleue Vue.js, Nuxt, TypeScript, TailwindCSS
Backend Teinte verte Laravel, PHP, Node.js, MySQL
Tools Teinte jaune Git, Docker, VS Code
Soft Skills Teinte violette Communication, Gestion de projet

Dépendances

Cette story nécessite :

  • Story 1.2 : Table skills, Model Skill avec relations
  • Story 1.3 : Système i18n configuré

Cette story prépare pour :

  • Story 2.5 : Compétences cliquables → Projets liés (le clic est déjà émis)
  • Story 3.5 : Skill tree vis.js (Epic 3) - placeholder préparé

Project Structure Notes

Fichiers à créer :

api/app/Http/
├── Controllers/Api/
│   └── SkillController.php      # CRÉER
└── Resources/
    └── SkillResource.php        # CRÉER

frontend/app/
├── pages/
│   └── competences.vue          # CRÉER
├── components/
│   └── feature/
│       └── SkillCard.vue        # CRÉER
├── composables/
│   └── useFetchSkills.ts        # CRÉER
└── types/
    └── skill.ts                 # CRÉER

Fichiers à modifier :

api/app/Models/Skill.php         # AJOUTER getCurrentLevel()
api/routes/api.php               # AJOUTER route /skills
frontend/i18n/fr.json            # AJOUTER clés skills.*
frontend/i18n/en.json            # AJOUTER clés skills.*

References

  • [Source: docs/planning-artifacts/epics.md#Story-2.4]
  • [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Component-Strategy]
  • [Source: docs/planning-artifacts/ux-design-specification.md#SkillTree]

Technical Requirements

Requirement Value Source
API endpoint GET /api/skills Architecture
Groupement Par catégorie Epics
Niveau visuel Barre de progression Epics
Placeholder vis.js Desktop only Epics
Animation Stagger + respect motion NFR6

Dev Agent Record

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

Debug Log References

  • API SkillController et SkillResource existaient déjà, améliorés avec level, project_count et category_label

Completion Notes List

  • Modèle Skill amélioré : getCurrentLevel() + scope ordered par category puis display_order
  • SkillResource amélioré : ajout level, display_order, project_count
  • SkillController amélioré : groupement par catégorie avec category_label traduit
  • Type skill.ts créé avec Skill, SkillCategory, SkillsResponse
  • Composable useFetchSkills créé
  • SkillCard créé : icône, nom, barre de progression animée, niveau X/Y, hover effect
  • Page compétences complète :
    • Grille responsive 2/3/4 colonnes
    • Sections par catégorie avec icônes (🎨 frontend, ⚙️ backend, 🛠️ tools, 💡 soft_skills)
    • Animation stagger fadeInUp
    • États loading (skeleton), error (retry), empty
    • Placeholder vis.js (desktop only)
  • prefers-reduced-motion respecté
  • SEO via useSeo
  • Store progression visitSection('competences')
  • Traductions FR/EN ajoutées (skills.*)

Change Log

Date Change Author
2026-02-04 Story créée avec contexte complet SM Agent
2026-02-06 Tasks 1-9 implémentées et validées Dev Agent (Claude Opus 4.5)

File List

  • api/app/Models/Skill.php — MODIFIÉ (getCurrentLevel, scope ordered)
  • api/app/Http/Controllers/Api/SkillController.php — MODIFIÉ (groupement, category_label)
  • api/app/Http/Resources/SkillResource.php — MODIFIÉ (level, project_count)
  • frontend/app/types/skill.ts — CRÉÉ
  • frontend/app/composables/useFetchSkills.ts — CRÉÉ
  • frontend/app/components/feature/SkillCard.vue — CRÉÉ
  • frontend/app/pages/competences.vue — RÉÉCRIT
  • frontend/i18n/fr.json — MODIFIÉ (ajout skills.*)
  • frontend/i18n/en.json — MODIFIÉ (ajout skills.*)