# 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
<?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
<?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()),
        ];
    }
}
```

```php
// 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;
}
```

```php
// api/routes/api.php
Route::get('/skills', [SkillController::class, 'index']);
```

### Composable useFetchSkills

```typescript
// 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

```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

```vue
<!-- 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

```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 :**
```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 :**
```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

