Compare commits

..

3 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
ElPoyo
03ffc846bf Add WordPress integration as Headless CMS and enhance navigation with dynamic categories 2026-02-16 11:55:00 +01:00
ElPoyo
b7f55e0707 Refactor navigation and add news functionality with WordPress integration 2026-02-11 10:15:36 +01:00
13 changed files with 1985 additions and 190 deletions

View File

@@ -270,7 +270,10 @@ class TextService {
// Filtres
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) {
@@ -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() {
const allTexts = await this.scanTexts()
@@ -293,7 +296,8 @@ class TextService {
for (const text of allTexts) {
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
*/
@@ -439,6 +456,7 @@ app.listen(PORT, () => {
console.log(` GET /api/texts/:id - Détails d'un texte`)
console.log(` GET /api/random - Texte aléatoire`)
console.log(` GET /api/stats - Statistiques`)
console.log(` GET /api/categories - Liste des catégories`)
console.log(` GET /api/texts/:id/audio - Fichier audio`)
})

View File

@@ -128,7 +128,7 @@ export default {
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(70)
const volume = ref(100)
const playbackRate = ref(1)
const hasAudio = computed(() => !!props.audioSrc)

View File

@@ -1,6 +1,23 @@
<template>
<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">
<!-- 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">
<p class="text-gray-600 mb-2">
Site développé avec soin par Paul Fournel

View File

@@ -35,18 +35,36 @@
<span>Textes</span>
</div>
</router-link>
<template v-if="!useDropdown">
<router-link
to="/au-hasard"
v-for="category in dynamicCategories"
:key="category.id"
:to="getCategoryLink(category)"
class="nav-link"
:class="{ 'active': $route.name === 'Random' }"
:class="{ 'active': isCategoryActive(category) }"
>
<div class="flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span>Au Hasard</span>
</div>
<span>{{ category.name }}</span>
</router-link>
</template>
<div v-else class="relative group">
<button
class="nav-link"
:class="{ 'active': isBlogActive }"
type="button"
>
Blog
</button>
<div class="absolute left-0 mt-2 w-56 bg-white border border-gray-200 rounded-lg shadow-lg py-2 hidden group-hover:block">
<router-link
v-for="category in categories"
:key="category.id"
:to="getCategoryLink(category)"
class="dropdown-link"
>
{{ category.name }}
</router-link>
</div>
</div>
<!-- Barre de recherche -->
<div class="relative">
@@ -83,7 +101,17 @@
<div class="flex flex-col space-y-4">
<router-link to="/" class="nav-link-mobile">Accueil</router-link>
<router-link to="/textes" class="nav-link-mobile">Textes</router-link>
<router-link to="/au-hasard" class="nav-link-mobile">Au Hasard</router-link>
<div v-if="categories.length" class="pt-2 border-t border-gray-200">
<div class="text-xs uppercase tracking-wide text-gray-500 mb-2">Blog</div>
<router-link
v-for="category in categories"
:key="category.id"
:to="getCategoryLink(category)"
class="nav-link-mobile"
>
{{ category.name }}
</router-link>
</div>
<div class="pt-2">
<input
v-model="searchQuery"
@@ -100,15 +128,19 @@
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { wordpressService } from '../services/wordpressService.js'
export default {
name: 'NavigationBar',
setup() {
const router = useRouter()
const route = useRoute()
const searchQuery = ref('')
const mobileMenuOpen = ref(false)
const categories = ref([])
const categoriesLoading = ref(false)
const performSearch = () => {
if (searchQuery.value.trim()) {
@@ -116,13 +148,61 @@ export default {
name: 'Texts',
query: { search: searchQuery.value.trim() }
})
// Vider la barre de recherche après navigation
searchQuery.value = ''
mobileMenuOpen.value = false
}
}
const loadCategories = async () => {
categoriesLoading.value = true
try {
categories.value = await wordpressService.getCategories()
} catch (error) {
console.error('Erreur lors du chargement des catégories:', error)
} finally {
categoriesLoading.value = false
}
}
const dynamicCategories = computed(() => {
return categories.value.slice(0, 4)
})
const useDropdown = computed(() => {
return categories.value.length > 4
})
const getCategoryLink = (category) => {
return category.slug === 'actualites' ? '/actualites' : `/blog/${category.slug}`
}
const isCategoryActive = (category) => {
if (category.slug === 'actualites') {
return route.name === 'News' || route.name === 'NewsArticle'
}
return route.name === 'BlogCategory' && route.params.slug === category.slug
}
const isBlogActive = computed(() => {
return route.name === 'News' || route.name === 'BlogCategory' || route.name === 'NewsArticle'
})
onMounted(() => {
loadCategories()
})
return {
searchQuery,
mobileMenuOpen,
categories,
categoriesLoading,
dynamicCategories,
useDropdown,
isBlogActive,
getCategoryLink,
isCategoryActive,
performSearch
}
}
@@ -131,7 +211,9 @@ export default {
<style scoped>
.nav-link {
@apply font-medium transition-colors duration-200 pb-1;
font-weight: 500;
transition: color 0.2s;
padding-bottom: 0.25rem;
color: #6b7280;
}
@@ -140,15 +222,31 @@ export default {
}
.nav-link.active {
@apply text-black border-b-2 border-black;
color: black;
border-bottom: 2px solid black;
}
.nav-link-mobile {
@apply font-medium transition-colors duration-200 py-2;
font-weight: 500;
transition: color 0.2s;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6b7280;
}
.nav-link-mobile:hover {
color: black;
}
.dropdown-link {
display: block;
padding: 0.5rem 1rem;
color: #374151;
transition: color 0.2s, background-color 0.2s;
}
.dropdown-link:hover {
color: black;
background-color: #f9fafb;
}
</style>

View File

@@ -2,6 +2,9 @@
import Home from '../views/Home.vue'
import Texts from '../views/Texts.vue'
import TextReader from '../views/TextReader.vue'
import News from '../views/News.vue'
import NewsArticle from '../views/NewsArticle.vue'
import AboutPage from '../views/AboutPage.vue'
import { textService } from '../services/textService.js'
const routes = [
@@ -22,23 +25,35 @@ const routes = [
props: true
},
{
path: '/au-hasard',
name: 'Random',
beforeEnter: async (to, from, next) => {
try {
const randomText = await textService.getRandomText()
next(`/texte/${randomText.id}`)
} catch (error) {
console.error('Erreur lors de la redirection vers un texte aléatoire:', error)
next('/textes')
}
}
path: '/actualites',
name: 'News',
component: News
},
{
path: '/blog/:slug',
name: 'BlogCategory',
component: News
},
{
path: '/actualite/:id',
name: 'NewsArticle',
component: NewsArticle,
props: true
},
{
path: '/a-propos',
name: 'AboutPage',
component: AboutPage
}
]
const router = createRouter({
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

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

@@ -0,0 +1,227 @@
const API_BASE_URL = 'https://admin-afpl.federation-ouest-francoprovencal.fr/wp-json/wp/v2'
const ACTUALITES_CATEGORY_ID = 3
/**
* Service pour interagir avec l'API WordPress (Headless CMS)
*/
export const wordpressService = {
/**
* Récupère les catégories principales (parent = 0)
* @returns {Promise<Array>} - Liste des catégories
*/
async getCategories() {
try {
const response = await fetch(
`${API_BASE_URL}/categories?per_page=100&parent=0&hide_empty=true&orderby=name&order=asc`
)
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`)
}
const categories = await response.json()
return categories
.filter(category => category.slug !== 'uncategorized' && category.name.toLowerCase() !== 'uncategorized')
.map(category => this.formatCategory(category))
} catch (error) {
console.error('Erreur lors de la récupération des catégories:', error)
throw error
}
},
/**
* Récupère une catégorie par son slug
* @param {string} slug - Slug de la catégorie
* @returns {Promise<Object|null>} - Catégorie formatée ou null
*/
async getCategoryBySlug(slug) {
try {
const response = await fetch(`${API_BASE_URL}/categories?slug=${slug}`)
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`)
}
const categories = await response.json()
if (!categories.length) {
return null
}
return this.formatCategory(categories[0])
} catch (error) {
console.error(`Erreur lors de la récupération de la catégorie ${slug}:`, error)
throw error
}
},
/**
* Récupère tous les posts de la catégorie "actualites"
* @param {number} perPage - Nombre de posts par page (défaut: 10)
* @param {number} page - Numéro de page (défaut: 1)
* @returns {Promise<Object>} - { posts: Array, totalPages: number, total: number }
*/
async getNews(perPage = 10, page = 1) {
return this.getPostsByCategory(ACTUALITES_CATEGORY_ID, perPage, page)
},
/**
* Récupère les posts d'une catégorie
* @param {number} categoryId - ID de la catégorie
* @param {number} perPage - Nombre de posts par page (défaut: 10)
* @param {number} page - Numéro de page (défaut: 1)
* @returns {Promise<Object>} - { posts: Array, totalPages: number, total: number }
*/
async getPostsByCategory(categoryId, perPage = 10, page = 1) {
try {
const response = await fetch(
`${API_BASE_URL}/posts?categories=${categoryId}&per_page=${perPage}&page=${page}&_embed`
)
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`)
}
const posts = await response.json()
const totalPages = parseInt(response.headers.get('X-WP-TotalPages') || '1')
const total = parseInt(response.headers.get('X-WP-Total') || '0')
return {
posts: posts.map(post => this.formatPost(post)),
totalPages,
total
}
} catch (error) {
console.error('Erreur lors de la récupération des posts:', error)
throw error
}
},
/**
* Récupère un post spécifique par son ID
* @param {number} id - ID du post
* @returns {Promise<Object>} - Post formaté
*/
async getNewsById(id) {
return this.getPostById(id)
},
/**
* Récupère un post spécifique par son ID
* @param {number} id - ID du post
* @returns {Promise<Object>} - Post formaté
*/
async getPostById(id) {
try {
const response = await fetch(`${API_BASE_URL}/posts/${id}?_embed`)
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`)
}
const post = await response.json()
return this.formatPost(post)
} catch (error) {
console.error(`Erreur lors de la récupération du post ${id}:`, error)
throw error
}
},
/**
* 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
* @param {Object} category - Catégorie brute de l'API WordPress
* @returns {Object} - Catégorie formatée
*/
formatCategory(category) {
return {
id: category.id,
name: category.name,
slug: category.slug,
description: category.description,
count: category.count,
parent: category.parent
}
},
/**
* Formate un post WordPress pour l'utilisation dans l'application
* @param {Object} post - Post brut de l'API WordPress
* @returns {Object} - Post formaté
*/
formatPost(post) {
return {
id: post.id,
title: post.title.rendered,
content: post.content.rendered,
excerpt: post.excerpt.rendered,
date: post.date,
modified: post.modified,
slug: post.slug,
link: post.link,
author: post._embedded?.author?.[0]?.name || 'Auteur inconnu',
featuredImage: post._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
featuredImageAlt: post._embedded?.['wp:featuredmedia']?.[0]?.alt_text || '',
categories: post._embedded?.['wp:term']?.[0]?.map(cat => cat.name) || [],
tags: post._embedded?.['wp:term']?.[1]?.map(tag => tag.name) || []
}
},
/**
* 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
* @param {string} dateString - Date au format ISO
* @returns {string} - Date formatée en français
*/
formatDate(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
}

View File

@@ -2,6 +2,9 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@theme {
--font-sans: 'Inter', system-ui, sans-serif;
/* Ajouter les couleurs personnalisées si nécessaire */
--color-gray-custom: #6b7280;
--color-gray-custom-light: #9ca3af;
}
@@ -9,25 +12,50 @@
@layer base {
body {
font-family: 'Inter', sans-serif;
@apply bg-white text-black leading-relaxed;
background-color: white;
color: black;
line-height: 1.625;
}
}
@layer components {
.btn-primary {
@apply bg-black text-white px-6 py-3 rounded-full hover:bg-gray-800 transition-colors duration-200 font-medium;
background-color: black;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: #1f2937;
}
.btn-secondary {
@apply border-2 border-black text-black px-6 py-3 rounded-full hover:bg-black hover:text-white transition-all duration-200 font-medium;
border: 2px solid black;
color: black;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
font-weight: 500;
transition: all 0.2s;
}
.btn-secondary:hover {
background-color: black;
color: white;
}
.text-patois {
@apply font-medium text-lg leading-relaxed;
font-weight: 500;
font-size: 1.125rem;
line-height: 1.625;
}
.text-french {
@apply font-normal text-lg leading-relaxed;
font-weight: 400;
font-size: 1.125rem;
line-height: 1.625;
color: var(--color-gray-custom);
}
}

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 class="py-20 px-4">
<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
</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.
Une collection de textes traditionnels traduits et annotés pour transmettre
la richesse de notre langue ancestrale.
@@ -15,16 +15,146 @@
<router-link to="/textes" class="btn-primary">
Explorer les textes
</router-link>
<router-link to="/au-hasard" class="btn-secondary">
Texte au hasard
</router-link>
</div>
</div>
</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">
<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="text-center">
<div class="bg-white rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4 shadow-sm">
@@ -32,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" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Textes Authentiques</h3>
<p style="color: #6b7280">
<h3 class="text-xl font-semibold mb-2">Textes Authentiques</h3>
<p class="text-base" style="color: #6b7280">
Collection de textes traditionnels et contemporains en patois franco-provençal
</p>
</div>
@@ -44,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" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Audio Intégré</h3>
<p style="color: #6b7280">
<h3 class="text-xl font-semibold mb-2">Audio Intégré</h3>
<p class="text-base" style="color: #6b7280">
Écoutez la prononciation authentique avec nos enregistrements audio
</p>
</div>
@@ -56,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" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Recherche Avancée</h3>
<p style="color: #6b7280">
<h3 class="text-xl font-semibold mb-2">Recherche Avancée</h3>
<p class="text-base" style="color: #6b7280">
Trouvez facilement des textes par mot-clé, auteur ou thématique
</p>
</div>
@@ -71,20 +201,20 @@
<h2 class="text-3xl font-bold mb-12">Notre Collection</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
<div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.totalTexts }}</div>
<div style="color: #6b7280">Textes</div>
<div class="text-5xl font-bold text-black mb-2">{{ stats.totalTexts }}</div>
<div class="text-lg" style="color: #6b7280">Textes</div>
</div>
<div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.withAudio }}</div>
<div style="color: #6b7280">Avec Audio</div>
<div class="text-5xl font-bold text-black mb-2">{{ stats.withAudio }}</div>
<div class="text-lg" style="color: #6b7280">Avec Audio</div>
</div>
<div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.authors }}</div>
<div style="color: #6b7280">Auteurs</div>
<div class="text-5xl font-bold text-black mb-2">{{ stats.authors }}</div>
<div class="text-lg" style="color: #6b7280">Auteurs</div>
</div>
<div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.categories }}</div>
<div style="color: #6b7280">Catégories</div>
<div class="text-5xl font-bold text-black mb-2">{{ stats.categories }}</div>
<div class="text-lg" style="color: #6b7280">Catégories</div>
</div>
</div>
</div>
@@ -95,7 +225,7 @@
<div class="max-w-6xl mx-auto px-4">
<div class="text-center mb-12">
<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 class="grid md:grid-cols-3 gap-6">
@@ -105,9 +235,9 @@
class="bg-white rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
@click="$router.push(`/texte/${text.id}`)"
>
<h3 class="font-semibold mb-2">{{ text.metadata.titre_fr }}</h3>
<p class="text-sm mb-3" style="color: #6b7280">{{ text.metadata.titre_pt }}</p>
<div class="flex justify-between items-center text-xs text-gray-500">
<h3 class="text-lg font-semibold mb-2">{{ text.metadata.titre_fr }}</h3>
<p class="text-base mb-3" style="color: #6b7280">{{ text.metadata.titre_pt }}</p>
<div class="flex justify-between items-center text-sm text-gray-500">
<span>{{ text.metadata.auteur }}</span>
<span v-if="text.hasAudio" class="flex items-center">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">

