Construire un portfolio open-source moderne avec Nuxt 3 — thèmes CSS, données JSON, SEO automatique, et déploiement Docker.
Trois pages, deux composants globaux. On les construit une par une.
Le header est simple : logo à gauche, liens de navigation à droite, fond semi-transparent.
<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.
La page d'accueil affiche : avatar, nom, titre, bio, liens sociaux, et les sections expériences/compétences/éducation.
<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>La page projets a deux interactions : filtrer par catégorie, ouvrir un modal de détail.
<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.
<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.