Compare commits

..

1 Commits

Author SHA1 Message Date
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
10 changed files with 1016 additions and 154 deletions

View File

@@ -270,7 +270,10 @@ class TextService {
// Filtres // Filtres
if (filters.category) { if (filters.category) {
results = results.filter(text => text.metadata.categorie === filters.category) const categoryLower = filters.category.toLowerCase()
results = results.filter(text =>
text.metadata.categorie?.toLowerCase() === categoryLower
)
} }
if (filters.difficulty) { if (filters.difficulty) {
@@ -285,7 +288,7 @@ class TextService {
} }
/** /**
* NOUVEAU : Obtient toutes les catégories disponibles * NOUVEAU : Obtient toutes les catégories disponibles (normalisées en minuscules)
*/ */
async getCategories() { async getCategories() {
const allTexts = await this.scanTexts() const allTexts = await this.scanTexts()
@@ -293,7 +296,8 @@ class TextService {
for (const text of allTexts) { for (const text of allTexts) {
if (text.metadata.categorie) { if (text.metadata.categorie) {
categories.add(text.metadata.categorie) // Normaliser en minuscules
categories.add(text.metadata.categorie.toLowerCase())
} }
} }
@@ -404,6 +408,19 @@ app.get('/api/stats', async (req, res) => {
} }
}) })
/**
* GET /api/categories - Liste des catégories disponibles
*/
app.get('/api/categories', async (req, res) => {
try {
const categories = await textService.getCategories()
res.json(categories)
} catch (error) {
console.error('Erreur GET /api/categories:', error)
res.status(500).json({ error: 'Erreur lors du chargement des catégories' })
}
})
/** /**
* GET /api/texts/:id/audio - Fichier audio * GET /api/texts/:id/audio - Fichier audio
*/ */
@@ -439,6 +456,7 @@ app.listen(PORT, () => {
console.log(` GET /api/texts/:id - Détails d'un texte`) console.log(` GET /api/texts/:id - Détails d'un texte`)
console.log(` GET /api/random - Texte aléatoire`) console.log(` GET /api/random - Texte aléatoire`)
console.log(` GET /api/stats - Statistiques`) console.log(` GET /api/stats - Statistiques`)
console.log(` GET /api/categories - Liste des catégories`)
console.log(` GET /api/texts/:id/audio - Fichier audio`) console.log(` GET /api/texts/:id/audio - Fichier audio`)
}) })

View File

@@ -1,6 +1,23 @@
<template> <template>
<footer class="bg-gray-50 border-t border-gray-200 mt-auto"> <footer class="bg-gray-50 border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Liens de navigation -->
<nav class="flex justify-center flex-wrap gap-6 mb-6 text-base">
<router-link to="/" class="text-gray-600 hover:text-black transition-colors">
Accueil
</router-link>
<router-link to="/textes" class="text-gray-600 hover:text-black transition-colors">
Textes
</router-link>
<router-link to="/actualites" class="text-gray-600 hover:text-black transition-colors">
Actualités
</router-link>
<router-link to="/a-propos" class="text-gray-600 hover:text-black transition-colors font-medium">
À propos
</router-link>
</nav>
<!-- Informations principales -->
<div class="text-center"> <div class="text-center">
<p class="text-gray-600 mb-2"> <p class="text-gray-600 mb-2">
Site développé avec soin par Paul Fournel Site développé avec soin par Paul Fournel

View File

@@ -148,6 +148,8 @@ export default {
name: 'Texts', name: 'Texts',
query: { search: searchQuery.value.trim() } query: { search: searchQuery.value.trim() }
}) })
// Vider la barre de recherche après navigation
searchQuery.value = ''
mobileMenuOpen.value = false mobileMenuOpen.value = false
} }
} }

View File

@@ -4,6 +4,7 @@ import Texts from '../views/Texts.vue'
import TextReader from '../views/TextReader.vue' import TextReader from '../views/TextReader.vue'
import News from '../views/News.vue' import News from '../views/News.vue'
import NewsArticle from '../views/NewsArticle.vue' import NewsArticle from '../views/NewsArticle.vue'
import AboutPage from '../views/AboutPage.vue'
import { textService } from '../services/textService.js' import { textService } from '../services/textService.js'
const routes = [ const routes = [
@@ -38,12 +39,21 @@ const routes = [
name: 'NewsArticle', name: 'NewsArticle',
component: NewsArticle, component: NewsArticle,
props: true props: true
},
{
path: '/a-propos',
name: 'AboutPage',
component: AboutPage
} }
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes,
scrollBehavior(to, from, savedPosition) {
// Toujours scroller en haut lors d'un changement de page
return { top: 0, behavior: 'smooth' }
}
}) })
export default router export default router

View File

