iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
Vue · Intermédiaire

OpenFolio

Construire un portfolio open-source moderne avec Nuxt 3 — thèmes CSS, données JSON, SEO automatique, et déploiement Docker.

Nuxt 3Vue 3TypeScriptTailwind CSSDocker
GitHub
01Setup et configuration02Structure et routing Nuxt 303Données JSON et composables04Système de thèmes CSS05Pages et composants06SEO et performances07Déploiement Docker et Vercel
Chapitre 5·30 min

Pages et composants

Trois pages, deux composants globaux. On les construit une par une.

Header.vue

Le header est simple : logo à gauche, liens de navigation à droite, fond semi-transparent.

components/Header.vue
<template>
  <nav class="header">
    <NuxtLink to="/" class="logo">OpenFolio</NuxtLink>
    <div class="nav-links">
      <NuxtLink to="/" class="nav-link">Profil</NuxtLink>
      <NuxtLink to="/projets" class="nav-link">Projets</NuxtLink>
      <NuxtLink to="/contact" class="nav-link">Contact</NuxtLink>
    </div>
  </nav>
</template>
 
<style scoped>
.header {
  position: fixed;
  top: 0;
  width: 100%;
  background: rgba(var(--bg-primary-rgb), 0.8);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 2rem;
}
 
.nav-link {
  color: var(--text-secondary);
  transition: color 0.2s;
}
.nav-link:hover,
.router-link-active {
  color: var(--color-primary);
}
</style>

NuxtLink génère une balise <a> avec la classe router-link-active sur le lien de la page courante — pas besoin de JavaScript pour gérer l'état actif.

Page index.vue — profil

La page d'accueil affiche : avatar, nom, titre, bio, liens sociaux, et les sections expériences/compétences/éducation.

pages/index.vue
<script setup lang="ts">
const { data: profile } = await useJson<Profile>('/data/profile.json')
const { data: experiences } = await useJson<Experience[]>('/data/experiences.json')
const { data: skills } = await useJson<SkillCategory[]>('/data/skills.json')
const { data: education } = await useJson<Education[]>('/data/education.json')
</script>
 
<template>
  <main class="profile-page">
    <!-- Hero -->
    <section class="hero">
      <img :src="profile?.avatar" :alt="profile?.name" class="avatar" />
      <h1>{{ profile?.name }}</h1>
      <p class="title">{{ profile?.title }}</p>
      <p class="bio">{{ profile?.bio }}</p>
 
      <!-- Badge disponible -->
      <span v-if="profile?.available" class="badge-available">
        Disponible
      </span>
 
      <!-- Liens sociaux -->
      <div class="social-links">
        <a
          v-for="link in profile?.social"
          :key="link.type"
          :href="link.url"
          target="_blank"
          rel="noopener noreferrer"
          class="social-link"
        >
          <Icon :name="`mdi:${link.type}`" />
          {{ link.label }}
        </a>
      </div>
    </section>
 
    <!-- Expériences -->
    <section class="timeline">
      <h2>Expériences</h2>
      <div
        v-for="exp in experiences"
        :key="exp.company"
        class="timeline-item"
        :class="{ current: exp.current }"
      >
        <h3>{{ exp.role }} — {{ exp.company }}</h3>
        <span class="period">{{ exp.period }}</span>
        <p>{{ exp.description }}</p>
      </div>
    </section>
 
    <!-- Compétences -->
    <section class="skills">
      <h2>Compétences</h2>
      <div v-for="cat in skills" :key="cat.category">
        <h3>{{ cat.category }}</h3>
        <div v-for="skill in cat.items" :key="skill.name" class="skill-bar">
          <span>{{ skill.name }}</span>
          <div class="bar">
            <div class="fill" :style="{ width: skill.level + '%' }" />
          </div>
        </div>
      </div>
    </section>
  </main>
</template>

Page projets.vue — filtres et modal

La page projets a deux interactions : filtrer par catégorie, ouvrir un modal de détail.

pages/projets.vue
<script setup lang="ts">
const { data: projects } = await useJson<Project[]>('/data/projects.json')
 
const activeFilter = ref('Tous')
 
const categories = computed(() =>
  ['Tous', ...new Set(projects.value?.map(p => p.category) ?? [])]
)
 
const filtered = computed(() =>
  activeFilter.value === 'Tous'
    ? projects.value ?? []
    : projects.value?.filter(p => p.category === activeFilter.value) ?? []
)
 
const selectedProject = ref<Project | null>(null)
const openModal = (project: Project) => { selectedProject.value = project }
const closeModal = () => { selectedProject.value = null }
</script>
 
<template>
  <!-- Filtres -->
  <div class="filters">
    <button
      v-for="cat in categories"
      :key="cat"
      :class="['filter-btn', { active: activeFilter === cat }]"
      @click="activeFilter = cat"
    >
      {{ cat }}
    </button>
  </div>
 
  <!-- Grille -->
  <TransitionGroup name="grid" tag="div" class="projects-grid">
    <div
      v-for="project in filtered"
      :key="project.id"
      class="project-card"
      @click="openModal(project)"
    >
      <img :src="project.image" :alt="project.title" loading="lazy" />
      <h3>{{ project.title }}</h3>
      <span :class="`badge badge-${project.status}`">{{ project.status }}</span>
    </div>
  </TransitionGroup>
 
  <!-- Modal via Teleport -->
  <Teleport to="body">
    <div v-if="selectedProject" class="modal-overlay" @click.self="closeModal">
      <div class="modal">
        <h2>{{ selectedProject.title }}</h2>
        <p>{{ selectedProject.description }}</p>
        <div class="tech-tags">
          <span v-for="tech in selectedProject.technologies" :key="tech">{{ tech }}</span>
        </div>
        <a :href="selectedProject.github" target="_blank" @click.stop>GitHub</a>
      </div>
    </div>
  </Teleport>
</template>

Teleport to="body" sort le modal du flow du DOM — il s'affiche par-dessus tout, sans problème de z-index ou overflow: hidden hérité.

@click.self sur l'overlay ferme le modal seulement si on clique sur le fond, pas sur le contenu.

Page contact.vue

pages/contact.vue
<script setup lang="ts">
const { data: profile } = await useJson<Profile>('/data/profile.json')
 
const form = reactive({ name: '', email: '', message: '' })
const sent = ref(false)
 
function submit() {
  // OpenFolio est statique : pas de backend.
  // Brancher Formspree, Resend, ou EmailJS ici.
  console.log('Formulaire soumis', form)
  sent.value = true
}
</script>
 
<template>
  <main class="contact-page">
    <h1>Contact</h1>
 
    <div v-if="sent" class="success">Message envoyé.</div>
 
    <form v-else @submit.prevent="submit" class="contact-form">
      <input v-model="form.name" type="text" placeholder="Votre nom" required />
      <input v-model="form.email" type="email" placeholder="Votre email" required />
      <textarea v-model="form.message" placeholder="Votre message" required />
      <button type="submit">Envoyer</button>
    </form>
  </main>
</template>

OpenFolio est 100% statique — il n'a pas de serveur pour traiter les emails. Pour une vraie implémentation, on branche Formspree (gratuit jusqu'à 50 soumissions/mois) ou Resend côté Nuxt server routes.

Précédent
Système de thèmes CSS
Suivant
SEO et performances

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main