feat: Refactor search functionality and integrate WordPress as Headless CMS

- Introduced a unified search service (`searchService.js`) for texts and blog posts.
- Redesigned the search page (`Texts.vue`) with improved filters and pagination.
- Enhanced navigation bar (`NavigationBar.vue`) for better user experience.
- Added WordPress integration for news articles with a dedicated service (`wordpressService.js`).
- Created a new About page (`AboutPage.vue`) detailing the association's history and values.
- Updated backend API to support new endpoints and optimizations.
- Implemented caching and performance improvements for search queries.
This commit is contained in:
ElPoyo
2026-03-02 14:05:19 +01:00
parent 03ffc846bf
commit 10d5238f3a
10 changed files with 1016 additions and 154 deletions

View File

@@ -3,51 +3,87 @@
<div class="max-w-6xl mx-auto px-4">
<!-- En-tête avec recherche -->
<div class="mb-8">
<h1 class="text-4xl font-bold text-black mb-4">Collection de Textes</h1>
<h1 class="text-4xl font-bold text-black mb-4">Recherche</h1>
<p class="text-gray-600 mb-6">
Explorez notre collection de textes en patois franco-provençal
Explorez notre collection de textes en patois et nos articles de blog
</p>
<!-- Barre de recherche avancée -->
<div class="bg-gray-50 rounded-lg p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<input
v-model="searchQuery"
@input="performSearch"
type="text"
placeholder="Rechercher dans les textes..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-black"
/>
</div>
<div>
<select
v-model="selectedCategory"
@change="performSearch"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-black"
<!-- Barre de recherche principale -->
<div class="mb-4">
<input
v-model="searchQuery"
@input="performSearch"
type="text"
placeholder="Rechercher dans les textes et articles..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-black"
/>
</div>
<!-- Filtres par type de contenu -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Type de contenu</label>
<div class="flex flex-wrap gap-2">
<button
@click="setContentType('all')"
:class="contentType === 'all' ? 'bg-black text-white' : 'bg-white text-gray-700 border border-gray-300'"
class="px-4 py-2 rounded-lg hover:shadow-sm transition-shadow"
>
<option value="">Toutes les catégories</option>
<option v-for="category in categories" :key="category" :value="category">
{{ category }}
</option>
</select>
</div>
<div>
<select
v-model="selectedDifficulty"
@change="performSearch"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-black"
Tout
</button>
<button
@click="setContentType('texts')"
:class="contentType === 'texts' ? 'bg-black text-white' : 'bg-white text-gray-700 border border-gray-300'"
class="px-4 py-2 rounded-lg hover:shadow-sm transition-shadow"
>
<option value="">Toutes les difficultés</option>
<option value="Facile">Facile</option>
<option value="Moyen">Moyen</option>
<option value="Difficile">Difficile</option>
</select>
Textes en patois
</button>
<button
@click="setContentType('blog')"
:class="contentType === 'blog' ? 'bg-black text-white' : 'bg-white text-gray-700 border border-gray-300'"
class="px-4 py-2 rounded-lg hover:shadow-sm transition-shadow"
>
Articles de blog
</button>
</div>
</div>
<!-- Filtres supplémentaires -->
<div class="flex flex-wrap gap-4 mt-4">
<!-- Filtres pour textes -->
<div v-if="contentType === 'texts' || contentType === 'all'" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Catégories de textes</label>
<div class="flex flex-wrap gap-2">
<button
v-for="category in textCategories"
:key="category"
@click="toggleTextCategory(category)"
:class="selectedTextCategories.includes(category) ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 border border-gray-300'"
class="px-3 py-1 rounded-full text-sm hover:shadow-sm transition-shadow capitalize"
>
{{ category }}
</button>
</div>
</div>
<!-- Filtres pour blog -->
<div v-if="contentType === 'blog' || contentType === 'all'" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Catégories de blog</label>
<div class="flex flex-wrap gap-2">
<button
v-for="category in blogCategories"
:key="category.id"
@click="toggleBlogCategory(category.id)"
:class="selectedBlogCategories.includes(category.id) ? 'bg-green-600 text-white' : 'bg-white text-gray-700 border border-gray-300'"
class="px-3 py-1 rounded-full text-sm hover:shadow-sm transition-shadow"
>
{{ category.name }}
<span v-if="category.count" class="ml-1 opacity-75">({{ category.count }})</span>
</button>
</div>
</div>
<!-- Filtres supplémentaires pour textes -->
<div v-if="contentType === 'texts' || contentType === 'all'" class="flex flex-wrap gap-4">
<label class="flex items-center">
<input
v-model="onlyWithAudio"
@@ -70,59 +106,73 @@
<!-- Résultats -->
<div class="mb-6">
<p style="color: #6b7280" class="mb-6">
{{ filteredTexts.length }} texte(s) trouvé(s)
<span v-if="searchQuery || selectedCategory || selectedDifficulty || onlyWithAudio">
pour vos critères de recherche
<span v-if="loading" class="inline-flex items-center">
<svg class="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Chargement...
</span>
<span v-else>
{{ totalResults }} résultat(s) trouvé(s)
<span v-if="hasActiveFilters">
pour vos critères de recherche
</span>
</span>
</p>
</div>
<!-- Liste des textes -->
<!-- Liste des résultats -->
<div class="grid gap-6">
<!-- Résultat texte -->
<div
v-for="text in paginatedTexts"
:key="text.id"
v-for="item in paginatedResults"
:key="`${item.type}-${item.id}`"
class="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow cursor-pointer"
@click="$router.push(`/texte/${text.id}`)"
@click="navigateToItem(item)"
>
<div class="flex justify-between items-start">
<!-- Badge de type -->
<div class="mb-2">
<span
:class="item.type === 'text' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'"
class="px-2 py-1 rounded-full text-xs font-medium"
>
{{ item.type === 'text' ? 'Texte en patois' : 'Article de blog' }}
</span>
</div>
<!-- Contenu pour texte -->
<div v-if="item.type === 'text'" class="flex justify-between items-start">
<div class="flex-1">
<h2 class="text-xl font-semibold text-black mb-2">
{{ text.metadata.titre_fr }}
{{ item.metadata.titre_fr }}
</h2>
<p class="text-lg mb-3" style="color: #6b7280">
{{ text.metadata.titre_pt }}
{{ item.metadata.titre_pt }}
</p>
<div class="flex flex-wrap gap-4 text-sm mb-4" style="color: #6b7280">
<span v-if="text.metadata.auteur">
<strong>Auteur:</strong> {{ text.metadata.auteur }}
<span v-if="item.metadata.auteur">
<strong>Auteur:</strong> {{ item.metadata.auteur }}
</span>
<span v-if="text.metadata.traducteur">
<strong>Traducteur:</strong> {{ text.metadata.traducteur }}
<span v-if="item.metadata.traducteur">
<strong>Traducteur:</strong> {{ item.metadata.traducteur }}
</span>
<span v-if="text.metadata.categorie">
<strong>Catégorie:</strong> {{ text.metadata.categorie }}
<span v-if="item.metadata.categorie" class="capitalize">
<strong>Catégorie:</strong> {{ item.metadata.categorie }}
</span>
</div>
<!-- Aperçu du texte -->
<p class="text-sm line-clamp-3" style="color: #6b7280">
{{ getTextPreview(text.frenchText) }}
{{ getTextPreview(item.frenchText) }}
</p>
</div>
<div class="flex flex-col items-end ml-4">
<!-- Badges -->
<div class="flex flex-col gap-2">
<span
v-if="text.metadata.difficulte"
class="px-2 py-1 rounded-full text-xs"
:class="getDifficultyClass(text.metadata.difficulte)"
>
{{ text.metadata.difficulte }}
</span>
<span v-if="text.hasAudio" class="flex items-center text-xs" style="color: #6b7280">
<span v-if="item.hasAudio" class="flex items-center text-xs" style="color: #6b7280">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v6.114A4.369 4.369 0 005 11c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.369 4.369 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z"/>
</svg>
@@ -131,6 +181,32 @@
</div>
</div>
</div>
<!-- Contenu pour post -->
<div v-else class="flex justify-between items-start">
<div class="flex-1">
<h2 class="text-xl font-semibold text-black mb-2" v-html="item.title"></h2>
<div class="flex flex-wrap gap-4 text-sm mb-4" style="color: #6b7280">
<span v-if="item.author">
<strong>Auteur:</strong> {{ item.author }}
</span>
<span v-if="item.date">
<strong>Date:</strong> {{ formatDate(item.date) }}
</span>
<span v-if="item.categories && item.categories.length">
<strong>Catégories:</strong> {{ item.categories.join(', ') }}
</span>
</div>
<!-- Aperçu du post -->
<div class="text-sm line-clamp-3" style="color: #6b7280" v-html="item.excerpt"></div>
</div>
<div v-if="item.featuredImage" class="ml-4">
<img :src="item.featuredImage" :alt="item.featuredImageAlt" class="w-32 h-32 object-cover rounded-lg" />
</div>
</div>
</div>
</div>
@@ -160,11 +236,11 @@
</div>
<!-- Message si aucun résultat -->
<div v-if="filteredTexts.length === 0" class="text-center py-12">
<div v-if="!loading && totalResults === 0" class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun texte trouvé</h3>
<h3 class="text-lg font-medium text-gray-900 mb-2">Aucun résultat trouvé</h3>
<p class="text-gray-500">Essayez de modifier vos critères de recherche.</p>
</div>
</div>
@@ -173,82 +249,177 @@
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { textService } from '../services/textService.js'
import { useRoute, useRouter } from 'vue-router'
import { searchService } from '../services/searchService.js'
export default {
name: 'Texts',
setup() {
const route = useRoute()
const router = useRouter()
const allTexts = ref([])
// État de la recherche
const searchQuery = ref('')
const selectedCategory = ref('')
const selectedDifficulty = ref('')
const contentType = ref('texts') // 'all', 'texts', 'blog' - Par défaut sur textes
const selectedTextCategories = ref([])
const selectedBlogCategories = ref([])
const onlyWithAudio = ref(false)
const currentPage = ref(1)
const textsPerPage = 10
const resultsPerPage = 10
const categories = computed(() => {
const cats = new Set()
allTexts.value.forEach(text => {
if (text.metadata.categorie) {
cats.add(text.metadata.categorie)
}
})
return Array.from(cats).sort()
// Résultats
const searchResults = ref({ texts: [], posts: [], total: 0 })
const textCategories = ref([])
const blogCategories = ref([])
const loading = ref(true) // Commencer en mode chargement
let searchVersion = 0 // Pour éviter les race conditions
// Calculs
const hasActiveFilters = computed(() => {
return searchQuery.value.trim() !== '' ||
selectedTextCategories.value.length > 0 ||
selectedBlogCategories.value.length > 0 ||
onlyWithAudio.value ||
contentType.value !== 'all'
})
const filteredTexts = computed(() => {
// Ne pas filtrer localement - l'API backend s'en charge
return allTexts.value
const allResults = computed(() => {
const results = []
if (contentType.value === 'all' || contentType.value === 'texts') {
results.push(...searchResults.value.texts)
}
if (contentType.value === 'all' || contentType.value === 'blog') {
results.push(...searchResults.value.posts)
}
return results
})
const totalResults = computed(() => allResults.value.length)
const totalPages = computed(() => {
return Math.ceil(filteredTexts.value.length / textsPerPage)
return Math.ceil(totalResults.value / resultsPerPage)
})
const paginatedTexts = computed(() => {
const start = (currentPage.value - 1) * textsPerPage
const end = start + textsPerPage
return filteredTexts.value.slice(start, end)
const paginatedResults = computed(() => {
const start = (currentPage.value - 1) * resultsPerPage
const end = start + resultsPerPage
return allResults.value.slice(start, end)
})
const loadTexts = async () => {
// Méthodes
const loadCategories = async () => {
try {
// Utiliser l'API de recherche avec les filtres actuels
const texts = await textService.searchTexts(searchQuery.value, {
category: selectedCategory.value,
difficulty: selectedDifficulty.value,
onlyWithAudio: onlyWithAudio.value
})
allTexts.value = texts
const [textCats, blogCats] = await Promise.all([
searchService.getTextCategories(),
searchService.getBlogCategories()
])
textCategories.value = textCats
blogCategories.value = blogCats
} catch (error) {
console.error('Erreur lors du chargement des textes:', error)
// Fallback : charger tous les textes si la recherche échoue
try {
const allTextsData = await textService.loadAllTexts()
allTexts.value = allTextsData
} catch (fallbackError) {
console.error('Erreur fallback:', fallbackError)
allTexts.value = []
}
console.error('Erreur lors du chargement des catégories:', error)
}
}
const performSearch = async () => {
loading.value = true
currentPage.value = 1
await loadTexts()
// Incrémenter la version de recherche pour éviter les race conditions
searchVersion++
const currentVersion = searchVersion
try {
const filters = {
type: contentType.value,
onlyWithAudio: onlyWithAudio.value
}
// Ajouter les catégories de textes si sélectionnées
if (selectedTextCategories.value.length > 0) {
// Pour l'instant, on ne peut filtrer que par une catégorie via l'API
// On prend la première sélectionnée
filters.textCategory = selectedTextCategories.value[0]
}
// Ajouter les catégories de blog si sélectionnées
if (selectedBlogCategories.value.length > 0) {
filters.blogCategories = selectedBlogCategories.value
}
const results = await searchService.search(searchQuery.value, filters)
// Vérifier que cette recherche est toujours la plus récente
if (currentVersion !== searchVersion) {
console.log('Recherche obsolète ignorée')
return
}
// Filtrage côté client pour les catégories multiples de textes
if (selectedTextCategories.value.length > 1) {
results.texts = results.texts.filter(text =>
selectedTextCategories.value.includes(text.metadata.categorie)
)
}
searchResults.value = results
} catch (error) {
console.error('Erreur lors de la recherche:', error)
// Ne mettre à jour que si c'est toujours la recherche actuelle
if (currentVersion === searchVersion) {
searchResults.value = { texts: [], posts: [], total: 0 }
}
} finally {
// Ne mettre loading à false que si c'est toujours la recherche actuelle
if (currentVersion === searchVersion) {
loading.value = false
}
}
}
const clearFilters = async () => {
const setContentType = (type) => {
contentType.value = type
performSearch()
}
const toggleTextCategory = (category) => {
const index = selectedTextCategories.value.indexOf(category)
if (index > -1) {
selectedTextCategories.value.splice(index, 1)
} else {
selectedTextCategories.value.push(category)
}
performSearch()
}
const toggleBlogCategory = (categoryId) => {
const index = selectedBlogCategories.value.indexOf(categoryId)
if (index > -1) {
selectedBlogCategories.value.splice(index, 1)
} else {
selectedBlogCategories.value.push(categoryId)
}
performSearch()
}
const clearFilters = () => {
searchQuery.value = ''
selectedCategory.value = ''
selectedDifficulty.value = ''
contentType.value = 'texts' // Remettre sur textes par défaut
selectedTextCategories.value = []
selectedBlogCategories.value = []
onlyWithAudio.value = false
currentPage.value = 1
// IMPORTANT: Recharger via l'API après avoir vidé les filtres
await loadTexts()
performSearch()
}
const navigateToItem = (item) => {
if (item.type === 'text') {
router.push(`/texte/${item.id}`)
} else {
router.push(`/actualites/${item.id}`)
}
}
const getTextPreview = (text) => {
@@ -256,46 +427,61 @@ export default {
return text.split('\n').slice(0, 3).join(' ').substring(0, 200) + '...'
}
const getDifficultyClass = (difficulty) => {
switch (difficulty?.toLowerCase()) {
case 'facile':
return 'bg-green-100 text-green-800'
case 'moyen':
case 'moyenne':
return 'bg-yellow-100 text-yellow-800'
case 'difficile':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// Initialiser la recherche depuis l'URL
watch(() => route.query.search, (newSearch) => {
if (newSearch) {
searchQuery.value = newSearch
// Si recherche depuis la navbar, chercher dans tout
contentType.value = 'all'
performSearch()
}
}, { immediate: true })
onMounted(() => {
loadTexts()
onMounted(async () => {
loading.value = true
try {
await loadCategories()
if (!route.query.search) {
// Si pas de recherche URL, rester sur textes par défaut
await performSearch()
}
} finally {
loading.value = false
}
})
return {
allTexts,
searchQuery,
selectedCategory,
selectedDifficulty,
contentType,
selectedTextCategories,
selectedBlogCategories,
onlyWithAudio,
currentPage,
categories,
filteredTexts,
textCategories,
blogCategories,
searchResults,
loading,
hasActiveFilters,
totalResults,
totalPages,
paginatedTexts,
paginatedResults,
performSearch,
setContentType,
toggleTextCategory,
toggleBlogCategory,
clearFilters,
navigateToItem,
getTextPreview,
getDifficultyClass
formatDate
}
}
}