Introduction
Un portfolio d'artiste n'est pas une carte de visite. C'est une exposition vivante.
Il doit respirer. Bouger. Faire s'arrêter quelqu'un en train de scroller et lui faire ressentir quelque chose avant même la première ligne. Et derrière cette expérience, il doit rester gérable : l'artiste doit pouvoir ajouter une œuvre, mettre à jour sa biographie, ou répondre à un message sans appeler un développeur.
C'est exactement le défi que j'ai résolu en construisant Chat Rouge Art — une plateforme portfolio artiste full stack avec Laravel, Inertia.js et Vue.js. Une galerie publique qui paraît vivante, soutenue par un tableau de bord admin complet que l'artiste contrôle entièrement.
Ce guide documente l'architecture, les décisions, et les détails d'implémentation qui ont rendu cela possible.
Pourquoi Laravel Inertia.js et Vue.js
Avant d'écrire la moindre ligne de code, le choix de la stack est crucial. Pour un portfolio artiste avec dashboard admin, trois exigences dictent la décision :
La complexité backend est réelle. Authentification utilisateur, gestion des œuvres, filtrage par catégories, traitement du formulaire de contact, optimisation d'image — ce n'est pas un site statique. Il faut un vrai backend. Laravel gère tout cela élégamment avec très peu de boilerplate.
Le frontend doit être natif dans le ressenti. Transitions de page, animations fluides, filtres réactifs — une SPA Vue.js livre cette expérience. Mais construire une API séparée ajoute une surcharge qu'un développeur solo pour un client unique ne peut pas se permettre.
Inertia.js supprime cette fracture. Il relie directement le backend Laravel aux composants Vue.js sans API REST. Les contrôleurs retournent des réponses Inertia. Les composants Vue reçoivent les props directement. Le résultat : une application full stack qui se vit comme une SPA, mais architecturée comme une app serveur traditionnelle — avec toute la puissance de Laravel intacte.
1. Mise en place du projet
Commencez avec une installation Laravel neuve puis ajoutez Inertia.js avec l'adaptateur Vue.js :
composer create-project laravel/laravel chat-rouge-art
cd chat-rouge-art
composer require inertiajs/inertia-laravel
npm install @inertiajs/vue3 vue@3
npm install -D @vitejs/plugin-vue
Configurez Vite pour gérer Vue dans vite.config.js :
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: ['resources/js/app.js'],
refresh: true
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false
}
}
})
]
});
Configurez le middleware Inertia dans app/Http/Middleware/HandleInertiaRequests.php :
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user()
],
'flash' => [
'success' => session('success'),
'error' => session('error')
]
]);
}
}
Enregistrez-le dans bootstrap/app.php et créez votre vue Blade racine resources/views/app.blade.php :
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title inertia>{{ config('app.name') }}</title>
@vite(['resources/js/app.js'])
@inertiaHead
</head>
<body class="antialiased">
@inertia
</body>
</html>
Initialisez Vue avec Inertia dans resources/js/app.js :
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
createInertiaApp({
title: (title) => `${title} — Chat Rouge Art`,
resolve: (name) =>
resolvePageComponent(
`./Pages/${name}.vue`,
globalThis._importMeta_.glob('./Pages/**/*.vue')
),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
}
});
2. Architecture de base de données
Un portfolio artiste a besoin d'une base propre et extensible. Voici la structure de migration utilisée sur Chat Rouge Art :
// Artworks table
Schema::create('artworks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->string('medium')->nullable(); // Oil, watercolor, digital
$table->string('dimensions')->nullable();
$table->integer('year')->nullable();
$table->string('image_path');
$table->string('thumbnail_path')->nullable();
$table->foreignId('category_id')->constrained()->onDelete('cascade');
$table->boolean('featured')->default(false);
$table->boolean('for_sale')->default(false);
$table->decimal('price', 10, 2)->nullable();
$table->integer('sort_order')->default(0);
$table->timestamps();
});
// Categories table
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->string('cover_image')->nullable();
$table->integer('sort_order')->default(0);
$table->timestamps();
});
// Contact messages table
Schema::create('contact_messages', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->string('subject')->nullable();
$table->text('message');
$table->boolean('read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
3. Le modèle Artwork
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Str;
class Artwork extends Model
{
use HasFactory;
protected $fillable = [
'title', 'slug', 'description', 'medium',
'dimensions', 'year', 'image_path', 'thumbnail_path',
'category_id', 'featured', 'for_sale', 'price', 'sort_order'
];
protected $casts = [
'featured' => 'boolean',
'for_sale' => 'boolean',
'price' => 'decimal:2'
];
protected static function boot()
{
parent::boot();
static::creating(function ($artwork) {
$artwork->slug = Str::slug($artwork->title);
});
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function scopeFeatured($query)
{
return $query->where('featured', true);
}
public function scopeForSale($query)
{
return $query->where('for_sale', true);
}
public function getImageUrlAttribute()
{
return asset('storage/' . $this->image_path);
}
public function getThumbnailUrlAttribute()
{
return $this->thumbnail_path
? asset('storage/' . $this->thumbnail_path)
: $this->image_url;
}
}
4. Contrôleur de galerie publique
La galerie est le cœur du portfolio. Elle doit proposer filtrage, pagination et chargement rapide :
<?php
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\Category;
use Inertia\Inertia;
use Illuminate\Http\Request;
class GalleryController extends Controller
{
public function index(Request $request)
{
$categories = Category::orderBy('sort_order')->get();
$artworks = Artwork::with('category')
->when($request->category, function ($query, $category) {
$query->whereHas('category', fn($q) =>
$q->where('slug', $category)
);
})
->when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('medium', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
})
->orderBy('sort_order')
->orderBy('created_at', 'desc')
->paginate(12)
->withQueryString();
return Inertia::render('Gallery/Index', [
'artworks' => $artworks,
'categories' => $categories,
'filters' => $request->only(['category', 'search'])
]);
}
public function show(Artwork $artwork)
{
$artwork->load('category');
$related = Artwork::where('category_id', $artwork->category_id)
->where('id', '!=', $artwork->id)
->limit(4)
->get();
return Inertia::render('Gallery/Show', [
'artwork' => $artwork,
'related' => $related
]);
}
}
5. Composant Vue de galerie
<template>
<PublicLayout>
<!-- Filter Bar -->
<div class="filter-bar">
<button
v-for="category in categories"
:key="category.id"
@click="filterByCategory(category.slug)"
:class="['filter-btn', filters.category === category.slug ? 'active' : '']"
>
{{ category.name }}
</button>
<button @click="filterByCategory(null)" class="filter-btn">
All
</button>
</div>
<!-- Search -->
<div class="search-bar">
<input
v-model="searchQuery"
@input="debouncedSearch"
type="text"
placeholder="Search artworks..."
class="search-input"
/>
</div>
<!-- Artwork Grid -->
<TransitionGroup
name="gallery"
tag="div"
class="artwork-grid"
>
<div
v-for="artwork in artworks.data"
:key="artwork.id"
class="artwork-card"
@click="openArtwork(artwork)"
>
<div class="artwork-image-wrapper">
<img
:src="artwork.thumbnail_url"
:alt="artwork.title"
class="artwork-image"
loading="lazy"
/>
<div class="artwork-overlay">
<h3>{{ artwork.title }}</h3>
<p>{{ artwork.medium }} — {{ artwork.year }}</p>
<span v-if="artwork.for_sale" class="for-sale-badge">
Available
</span>
</div>
</div>
</div>
</TransitionGroup>
<!-- Pagination -->
<Pagination :links="artworks.links" />
</PublicLayout>
</template>
<script setup>
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
import { useDebounceFn } from '@vueuse/core';
import PublicLayout from '@/Layouts/PublicLayout.vue';
import Pagination from '@/Components/Pagination.vue';
const props = defineProps({
artworks: Object,
categories: Array,
filters: Object
});
const searchQuery = ref(props.filters.search || '');
const filterByCategory = (slug) => {
router.get(route('gallery.index'), {
category: slug,
search: searchQuery.value
}, {
preserveState: true,
replace: true
});
};
const debouncedSearch = useDebounceFn(() => {
router.get(route('gallery.index'), {
search: searchQuery.value,
category: props.filters.category
}, {
preserveState: true,
replace: true
});
}, 300);
const openArtwork = (artwork) => {
router.visit(route('gallery.show', artwork.slug));
};
</script>
6. Architecture du dashboard admin
Le dashboard admin est ce qui transforme ce projet en produit complet plutôt qu’en simple vitrine. L’artiste gère tout — aucun développeur requis après livraison.
Protégez toutes les routes admin avec le middleware d’authentification :
// routes/web.php
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/', [AdminController::class, 'index'])->name('dashboard');
Route::resource('artworks', AdminArtworkController::class);
Route::resource('categories', AdminCategoryController::class);
Route::get('messages', [AdminMessageController::class, 'index'])->name('messages.index');
Route::patch('messages/{message}/read', [AdminMessageController::class, 'markRead'])->name('messages.read');
});
Le contrôleur admin des œuvres gère l’upload et l’optimisation des images :
<?php
namespace App\Http\Controllers\Admin;
use App\Models\Artwork;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Intervention\Image\Facades\Image;
class AdminArtworkController extends Controller
{
public function index()
{
return Inertia::render('Admin/Artworks/Index', [
'artworks' => Artwork::with('category')
->orderBy('sort_order')
->paginate(20)
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'medium' => 'nullable|string|max:100',
'dimensions' => 'nullable|string|max:100',
'year' => 'nullable|integer|min:1900|max:' . date('Y'),
'category_id' => 'required|exists:categories,id',
'image' => 'required|image|max:10240',
'featured' => 'boolean',
'for_sale' => 'boolean',
'price' => 'nullable|numeric|min:0'
]);
if ($request->hasFile('image')) {
$image = $request->file('image');
$path = $image->store('artworks', 'public');
// Generate optimized thumbnail
$thumbnail = Image::make($image)
->fit(600, 600)
->encode('jpg', 85);
$thumbnailPath = 'artworks/thumbnails/' . basename($path);
Storage::disk('public')->put($thumbnailPath, $thumbnail);
$validated['image_path'] = $path;
$validated['thumbnail_path'] = $thumbnailPath;
}
Artwork::create($validated);
return redirect()->route('admin.artworks.index')
->with('success', 'Artwork added successfully');
}
public function destroy(Artwork $artwork)
{
Storage::disk('public')->delete($artwork->image_path);
Storage::disk('public')->delete($artwork->thumbnail_path);
$artwork->delete();
return redirect()->route('admin.artworks.index')
->with('success', 'Artwork deleted successfully');
}
}
7. Mode sombre et mode clair
Les artistes accordent une grande importance à la présentation de leurs œuvres. Le mode sombre fait ressortir les couleurs. Le mode clair met en valeur les détails. Les deux comptent.
Gérez la préférence de thème dans un composable resources/js/composables/useTheme.js :
import { ref, watch, onMounted } from 'vue';
const THEME_KEY = 'chat-rouge-theme';
export const useTheme = () => {
const isDark = ref(false);
const applyTheme = (dark) => {
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
};
const toggleTheme = () => {
isDark.value = !isDark.value;
applyTheme(isDark.value);
};
onMounted(() => {
const saved = localStorage.getItem(THEME_KEY);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark.value = saved ? saved === 'dark' : prefersDark;
applyTheme(isDark.value);
});
return { isDark, toggleTheme };
};
Utilisez-le dans votre navbar avec un bouton de bascule fluide qui mémorise la préférence entre les sessions.
8. Des animations qui rendent l’art vivant
La galerie devait donner la sensation d’entrer dans une exposition, pas de charger une page web. Trois décisions d’animation ont eu le plus d’impact :
Animations d’entrée en cascade — les œuvres apparaissent une par une au chargement, laissant à l’œil le temps de se poser avant la suivante.
Transitions de filtre fluides — quand un filtre catégorie est appliqué, les œuvres sortantes s’estompent et réduisent légèrement. Les œuvres entrantes apparaissent en fondu et augmentent légèrement. La grille ne « saute » jamais.
Révélation de l’œuvre — l’ouverture d’une œuvre déclenche une transition plein écran où l’image s’étend depuis sa position dans la grille jusqu’au viewport, en conservant la continuité visuelle.
Cela a été implémenté avec les composants Vue natifs <Transition> et <TransitionGroup>, combinés à des propriétés CSS personnalisées pour contrôler le timing.
9. Ce que l’artiste peut faire sans développeur
C’est le vrai indicateur d’un projet client réussi. Après livraison, l’artiste peut de manière autonome :
- Ajouter, modifier et supprimer des œuvres avec upload d’image
- Créer et gérer des catégories
- Marquer des œuvres comme mises en avant ou disponibles à la vente
- Définir des prix par pièce
- Lire et gérer les messages de contact des collectionneurs
- Basculer entre thème sombre et clair
- Mettre à jour biographie et informations de contact
Zéro dépendance à un développeur après le lancement. C’est ce qui rend un client suffisamment satisfait pour donner un témoignage et recommander votre travail.
Conclusion
Construire un portfolio artiste full stack est un exercice d’équilibre. La vitrine publique doit être émotionnelle, belle et rapide. Le backend doit être puissant, fiable, et invisible pour la personne qui l’utilise.
Laravel apporte la puissance backend. Inertia.js supprime la complexité API. Vue.js délivre l’expérience frontend. Ensemble, ils produisent quelque chose qu’un builder statique ou un CMS headless ne peut pas égaler — une plateforme sur mesure que le client possède totalement.
Chat Rouge Art a été mon premier projet freelance full stack réellement terminé. Il m’a donné mon premier vrai témoignage. Il a prouvé que l’alliance entre profondeur technique et sensibilité design crée un travail que les clients valorisent et retiennent.
Cette alliance est le socle de Dywan Dev.
Besoin d’un portfolio full stack, d’une plateforme métier ou d’une application web sur mesure ? Chez Dywan Dev, je construis des solutions complètes — pas seulement des sites. Lancer une conversation.
