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