- 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>
18 KiB
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
- 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) - And chaque compétence affiche : icône, nom traduit, niveau actuel (représentation visuelle)
- And les données sont chargées depuis l'API
/api/skillsavec le contenu traduit - And une animation d'entrée des éléments est présente (respectant
prefers-reduced-motion) - And sur desktop : préparé pour accueillir le skill tree vis.js (Epic 3)
- And sur mobile : liste groupée par catégorie avec design adapté
- And les meta tags SEO sont dynamiques pour cette page
- 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/skillsdansroutes/api.php
- Créer
-
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[]
- Créer
-
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)
- Créer
-
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
- Créer
-
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-accentpour 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ÉÉCRITfrontend/i18n/fr.json— MODIFIÉ (ajout skills.*)frontend/i18n/en.json— MODIFIÉ (ajout skills.*)