@@ -0,0 +1,291 @@
import { textService } from './textService.js'
import { wordpressService } from './wordpressService.js'
/**
* Service de recherche unifié qui combine les textes et les posts de blog
*/
export class SearchService {
constructor() {
this.cache = new Map()
this.CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
}
/**
* Effectue une recherche unifiée dans les textes et les posts de blog
* @param {string} query - Terme de recherche
* @param {Object} filters - Filtres de recherche
* @param {string} filters.type - Type de contenu: 'texts', 'blog', 'all'
* @param {string} filters.textCategory - Catégorie de texte
* @param {Array<number>} filters.blogCategories - IDs des catégories de blog
* @param {boolean} filters.onlyWithAudio - Seulement les textes avec audio
* @returns {Promise<Object>} - Résultats de recherche
*/
async search(query = '', filters = {}) {
const normalizedQuery = query.trim().toLowerCase()
const type = filters.type || 'all'
try {
const results = {
texts: [],
posts: [],
total: 0,
query: normalizedQuery,
filters: filters
}
// Rechercher dans les textes si nécessaire
if (type === 'texts' || type === 'all') {
results.texts = await this.searchTexts(normalizedQuery, filters)
}
// Rechercher dans les posts de blog si nécessaire
if (type === 'blog' || type === 'all') {
results.posts = await this.searchPosts(normalizedQuery, filters)
}
results.total = results.texts.length + results.posts.length
return results
} catch (error) {
console.error('Erreur lors de la recherche:', error)
throw error
}
}
/**
* Recherche dans les textes
* @param {string} query - Terme de recherche
* @param {Object} filters - Filtres
* @returns {Promise<Array>} - Textes correspondants
*/
async searchTexts(query, filters) {
try {
// Utiliser l'API de recherche des textes
const textFilters = {}
if (filters.textCategory) {
textFilters.category = filters.textCategory
}
if (filters.onlyWithAudio) {
textFilters.onlyWithAudio = true
}
let texts = await textService.searchTexts(query, textFilters)
// Filtrage côté client pour la recherche locale si nécessaire
if (query) {
texts = texts.filter(text =>
this.matchesTextSearch(text, query)
)
}
// Normaliser les catégories (insensible à la casse)
texts = texts.map(text => ({
...text,
type: 'text',
metadata: {
...text.metadata,
categorie: text.metadata.categorie?.toLowerCase()
}
}))
return texts
} catch (error) {
console.error('Erreur lors de la recherche de textes:', error)
return []
}
}
/**
* Recherche dans les posts de blog
* @param {string} query - Terme de recherche
* @param {Object} filters - Filtres
* @returns {Promise<Array>} - Posts correspondants
*/
async searchPosts(query, filters) {
try {
let allPosts = []
// Si des catégories spécifiques sont demandées
if (filters.blogCategories && filters.blogCategories.length > 0) {
// Récupérer les posts pour chaque catégorie
for (const categoryId of filters.blogCategories) {
try {
const { posts } = await wordpressService.getPostsByCategory(categoryId, 100, 1)
allPosts = allPosts.concat(posts)
} catch (error) {
console.warn(`Erreur lors de la récupération des posts de la catégorie ${categoryId}:`, error)
}
}
} else {
// Récupérer tous les posts de toutes les catégories
const categories = await wordpressService.getCategories()
for (const category of categories) {
try {
const { posts } = await wordpressService.getPostsByCategory(category.id, 100, 1)
allPosts = allPosts.concat(posts)
} catch (error) {
console.warn(`Erreur lors de la récupération des posts de la catégorie ${category.name}:`, error)
}
}
}
// Filtrer par recherche textuelle si nécessaire
if (query) {
allPosts = allPosts.filter(post =>
this.matchesPostSearch(post, query)
)
}
// Dédupliquer les posts
const uniquePosts = Array.from(
new Map(allPosts.map(post => [post.id, post])).values()
)
// Ajouter le type pour différenciation
return uniquePosts.map(post => ({
...post,
type: 'post'
}))
} catch (error) {
console.error('Erreur lors de la recherche de posts:', error)
return []
}
}
/**
* Vérifie si un texte correspond à la recherche
* @param {Object} text - Texte à vérifier
* @param {string} query - Terme de recherche
* @returns {boolean} - true si correspond
*/
matchesTextSearch(text, query) {
const lowerQuery = query.toLowerCase()
return (
text.metadata.titre_fr?.toLowerCase().includes(lowerQuery) ||
text.metadata.titre_pt?.toLowerCase().includes(lowerQuery) ||
text.metadata.auteur?.toLowerCase().includes(lowerQuery) ||
text.metadata.traducteur?.toLowerCase().includes(lowerQuery) ||
text.metadata.categorie?.toLowerCase().includes(lowerQuery) ||
text.frenchText?.toLowerCase().includes(lowerQuery) ||
text.patoisText?.toLowerCase().includes(lowerQuery)
)
}
/**
* Vérifie si un post correspond à la recherche
* @param {Object} post - Post à vérifier
* @param {string} query - Terme de recherche
* @returns {boolean} - true si correspond
*/
matchesPostSearch(post, query) {
const lowerQuery = query.toLowerCase()
// Extraire le texte HTML (simplifié)
const stripHtml = (html) => {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
return (
post.title?.toLowerCase().includes(lowerQuery) ||
stripHtml(post.content || '').toLowerCase().includes(lowerQuery) ||
stripHtml(post.excerpt || '').toLowerCase().includes(lowerQuery) ||
post.author?.toLowerCase().includes(lowerQuery) ||
post.categories?.some(cat => cat.toLowerCase().includes(lowerQuery)) ||
post.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
)
}
/**
* Récupère toutes les catégories de textes disponibles
* @returns {Promise<Array<string>>} - Liste des catégories
*/
async getTextCategories() {
try {
// Essayer de récupérer depuis l'API si disponible
try {
const categories = await textService.fetchAPI('/categories')
return categories.sort()
} catch (apiError) {
console.warn('API categories non disponible, fallback sur scan local')
}
// Fallback : scanner tous les textes
const texts = await textService.loadAllTexts()
const categories = new Set()
texts.forEach(text => {
if (text.metadata.categorie) {
// Normaliser en minuscules
categories.add(text.metadata.categorie.toLowerCase())
}
})
return Array.from(categories).sort()
} catch (error) {
console.error('Erreur lors de la récupération des catégories de textes:', error)
return []
}
}
/**
* Récupère toutes les catégories de blog disponibles
* @returns {Promise<Array>} - Liste des catégories de blog
*/
async getBlogCategories() {
try {
const categories = await wordpressService.getCategories()
// Récupérer également les sous-catégories
const allCategories = [...categories]
for (const category of categories) {
const subcategories = await this.getSubcategories(category.id)
allCategories.push(...subcategories)
}
return allCategories
} catch (error) {
console.error('Erreur lors de la récupération des catégories de blog:', error)
return []
}
}
/**
* Récupère les sous-catégories d'une catégorie
* @param {number} parentId - ID de la catégorie parent
* @returns {Promise<Array>} - Sous-catégories
*/
async getSubcategories(parentId) {
try {
const response = await fetch(
`https://admin-afpl.federation-ouest-francoprovencal.fr/wp-json/wp/v2/categories?parent=${parentId}&per_page=100`
)
if (!response.ok) {
return []
}
const subcategories = await response.json()
return subcategories
.filter(cat => cat.slug !== 'uncategorized')
.map(cat => wordpressService.formatCategory(cat))
} catch (error) {
console.warn(`Erreur lors de la récupération des sous-catégories de ${parentId}:`, error)
return []
}
}
/**
* Vide le cache
*/
clearCache() {
this.cache.clear()
textService.clearCache()
}
}
// Instance singleton
export const searchService = new SearchService()

View File

@@ -2,7 +2,7 @@
* Service pour communiquer avec l'API backend des textes * Service pour communiquer avec l'API backend des textes
*/ */
const API_BASE_URL = import.meta.env.VITE_TEXTS_API_URL || 'https://patois.lagaudiere.uk/api' || 'http://localhost:3000/api' const API_BASE_URL = import.meta.env.VITE_TEXTS_API_URL || 'http://localhost:3000/api'
export class TextService { export class TextService {
constructor() { constructor() {

View File

@@ -126,6 +126,31 @@ export const wordpressService = {
} }
}, },
/**
* Récupère une page WordPress par son slug
* @param {string} slug - Slug de la page (ex: 'a-propos')
* @returns {Promise<Object>} - Page formatée
*/
async getPageBySlug(slug) {
try {
const response = await fetch(`${API_BASE_URL}/pages?slug=${slug}&_embed`)
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`)
}
const pages = await response.json()
if (!pages.length) {
throw new Error(`Page "${slug}" non trouvée`)
}
return this.formatPage(pages[0])
} catch (error) {
console.error(`Erreur lors de la récupération de la page ${slug}:`, error)
throw error
}
},
/** /**
* Formate une catégorie WordPress * Formate une catégorie WordPress
* @param {Object} category - Catégorie brute de l'API WordPress * @param {Object} category - Catégorie brute de l'API WordPress
@@ -165,6 +190,27 @@ export const wordpressService = {
} }
}, },
/**
* Formate une page WordPress pour l'utilisation dans l'application
* @param {Object} page - Page brute de l'API WordPress
* @returns {Object} - Page formatée
*/
formatPage(page) {
return {
id: page.id,
title: page.title.rendered,
content: page.content.rendered,
excerpt: page.excerpt.rendered,
date: page.date,
modified: page.modified,
slug: page.slug,
link: page.link,
author: page._embedded?.author?.[0]?.name || 'Auteur inconnu',
featuredImage: page._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
featuredImageAlt: page._embedded?.['wp:featuredmedia']?.[0]?.alt_text || ''
}
},
/** /**
* Formate une date pour l'affichage * Formate une date pour l'affichage
* @param {string} dateString - Date au format ISO * @param {string} dateString - Date au format ISO

159
src/views/AboutPage.vue Normal file
View File

@@ -0,0 +1,159 @@
<template>
<div class="min-h-screen bg-white">
<!-- Section héro -->
<section class="py-20 px-4">
<div class="max-w-4xl mx-auto text-center">
<h1 class="text-6xl font-bold text-black mb-6">
À propos de l'association
</h1>
<p class="text-2xl mb-8 leading-relaxed" style="color: #6b7280">
Découvrez l'histoire et les valeurs de notre association dédiée à la préservation
du patrimoine linguistique franco-provençal.
</p>
</div>
</section>
<!-- Section avec image -->
<section class="py-20 bg-gradient-to-b from-gray-50 to-white">
<div class="max-w-5xl mx-auto px-4">
<div class="grid md:grid-cols-2 gap-12 items-center mb-20">
<!-- Emplacement image -->
<div class="bg-gray-200 rounded-xl aspect-[4/3] flex items-center justify-center">
<span class="text-gray-400 text-lg">Image à venir</span>
</div>
<!-- Texte -->
<div>
<h2 class="text-4xl font-bold text-black mb-6">Notre histoire</h2>
<p class="text-lg text-gray-700 leading-relaxed mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris.
</p>
<p class="text-lg text-gray-700 leading-relaxed">
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident.
</p>
</div>
</div>
<!-- Section inversée avec image -->
<div class="grid md:grid-cols-2 gap-12 items-center mb-20">
<!-- Texte -->
<div class="md:order-1 order-2">
<h2 class="text-4xl font-bold text-black mb-6">Nos valeurs</h2>
<p class="text-lg text-gray-700 leading-relaxed mb-4">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi
architecto beatae vitae dicta sunt explicabo.
</p>
<p class="text-lg text-gray-700 leading-relaxed">
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
</p>
</div>
<!-- Emplacement image -->
<div class="bg-gray-200 rounded-xl aspect-[4/3] flex items-center justify-center md:order-2 order-1">
<span class="text-gray-400 text-lg">Image à venir</span>
</div>
</div>
</div>
</section>
<!-- Section équipe -->
<section class="py-20 px-4">
<div class="max-w-5xl mx-auto">
<h2 class="text-4xl font-bold text-black mb-12 text-center">L'équipe</h2>
<div class="grid md:grid-cols-3 gap-8">
<!-- Membre 1 -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-gray-200 aspect-square flex items-center justify-center">
<span class="text-gray-400">Photo</span>
</div>
<div class="p-6">
<h3 class="font-bold text-xl mb-2 text-black">Prénom Nom</h3>
<p class="text-gray-600 mb-2">Président(e)</p>
<p class="text-gray-700 text-base">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
</div>
</div>
<!-- Membre 2 -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-gray-200 aspect-square flex items-center justify-center">
<span class="text-gray-400">Photo</span>
</div>
<div class="p-6">
<h3 class="font-bold text-xl mb-2 text-black">Prénom Nom</h3>
<p class="text-gray-600 mb-2">Vice-président(e)</p>
<p class="text-gray-700 text-base">
Sed do eiusmod tempor incididunt ut labore et dolore.
</p>
</div>
</div>
<!-- Membre 3 -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-gray-200 aspect-square flex items-center justify-center">
<span class="text-gray-400">Photo</span>
</div>
<div class="p-6">
<h3 class="font-bold text-xl mb-2 text-black">Prénom Nom</h3>
<p class="text-gray-600 mb-2">Secrétaire</p>
<p class="text-gray-700 text-base">
Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Section contact -->
<section class="py-20 bg-gradient-to-b from-gray-50 to-white">
<div class="max-w-4xl mx-auto px-4">
<div class="bg-white rounded-xl shadow-lg p-8 md:p-12">
<h2 class="text-4xl font-bold text-black mb-8 text-center">Nous contacter</h2>
<div class="grid md:grid-cols-2 gap-8">
<div>
<h3 class="font-bold text-xl mb-4 text-black">Coordonnées</h3>
<div class="space-y-3 text-lg text-gray-700">
<p>
<strong>Email :</strong><br>
contact@exemple.fr
</p>
<p>
<strong>Téléphone :</strong><br>
04 00 00 00 00
</p>
<p>
<strong>Adresse :</strong><br>
MJC, Place du Plomb<br>
69850 Saint-Martin-en-Haut
</p>
</div>
</div>
<div>
<div class="bg-gray-200 rounded-lg aspect-video flex items-center justify-center">
<span class="text-gray-400">Carte Google Maps</span>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
document.title = 'À propos - Patois Franco-Provençal'
})
</script>

View File

@@ -3,10 +3,10 @@
<!-- Section héro --> <!-- Section héro -->
<section class="py-20 px-4"> <section class="py-20 px-4">
<div class="max-w-4xl mx-auto text-center"> <div class="max-w-4xl mx-auto text-center">
<h1 class="text-5xl font-bold text-black mb-6"> <h1 class="text-6xl font-bold text-black mb-6">
Patois Franco-Provençal Patois Franco-Provençal
</h1> </h1>
<p class="text-xl mb-8 leading-relaxed" style="color: #6b7280"> <p class="text-2xl mb-8 leading-relaxed" style="color: #6b7280">
Découvrez, apprenez et préservez notre patrimoine linguistique régional. Découvrez, apprenez et préservez notre patrimoine linguistique régional.
Une collection de textes traditionnels traduits et annotés pour transmettre Une collection de textes traditionnels traduits et annotés pour transmettre
la richesse de notre langue ancestrale. la richesse de notre langue ancestrale.
@@ -19,9 +19,142 @@
</div> </div>
</section> </section>
<!-- Section présentation --> <!-- Section à propos de l'association -->
<section class="py-20 bg-gradient-to-b from-gray-50 to-white">
<div class="max-w-5xl mx-auto px-4">
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-black mb-4">Notre Mission</h2>
<p class="text-2xl text-gray-700 max-w-3xl mx-auto leading-relaxed">
Faire connaître et perdurer notre langue régionale, le patois franco-provençal en Pays Lyonnais.
Nous créons des ponts entre les générations et faisons se rencontrer patoisants et sympathisants.
</p>
</div>
<!-- Histoire de l'association -->
<div class="bg-white rounded-xl shadow-lg p-8 md:p-12 mb-12">
<div class="flex items-start mb-6">
<div class="bg-black text-white rounded-full w-12 h-12 flex items-center justify-center flex-shrink-0 mr-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 class="text-2xl font-bold mb-4 text-black">Il était une fois...</h3>
<p class="text-lg text-gray-700 leading-relaxed mb-4">
À la fin du vingtième siècle, en Lyonnais, des gens imaginèrent de se réunir pour agir
contre la disparition de leur patois. À Saint-Martin, Yzeron, Coise, Larajasse,
Saint-Symphorien et en bien d'autres lieux, on se rencontrait pour le simple plaisir de converser.
</p>
<p class="text-lg text-gray-700 leading-relaxed mb-4">
Peu à peu se fit jour une évidence surprenante : le nombre de patoisants était bien plus
grand que ce que l'on pensait. Des témoignages émouvants émergèrent, comme celui de cette
dame : <em class="text-gray-600">"J'avais oublié que je parlais patois. Je l'ai redécouvert en assistant
aux rencontres. Et maintenant, je sais à nouveau parler mon patois"</em>.
</p>
<p class="text-lg text-gray-700 leading-relaxed">
C'est ainsi que naquit l'idée de relier toutes ces initiatives locales, souvent méconnues
et isolées. Notre association vise à créer un réseau vivant et dynamique de préservation
de notre patrimoine linguistique.
</p>
</div>
</div>
</div>
<!-- Objectifs -->
<div class="grid md:grid-cols-3 gap-6 mb-12">
<div class="bg-white rounded-lg p-6 shadow-md border-t-4 border-black">
<div class="text-black mb-4">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<h4 class="font-bold text-xl mb-2 text-black">Sauvegarder</h4>
<p class="text-gray-600 text-base">
Maintenir et développer l'usage du francoprovençal en Pays Lyonnais pour les générations futures.
</p>
</div>
<div class="bg-white rounded-lg p-6 shadow-md border-t-4 border-black">
<div class="text-black mb-4">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<h4 class="font-bold text-xl mb-2 text-black">Publier</h4>
<p class="text-gray-600 text-base">
Créer des contenus : bulletins, recueils, CD-ROM, DVD et spectacles vivants pour faire vivre notre culture.
</p>
</div>
<div class="bg-white rounded-lg p-6 shadow-md border-t-4 border-black">
<div class="text-black mb-4">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h4 class="font-bold text-xl mb-2 text-black">Rassembler</h4>
<p class="text-gray-600 text-base">
Organiser des rencontres locales, régionales et internationales avec l'aire francoprovençale.
</p>
</div>
</div>
<!-- Le Francoprovençal -->
<div class="bg-gradient-to-r from-gray-900 to-gray-800 rounded-xl shadow-xl p-8 md:p-12 text-white mb-12">
<h3 class="text-2xl font-bold mb-6 flex items-center">
<svg class="w-8 h-8 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Le Francoprovençal : Un Patrimoine Vivant
</h3>
<div class="space-y-4 text-lg text-gray-200 leading-relaxed">
<p>
Découvert par le linguiste italien Ascoli dans les années 1870, le francoprovençal forme
une zone linguistique distincte entre langue d'oïl et langue d'oc, couvrant la région
Rhône-Alpes, la Suisse Romande et les vallées des Alpes italiennes.
</p>
<p>
de l'influence de Lugdunum (Lyon), fondée en 49 avant notre ère, cette langue porte
en elle plus de deux millénaires d'histoire. Malgré l'absence d'unité politique qui aurait
pu en faire une grande langue littéraire, le francoprovençal s'est maintenu dans les cœurs
et les pratiques quotidiennes de nos régions.
</p>
<p class="font-semibold border-l-4 border-white pl-4 italic">
Une langue n'est pas un simple code, mais une manière d'appréhender le monde et de sentir
les choses. Nos dialectes sont marqués par notre enfance, par des coutumes et des
savoir-faire, par des traditions de chansons et d'histoires.
</p>
</div>
</div>
<!-- Appel à l'action -->
<div class="text-center bg-white rounded-xl shadow-lg p-8 md:p-12">
<h3 class="text-3xl font-bold mb-4 text-black">Rejoignez-nous !</h3>
<p class="text-lg text-gray-700 mb-6 leading-relaxed max-w-2xl mx-auto">
Que vous soyez patoisant confirmé, curieux de vos racines, ou simplement passionné par
les langues régionales, nous vous invitons à participer à cette aventure collective.
Le patrimoine linguistique est fragile : ensemble, donnons-lui la créativité et la
jeunesse qui font l'originalité de notre région.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<router-link to="/a-propos" class="btn-primary">
En savoir +
</router-link>
<a href="mailto:contact@patois-lyon.fr" class="btn-secondary">
Nous contacter
</a>
<router-link to="/textes" class="btn-secondary">
Découvrir les textes
</router-link>
</div>
</div>
</div>
</section>
<!-- Section présentation des fonctionnalités -->
<section class="py-16 bg-gray-50"> <section class="py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4"> <div class="max-w-6xl mx-auto px-4">
<h2 class="text-3xl font-bold text-center mb-12">Les outils du site</h2>
<div class="grid md:grid-cols-3 gap-8"> <div class="grid md:grid-cols-3 gap-8">
<div class="text-center"> <div class="text-center">
<div class="bg-white rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4 shadow-sm"> <div class="bg-white rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4 shadow-sm">
@@ -29,8 +162,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C20.832 18.477 19.246 18 17.5 18c-1.746 0-3.332.477-4.5 1.253" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C20.832 18.477 19.246 18 17.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">Textes Authentiques</h3> <h3 class="text-xl font-semibold mb-2">Textes Authentiques</h3>
<p style="color: #6b7280"> <p class="text-base" style="color: #6b7280">
Collection de textes traditionnels et contemporains en patois franco-provençal Collection de textes traditionnels et contemporains en patois franco-provençal
</p> </p>
</div> </div>
@@ -41,8 +174,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">Audio Intégré</h3> <h3 class="text-xl font-semibold mb-2">Audio Intégré</h3>
<p style="color: #6b7280"> <p class="text-base" style="color: #6b7280">
Écoutez la prononciation authentique avec nos enregistrements audio Écoutez la prononciation authentique avec nos enregistrements audio
</p> </p>
</div> </div>
@@ -53,8 +186,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">Recherche Avancée</h3> <h3 class="text-xl font-semibold mb-2">Recherche Avancée</h3>
<p style="color: #6b7280"> <p class="text-base" style="color: #6b7280">
Trouvez facilement des textes par mot-clé, auteur ou thématique Trouvez facilement des textes par mot-clé, auteur ou thématique
</p> </p>
</div> </div>
@@ -68,20 +201,20 @@
<h2 class="text-3xl font-bold mb-12">Notre Collection</h2> <h2 class="text-3xl font-bold mb-12">Notre Collection</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-8">
<div> <div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.totalTexts }}</div> <div class="text-5xl font-bold text-black mb-2">{{ stats.totalTexts }}</div>
<div style="color: #6b7280">Textes</div> <div class="text-lg" style="color: #6b7280">Textes</div>
</div> </div>
<div> <div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.withAudio }}</div> <div class="text-5xl font-bold text-black mb-2">{{ stats.withAudio }}</div>
<div style="color: #6b7280">Avec Audio</div> <div class="text-lg" style="color: #6b7280">Avec Audio</div>
</div> </div>
<div> <div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.authors }}</div> <div class="text-5xl font-bold text-black mb-2">{{ stats.authors }}</div>
<div style="color: #6b7280">Auteurs</div> <div class="text-lg" style="color: #6b7280">Auteurs</div>
</div> </div>
<div> <div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.categories }}</div> <div class="text-5xl font-bold text-black mb-2">{{ stats.categories }}</div>
<div style="color: #6b7280">Catégories</div> <div class="text-lg" style="color: #6b7280">Catégories</div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,7 +225,7 @@
<div class="max-w-6xl mx-auto px-4"> <div class="max-w-6xl mx-auto px-4">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl font-bold mb-4">Textes Récents</h2> <h2 class="text-3xl font-bold mb-4">Textes Récents</h2>
<p style="color: #6b7280">Découvrez nos derniers ajouts à la collection</p> <p class="text-lg" style="color: #6b7280">Découvrez nos derniers ajouts à la collection</p>
</div> </div>
<div class="grid md:grid-cols-3 gap-6"> <div class="grid md:grid-cols-3 gap-6">
@@ -102,9 +235,9 @@
class="bg-white rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer" class="bg-white rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
@click="$router.push(`/texte/${text.id}`)" @click="$router.push(`/texte/${text.id}`)"
> >
<h3 class="font-semibold mb-2">{{ text.metadata.titre_fr }}</h3> <h3 class="text-lg font-semibold mb-2">{{ text.metadata.titre_fr }}</h3>
<p class="text-sm mb-3" style="color: #6b7280">{{ text.metadata.titre_pt }}</p> <p class="text-base mb-3" style="color: #6b7280">{{ text.metadata.titre_pt }}</p>
<div class="flex justify-between items-center text-xs text-gray-500"> <div class="flex justify-between items-center text-sm text-gray-500">
<span>{{ text.metadata.auteur }}</span> <span>{{ text.metadata.auteur }}</span>
<span v-if="text.hasAudio" class="flex items-center"> <span v-if="text.hasAudio" class="flex items-center">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">

View File

@@ -3,51 +3,87 @@
<div class="max-w-6xl mx-auto px-4"> <div class="max-w-6xl mx-auto px-4">
<!-- En-tête avec recherche --> <!-- En-tête avec recherche -->
<div class="mb-8"> <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"> <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> </p>
<!-- Barre de recherche avancée --> <!-- Barre de recherche avancée -->
<div class="bg-gray-50 rounded-lg p-6 mb-6"> <div class="bg-gray-50 rounded-lg p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <!-- Barre de recherche principale -->
<div> <div class="mb-4">
<input <input
v-model="searchQuery" v-model="searchQuery"
@input="performSearch" @input="performSearch"
type="text" type="text"
placeholder="Rechercher dans les textes..." 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" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-black"
/> />
</div> </div>
<div>
<select <!-- Filtres par type de contenu -->
v-model="selectedCategory" <div class="mb-4">
@change="performSearch" <label class="block text-sm font-medium text-gray-700 mb-2">Type de contenu</label>
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-black" <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> Tout
<option v-for="category in categories" :key="category" :value="category"> </button>
{{ category }} <button
</option> @click="setContentType('texts')"
</select> :class="contentType === 'texts' ? 'bg-black text-white' : 'bg-white text-gray-700 border border-gray-300'"
</div> class="px-4 py-2 rounded-lg hover:shadow-sm transition-shadow"
<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"
> >
<option value="">Toutes les difficultés</option> Textes en patois
<option value="Facile">Facile</option> </button>
<option value="Moyen">Moyen</option> <button
<option value="Difficile">Difficile</option> @click="setContentType('blog')"
</select> :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>
</div> </div>
<!-- Filtres supplémentaires --> <!-- Filtres pour textes -->
<div class="flex flex-wrap gap-4 mt-4"> <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"> <label class="flex items-center">
<input <input
v-model="onlyWithAudio" v-model="onlyWithAudio"
@@ -70,59 +106,73 @@
<!-- Résultats --> <!-- Résultats -->
<div class="mb-6"> <div class="mb-6">
<p style="color: #6b7280" class="mb-6"> <p style="color: #6b7280" class="mb-6">
{{ filteredTexts.length }} texte(s) trouvé(s) <span v-if="loading" class="inline-flex items-center">
<span v-if="searchQuery || selectedCategory || selectedDifficulty || onlyWithAudio"> <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 pour vos critères de recherche
</span> </span>
</span>
</p> </p>
</div> </div>
<!-- Liste des textes --> <!-- Liste des résultats -->
<div class="grid gap-6"> <div class="grid gap-6">
<!-- Résultat texte -->
<div <div
v-for="text in paginatedTexts" v-for="item in paginatedResults"
:key="text.id" :key="`${item.type}-${item.id}`"
class="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow cursor-pointer" 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"> <div class="flex-1">
<h2 class="text-xl font-semibold text-black mb-2"> <h2 class="text-xl font-semibold text-black mb-2">
{{ text.metadata.titre_fr }} {{ item.metadata.titre_fr }}
</h2> </h2>
<p class="text-lg mb-3" style="color: #6b7280"> <p class="text-lg mb-3" style="color: #6b7280">
{{ text.metadata.titre_pt }} {{ item.metadata.titre_pt }}
</p> </p>
<div class="flex flex-wrap gap-4 text-sm mb-4" style="color: #6b7280"> <div class="flex flex-wrap gap-4 text-sm mb-4" style="color: #6b7280">
<span v-if="text.metadata.auteur"> <span v-if="item.metadata.auteur">
<strong>Auteur:</strong> {{ text.metadata.auteur }} <strong>Auteur:</strong> {{ item.metadata.auteur }}
</span> </span>
<span v-if="text.metadata.traducteur"> <span v-if="item.metadata.traducteur">
<strong>Traducteur:</strong> {{ text.metadata.traducteur }} <strong>Traducteur:</strong> {{ item.metadata.traducteur }}
</span> </span>
<span v-if="text.metadata.categorie"> <span v-if="item.metadata.categorie" class="capitalize">
<strong>Catégorie:</strong> {{ text.metadata.categorie }} <strong>Catégorie:</strong> {{ item.metadata.categorie }}
</span> </span>
</div> </div>
<!-- Aperçu du texte --> <!-- Aperçu du texte -->
<p class="text-sm line-clamp-3" style="color: #6b7280"> <p class="text-sm line-clamp-3" style="color: #6b7280">
{{ getTextPreview(text.frenchText) }} {{ getTextPreview(item.frenchText) }}
</p> </p>
</div> </div>
<div class="flex flex-col items-end ml-4"> <div class="flex flex-col items-end ml-4">
<!-- Badges --> <!-- Badges -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span <span v-if="item.hasAudio" class="flex items-center text-xs" style="color: #6b7280">
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">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"> <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"/> <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> </svg>
@@ -131,6 +181,32 @@
</div> </div>
</div> </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>
</div> </div>
@@ -160,11 +236,11 @@
</div> </div>
<!-- Message si aucun résultat --> <!-- 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"> <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" /> <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> </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> <p class="text-gray-500">Essayez de modifier vos critères de recherche.</p>
</div> </div>
</div> </div>
@@ -173,82 +249,177 @@
<script> <script>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { textService } from '../services/textService.js' import { searchService } from '../services/searchService.js'
export default { export default {
name: 'Texts', name: 'Texts',
setup() { setup() {
const route = useRoute() const route = useRoute()
const router = useRouter()
const allTexts = ref([]) // État de la recherche
const searchQuery = ref('') const searchQuery = ref('')
const selectedCategory = ref('') const contentType = ref('texts') // 'all', 'texts', 'blog' - Par défaut sur textes
const selectedDifficulty = ref('') const selectedTextCategories = ref([])
const selectedBlogCategories = ref([])
const onlyWithAudio = ref(false) const onlyWithAudio = ref(false)
const currentPage = ref(1) const currentPage = ref(1)
const textsPerPage = 10 const resultsPerPage = 10
const categories = computed(() => { // Résultats
const cats = new Set() const searchResults = ref({ texts: [], posts: [], total: 0 })
allTexts.value.forEach(text => { const textCategories = ref([])
if (text.metadata.categorie) { const blogCategories = ref([])
cats.add(text.metadata.categorie) 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)
} }
})
return Array.from(cats).sort() if (contentType.value === 'all' || contentType.value === 'blog') {
results.push(...searchResults.value.posts)
}
return results
}) })
const filteredTexts = computed(() => { const totalResults = computed(() => allResults.value.length)
// Ne pas filtrer localement - l'API backend s'en charge
return allTexts.value
})
const totalPages = computed(() => { const totalPages = computed(() => {
return Math.ceil(filteredTexts.value.length / textsPerPage) return Math.ceil(totalResults.value / resultsPerPage)
}) })
const paginatedTexts = computed(() => { const paginatedResults = computed(() => {
const start = (currentPage.value - 1) * textsPerPage const start = (currentPage.value - 1) * resultsPerPage
const end = start + textsPerPage const end = start + resultsPerPage
return filteredTexts.value.slice(start, end) return allResults.value.slice(start, end)
}) })
const loadTexts = async () => { // Méthodes
const loadCategories = async () => {
try { try {
// Utiliser l'API de recherche avec les filtres actuels const [textCats, blogCats] = await Promise.all([
const texts = await textService.searchTexts(searchQuery.value, { searchService.getTextCategories(),
category: selectedCategory.value, searchService.getBlogCategories()
difficulty: selectedDifficulty.value, ])
onlyWithAudio: onlyWithAudio.value textCategories.value = textCats
}) blogCategories.value = blogCats
allTexts.value = texts
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des textes:', error) console.error('Erreur lors du chargement des catégories:', 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 = []
}
} }
} }
const performSearch = async () => { const performSearch = async () => {
loading.value = true
currentPage.value = 1 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
} }
const clearFilters = async () => { // 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 = '' searchQuery.value = ''
selectedCategory.value = '' contentType.value = 'texts' // Remettre sur textes par défaut
selectedDifficulty.value = '' selectedTextCategories.value = []
selectedBlogCategories.value = []
onlyWithAudio.value = false onlyWithAudio.value = false
currentPage.value = 1 currentPage.value = 1
// IMPORTANT: Recharger via l'API après avoir vidé les filtres performSearch()
await loadTexts() }
const navigateToItem = (item) => {
if (item.type === 'text') {
router.push(`/texte/${item.id}`)
} else {
router.push(`/actualites/${item.id}`)
}
} }
const getTextPreview = (text) => { const getTextPreview = (text) => {
@@ -256,46 +427,61 @@ export default {
return text.split('\n').slice(0, 3).join(' ').substring(0, 200) + '...' return text.split('\n').slice(0, 3).join(' ').substring(0, 200) + '...'
} }
const getDifficultyClass = (difficulty) => { const formatDate = (dateString) => {
switch (difficulty?.toLowerCase()) { const date = new Date(dateString)
case 'facile': return date.toLocaleDateString('fr-FR', {
return 'bg-green-100 text-green-800' year: 'numeric',
case 'moyen': month: 'long',
case 'moyenne': day: 'numeric'
return 'bg-yellow-100 text-yellow-800' })
case 'difficile':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
} }
// Initialiser la recherche depuis l'URL // Initialiser la recherche depuis l'URL
watch(() => route.query.search, (newSearch) => { watch(() => route.query.search, (newSearch) => {
if (newSearch) { if (newSearch) {
searchQuery.value = newSearch searchQuery.value = newSearch
// Si recherche depuis la navbar, chercher dans tout
contentType.value = 'all'
performSearch()
} }
}, { immediate: true }) }, { immediate: true })
onMounted(() => { onMounted(async () => {
loadTexts() 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 { return {
allTexts,
searchQuery, searchQuery,
selectedCategory, contentType,
selectedDifficulty, selectedTextCategories,
selectedBlogCategories,
onlyWithAudio, onlyWithAudio,
currentPage, currentPage,
categories, textCategories,
filteredTexts, blogCategories,
searchResults,
loading,
hasActiveFilters,
totalResults,
totalPages, totalPages,
paginatedTexts, paginatedResults,
performSearch, performSearch,
setContentType,
toggleTextCategory,
toggleBlogCategory,
clearFilters, clearFilters,
navigateToItem,
getTextPreview, getTextPreview,
getDifficultyClass formatDate
} }
} }
} }