Files
PatoisSMEH/src/views/Texts.vue
ElPoyo 10d5238f3a 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.
2026-03-02 14:05:19 +01:00

498 lines
18 KiB
Vue

<template>
<div class="min-h-screen bg-white py-8">
<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">Recherche</h1>
<p class="text-gray-600 mb-6">
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">
<!-- 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"
>
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"
>
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 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"
@change="performSearch"
type="checkbox"
class="rounded mr-2"
/>
<span class="text-sm">Seulement avec audio</span>
</label>
<button
@click="clearFilters"
class="text-sm text-gray-600 hover:text-black"
>
Effacer les filtres
</button>
</div>
</div>
</div>
<!-- Résultats -->
<div class="mb-6">
<p style="color: #6b7280" class="mb-6">
<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 résultats -->
<div class="grid gap-6">
<!-- Résultat texte -->
<div
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="navigateToItem(item)"
>
<!-- 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">
{{ item.metadata.titre_fr }}
</h2>
<p class="text-lg mb-3" style="color: #6b7280">
{{ item.metadata.titre_pt }}
</p>
<div class="flex flex-wrap gap-4 text-sm mb-4" style="color: #6b7280">
<span v-if="item.metadata.auteur">
<strong>Auteur:</strong> {{ item.metadata.auteur }}
</span>
<span v-if="item.metadata.traducteur">
<strong>Traducteur:</strong> {{ item.metadata.traducteur }}
</span>
<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(item.frenchText) }}
</p>
</div>
<div class="flex flex-col items-end ml-4">
<!-- Badges -->
<div class="flex flex-col gap-2">
<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>
Audio
</span>
</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>
<!-- Pagination -->
<div v-if="totalPages > 1" class="mt-8 flex justify-center">
<div class="flex space-x-2">
<button
@click="currentPage = Math.max(1, currentPage - 1)"
:disabled="currentPage === 1"
class="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Précédent
</button>
<span class="px-4 py-2 bg-black text-white rounded-lg">
{{ currentPage }} / {{ totalPages }}
</span>
<button
@click="currentPage = Math.min(totalPages, currentPage + 1)"
:disabled="currentPage === totalPages"
class="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Suivant
</button>
</div>
</div>
<!-- Message si aucun résultat -->
<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 résultat trouvé</h3>
<p class="text-gray-500">Essayez de modifier vos critères de recherche.</p>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchService } from '../services/searchService.js'
export default {
name: 'Texts',
setup() {
const route = useRoute()
const router = useRouter()
// État de la recherche
const searchQuery = 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 resultsPerPage = 10
// 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 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(totalResults.value / resultsPerPage)
})
const paginatedResults = computed(() => {
const start = (currentPage.value - 1) * resultsPerPage
const end = start + resultsPerPage
return allResults.value.slice(start, end)
})
// Méthodes
const loadCategories = async () => {
try {
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 catégories:', error)
}
}
const performSearch = async () => {
loading.value = true
currentPage.value = 1
// 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 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 = ''
contentType.value = 'texts' // Remettre sur textes par défaut
selectedTextCategories.value = []
selectedBlogCategories.value = []
onlyWithAudio.value = false
currentPage.value = 1
performSearch()
}
const navigateToItem = (item) => {
if (item.type === 'text') {
router.push(`/texte/${item.id}`)
} else {
router.push(`/actualites/${item.id}`)
}
}
const getTextPreview = (text) => {
if (!text) return ''
return text.split('\n').slice(0, 3).join(' ').substring(0, 200) + '...'
}
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(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 {
searchQuery,
contentType,
selectedTextCategories,
selectedBlogCategories,
onlyWithAudio,
currentPage,
textCategories,
blogCategories,
searchResults,
loading,
hasActiveFilters,
totalResults,
totalPages,
paginatedResults,
performSearch,
setContentType,
toggleTextCategory,
toggleBlogCategory,
clearFilters,
navigateToItem,
getTextPreview,
formatDate
}
}
}
</script>
<style scoped>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>