285
src/views/News.vue Normal file
View File

@@ -0,0 +1,285 @@
<template>
<div class="min-h-screen bg-white py-8">
<div class="max-w-6xl mx-auto px-4">
<!-- En-tête -->
<div class="mb-8">
<h1 class="text-4xl font-bold text-black mb-4">
{{ categoryTitle }}
</h1>
<p class="text-gray-600">
{{ categoryDescription }}
</p>
</div>
<!-- Chargement -->
<div v-if="loading" class="flex justify-center items-center py-20">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-black"></div>
</div>
<!-- Erreur -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-600 mb-2">{{ error }}</p>
<button
@click="loadNews"
class="text-sm text-red-700 hover:text-red-900 underline"
>
Réessayer
</button>
</div>
<!-- Liste des articles -->
<div v-else-if="newsList.length > 0" class="space-y-6">
<article
v-for="news in newsList"
:key="news.id"
class="bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer"
@click="goToNews(news.id)"
>
<div class="md:flex">
<!-- Image à la une -->
<div v-if="news.featuredImage" class="md:w-1/3">
<img
:src="news.featuredImage"
:alt="news.featuredImageAlt || news.title"
class="w-full h-64 md:h-full object-cover"
/>
</div>
<!-- Contenu -->
<div class="p-6 md:w-2/3">
<!-- Date et auteur -->
<div class="flex items-center text-sm text-gray-500 mb-3">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ formatDate(news.date) }}</span>
<span class="mx-2"></span>
<span>{{ news.author }}</span>
</div>
<!-- Titre -->
<h2 class="text-2xl font-bold text-black mb-3 hover:text-gray-700 transition-colors">
{{ news.title }}
</h2>
<!-- Extrait -->
<div
class="text-gray-600 mb-4 line-clamp-3"
v-html="news.excerpt"
></div>
<!-- Catégories/Tags -->
<div v-if="news.tags.length > 0" class="flex flex-wrap gap-2">
<span
v-for="tag in news.tags.slice(0, 3)"
:key="tag"
class="px-3 py-1 bg-gray-100 text-gray-700 text-xs rounded-full"
>
{{ tag }}
</span>
</div>
<!-- Lien "Lire la suite" -->
<div class="mt-4">
<span class="text-black font-medium hover:underline inline-flex items-center">
Lire la suite
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</div>
</div>
</article>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex justify-center items-center space-x-4 mt-8">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border border-gray-300 text-black hover:bg-gray-50'
]"
>
Précédent
</button>
<div class="flex items-center space-x-2">
<button
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:class="[
'w-10 h-10 rounded-lg font-medium transition-colors',
page === currentPage
? 'bg-black text-white'
: 'bg-white border border-gray-300 text-black hover:bg-gray-50'
]"
>
{{ page }}
</button>
</div>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
:class="[
'px-4 py-2 rounded-lg font-medium transition-colors',
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border border-gray-300 text-black hover:bg-gray-50'
]"
>
Suivant
</button>
</div>
</div>
<!-- Aucun article -->
<div v-else class="text-center py-20">
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
<p class="text-gray-600 text-lg">Aucun article disponible pour le moment</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { wordpressService } from '../services/wordpressService.js'
const router = useRouter()
const route = useRoute()
const newsList = ref([])
const loading = ref(true)
const error = ref(null)
const currentPage = ref(1)
const totalPages = ref(1)
const total = ref(0)
const perPage = 10
const category = ref(null)
const categorySlug = computed(() => {
return route.params.slug || 'actualites'
})
const categoryTitle = computed(() => {
return category.value?.name || 'Actualités'
})
const categoryDescription = computed(() => {
return category.value?.description || 'Découvrez les derniers articles de cette catégorie'
})
// Charger la catégorie
const loadCategory = async () => {
category.value = await wordpressService.getCategoryBySlug(categorySlug.value)
if (!category.value) {
throw new Error('Catégorie introuvable')
}
}
// Charger les articles
const loadNews = async () => {
loading.value = true
error.value = null
try {
if (!category.value) {
await loadCategory()
}
const result = await wordpressService.getPostsByCategory(
category.value.id,
perPage,
currentPage.value
)
newsList.value = result.posts
totalPages.value = result.totalPages
total.value = result.total
} catch (err) {
error.value = 'Impossible de charger les articles. Veuillez réessayer plus tard.'
console.error('Erreur lors du chargement des articles:', err)
} finally {
loading.value = false
}
}
// Navigation vers une actualité
const goToNews = (id) => {
router.push({
name: 'NewsArticle',
params: { id },
query: { category: category.value?.slug || categorySlug.value }
})
}
// Navigation entre les pages
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// Pages visibles pour la pagination
const visiblePages = computed(() => {
const pages = []
const maxVisible = 5
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let end = Math.min(totalPages.value, start + maxVisible - 1)
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
// Formater la date
const formatDate = (dateString) => {
return wordpressService.formatDate(dateString)
}
// Charger au montage
onMounted(() => {
loadNews()
})
// Recharger lors du changement de page
watch(currentPage, () => {
loadNews()
})
// Recharger lors du changement de catégorie
watch(categorySlug, () => {
category.value = null
currentPage.value = 1
loadNews()
})
</script>
<style scoped>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Nettoyer le HTML de l'extrait */
:deep(.line-clamp-3 p) {
display: inline;
}
</style>

341
src/views/NewsArticle.vue Normal file
View File

@@ -0,0 +1,341 @@
<template>
<div class="min-h-screen bg-white">
<!-- Chargement -->
<div v-if="loading" class="flex justify-center items-center py-20">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-black"></div>
</div>
<!-- Erreur -->
<div v-else-if="error" class="max-w-4xl mx-auto px-4 py-20">
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-600 mb-4">{{ error }}</p>
<div class="flex justify-center space-x-4">
<button
@click="loadArticle"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Réessayer
</button>
<router-link
to="/actualites"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Retour aux actualités
</router-link>
</div>
</div>
</div>
<!-- Article -->
<article v-else-if="article" class="pb-16">
<!-- Image à la une -->
<div v-if="article.featuredImage" class="w-full h-96 overflow-hidden">
<img
:src="article.featuredImage"
:alt="article.featuredImageAlt || article.title"
class="w-full h-full object-cover"
/>
</div>
<!-- Contenu principal -->
<div class="max-w-4xl mx-auto px-4">
<!-- Fil d'Ariane -->
<nav class="py-6 text-sm">
<ol class="flex items-center space-x-2 text-gray-600">
<li>
<router-link to="/" class="hover:text-black transition-colors">
Accueil
</router-link>
</li>
<li>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</li>
<li>
<router-link :to="categoryLink" class="hover:text-black transition-colors">
{{ categoryTitle }}
</router-link>
</li>
<li>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</li>
<li class="text-black font-medium truncate">
{{ article.title }}
</li>
</ol>
</nav>
<!-- En-tête de l'article -->
<header class="mb-8">
<!-- Catégories -->
<div v-if="article.categories.length > 0" class="flex flex-wrap gap-2 mb-4">
<span
v-for="category in article.categories"
:key="category"
class="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
>
{{ category }}
</span>
</div>
<!-- Titre -->
<h1 class="text-4xl md:text-5xl font-bold text-black mb-6">
{{ article.title }}
</h1>
<!-- Métadonnées -->
<div class="flex flex-wrap items-center gap-4 text-gray-600">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>{{ article.author }}</span>
</div>
<span></span>
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ formatDate(article.date) }}</span>
</div>
<span v-if="article.modified !== article.date"></span>
<div v-if="article.modified !== article.date" class="flex items-center text-sm">
<span>Mis à jour le {{ formatDate(article.modified) }}</span>
</div>
</div>
</header>
<!-- Contenu de l'article -->
<div
class="prose prose-lg max-w-none article-content"
v-html="article.content"
></div>
<!-- Tags -->
<div v-if="article.tags.length > 0" class="mt-12 pt-8 border-t border-gray-200">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Mots-clés :</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in article.tags"
:key="tag"
class="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
>
#{{ tag }}
</span>
</div>
</div>
<!-- Navigation -->
<div class="mt-12 pt-8 border-t border-gray-200">
<router-link
:to="categoryLink"
class="inline-flex items-center text-black hover:text-gray-700 font-medium transition-colors"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Retour aux articles
</router-link>
</div>
</div>
</article>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { wordpressService } from '../services/wordpressService.js'
const route = useRoute()
const article = ref(null)
const loading = ref(true)
const error = ref(null)
const category = ref(null)
const categorySlug = computed(() => {
return route.query.category || 'actualites'
})
const categoryTitle = computed(() => {
return category.value?.name || 'Actualités'
})
const categoryLink = computed(() => {
return categorySlug.value === 'actualites'
? '/actualites'
: `/blog/${categorySlug.value}`
})
// Charger l'article
const loadArticle = async () => {
loading.value = true
error.value = null
try {
const id = parseInt(route.params.id)
if (isNaN(id)) {
throw new Error('ID d\'article invalide')
}
article.value = await wordpressService.getPostById(id)
category.value = await wordpressService.getCategoryBySlug(categorySlug.value)
// Mettre à jour le titre de la page
if (article.value) {
document.title = `${article.value.title} - Patois Franco-Provençal`
}
} catch (err) {
error.value = 'Impossible de charger cet article. Il n\'existe peut-être pas ou n\'est plus disponible.'
console.error('Erreur lors du chargement de l\'article:', err)
} finally {
loading.value = false
}
}
// Formater la date
const formatDate = (dateString) => {
return wordpressService.formatDate(dateString)
}
// Charger au montage
onMounted(() => {
loadArticle()
})
</script>
<style scoped>
/* Styles pour le contenu de l'article WordPress */
.article-content {
color: #1f2937;
line-height: 1.625;
}
.article-content :deep(h1),
.article-content :deep(h2),
.article-content :deep(h3),
.article-content :deep(h4),
.article-content :deep(h5),
.article-content :deep(h6) {
font-weight: 700;
color: black;
margin-top: 2rem;
margin-bottom: 1rem;
}
.article-content :deep(h1) { font-size: 1.875rem; }
.article-content :deep(h2) { font-size: 1.5rem; }
.article-content :deep(h3) { font-size: 1.25rem; }
.article-content :deep(h4) { font-size: 1.125rem; }
.article-content :deep(p) {
margin-bottom: 1rem;
}
.article-content :deep(a) {
color: black;
font-weight: 500;
text-decoration: underline;
transition: color 0.2s;
}
.article-content :deep(a:hover) {
color: #374151;
}
.article-content :deep(ul),
.article-content :deep(ol) {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.article-content :deep(ul) {
list-style-type: disc;
}
.article-content :deep(ol) {
list-style-type: decimal;
}
.article-content :deep(li) {
margin-bottom: 0.5rem;
}
.article-content :deep(blockquote) {
border-left: 4px solid #d1d5db;
padding-left: 1rem;
font-style: italic;
margin: 1.5rem 0;
color: #4b5563;
}
.article-content :deep(img) {
border-radius: 0.5rem;
margin: 1.5rem 0;
max-width: 100%;
height: auto;
}
.article-content :deep(figure) {
margin: 1.5rem 0;
}
.article-content :deep(figcaption) {
font-size: 0.875rem;
color: #4b5563;
text-align: center;
margin-top: 0.5rem;
}
.article-content :deep(pre) {
background-color: #f9fafb;
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
margin: 1.5rem 0;
}
.article-content :deep(code) {
background-color: #f3f4f6;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.article-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
.article-content :deep(th),
.article-content :deep(td) {
border: 1px solid #d1d5db;
padding: 0.75rem 1rem;
}
.article-content :deep(th) {
background-color: #f9fafb;
font-weight: 600;
}
.article-content :deep(hr) {
margin: 2rem 0;
border-color: #e5e7eb;
}
.article-content :deep(strong),
.article-content :deep(b) {
font-weight: 600;
}
.article-content :deep(em),
.article-content :deep(i) {
font-style: italic;
}
</style>

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>
<!-- Barre de recherche principale -->
<div class="mb-4">
<input
v-model="searchQuery"
@input="performSearch"
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"
/>
</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"
<!-- 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">
<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)
// 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)
}
})
return Array.from(cats).sort()
if (contentType.value === 'all' || contentType.value === 'blog') {
results.push(...searchResults.value.posts)
}
return results
})
const filteredTexts = computed(() => {
// Ne pas filtrer localement - l'API backend s'en charge
return allTexts.value
})
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
}
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 = ''
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
}
}
}