Files
Portfolio-Game/docs/implementation-artifacts/2-4-page-competences-affichage-categories.md
skycel ec1ae92799 🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 02:08:56 +01:00

16 KiB

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

Status: ready-for-dev

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

{{agent_model_name_version}}

Debug Log References

Completion Notes List

Change Log

Date Change Author
2026-02-04 Story créée avec contexte complet SM Agent

File List