V1 Fonctionnelle (pas de secu de l'api, ni front quali)

This commit is contained in:
ElPoyo
2025-09-27 11:08:24 +02:00
parent 65846640cf
commit b4a468c14b
14 changed files with 2132 additions and 353 deletions

View File

@@ -3,7 +3,7 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="text-center">
<p class="text-gray-600 mb-2">
Site développé pour la préservation du patrimoine linguistique
Site développé avec soin par Paul Fournel
</p>
<p class="text-black font-medium">
© 2025 Association du Patois Franco-Provençal

View File

@@ -1,43 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -16,21 +16,36 @@
class="nav-link"
:class="{ 'active': $route.name === 'Home' }"
>
Accueil
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
<span>Accueil</span>
</div>
</router-link>
<router-link
to="/textes"
class="nav-link"
:class="{ 'active': $route.name === 'Texts' }"
>
Textes
<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="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>
<span>Textes</span>
</div>
</router-link>
<router-link
to="/au-hasard"
class="nav-link"
:class="{ 'active': $route.name === 'Random' }"
>
Au Hasard
<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>
</router-link>
<!-- Barre de recherche -->

View File

@@ -73,15 +73,37 @@
<div class="mt-6 flex justify-center space-x-4">
<button
@click="copyPatoisText"
class="btn-secondary text-sm"
:disabled="copyingPatois"
class="btn-secondary text-sm relative overflow-hidden"
:class="{ 'animate-pulse': copyingPatois }"
>
Copier le texte patois
<span class="flex items-center space-x-2">
<svg v-if="!copyingPatois" 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg v-else class="w-4 h-4 animate-spin" 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>
<span>{{ copyingPatois ? 'Copie...' : 'Copier le texte patois' }}</span>
</span>
</button>
<button
@click="copyFrenchText"
class="btn-secondary text-sm"
:disabled="copyingFrench"
class="btn-secondary text-sm relative overflow-hidden"
:class="{ 'animate-pulse': copyingFrench }"
>
Copier le texte français
<span class="flex items-center space-x-2">
<svg v-if="!copyingFrench" 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg v-else class="w-4 h-4 animate-spin" 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>
<span>{{ copyingFrench ? 'Copie...' : 'Copier le texte français' }}</span>
</span>
</button>
</div>
</div>
@@ -108,6 +130,8 @@ export default {
},
setup(props) {
const highlightedLine = ref(null)
const copyingPatois = ref(false)
const copyingFrench = ref(false)
// Diviser les textes en lignes
const patoisLines = computed(() => {
@@ -153,20 +177,28 @@ export default {
}
const copyPatoisText = async () => {
copyingPatois.value = true
try {
await navigator.clipboard.writeText(props.patoisText)
// Ici vous pourriez ajouter une notification de succès
} catch (err) {
console.error('Erreur lors de la copie:', err)
} finally {
setTimeout(() => {
copyingPatois.value = false
}, 1000)
}
}
const copyFrenchText = async () => {
copyingFrench.value = true
try {
await navigator.clipboard.writeText(props.frenchText)
// Ici vous pourriez ajouter une notification de succès
} catch (err) {
console.error('Erreur lors de la copie:', err)
} finally {
setTimeout(() => {
copyingFrench.value = false
}, 1000)
}
}
@@ -178,7 +210,9 @@ export default {
getDifficultyClass,
formatDate,
copyPatoisText,
copyFrenchText
copyFrenchText,
copyingPatois,
copyingFrench
}
}
}

View File

@@ -1,148 +1,195 @@
/**
* Service pour communiquer avec l'API backend des textes en patois
* Service pour communiquer avec l'API backend des textes
*/
const API_BASE_URL = 'http://localhost:3001/api'
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
export class TextService {
constructor() {
this.cache = new Map()
this.CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
}
/**
* Effectue une requête HTTP vers l'API
*/
async fetchAPI(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `Erreur HTTP: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error(`Erreur API ${endpoint}:`, error)
throw error
}
}
/**
* Charge un texte spécifique par son ID
*/
async loadText(textId) {
const cacheKey = `text-${textId}`
// Vérifier le cache
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey)
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data
}
constructor() {
this.cache = new Map()
this.CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
}
try {
const textData = await this.fetchAPI(`/texts/${textId}`)
/**
* Effectue une requête HTTP vers l'API
*/
async fetchAPI(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
})
// Mettre en cache
this.cache.set(cacheKey, {
data: textData,
timestamp: Date.now()
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `Erreur HTTP: ${response.status}`)
}
return textData
} catch (error) {
throw new Error(`Impossible de charger le texte "${textId}": ${error.message}`)
return await response.json()
} catch (error) {
console.error(`Erreur API ${endpoint}:`, error)
throw error
}
}
}
ni
/**
* Charge un texte spécifique par son ID
*/
async loadText(textId) {
const cacheKey = `text-${textId}`
/**
* Recherche dans les textes
const cacheKey = 'all-texts'
// Vérifier le cache
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey)
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data
}
}
// Vérifier le cache
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey)
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data
}
try {
const textData = await this.fetchAPI(`/texts/${textId}`)
// Mettre en cache
this.cache.set(cacheKey, {
data: textData,
timestamp: Date.now()
})
return textData
} catch (error) {
throw new Error(`Impossible de charger le texte "${textId}": ${error.message}`)
}
}
/**
* Charge la liste de tous les textes disponibles
*/
async loadAllTexts() {
const cacheKey = 'all-texts'
// Vérifier le cache
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey)
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data
}
}
try {
const texts = await this.fetchAPI('/texts')
// Mettre en cache
this.cache.set(cacheKey, {
data: texts,
timestamp: Date.now()
})
return texts
} catch (error) {
throw new Error(`Impossible de charger la liste des textes: ${error.message}`)
}
}
try {
const texts = await this.fetchAPI('/texts')
// Mettre en cache
this.cache.set(cacheKey, {
data: texts,
timestamp: Date.now()
})
text.metadata.titre_pt?.toLowerCase().includes(searchTerm) ||
text.frenchText?.toLowerCase().includes(searchTerm) ||
)
throw new Error(`Impossible de charger la liste des textes: ${error.message}`)
/**
* Recherche dans les textes avec filtres
*/
async searchTexts(query, filters = {}) {
try {
const params = new URLSearchParams()
// Filtres
if (filters.category) {
results = results.filter(text => text.metadata.categorie === filters.category)
* Recherche dans les textes avec filtres
if (query && query.trim()) {
params.append('search', query.trim())
}
if (filters.difficulty) {
try {
const params = new URLSearchParams()
if (filters.category) {
params.append('category', filters.category)
}
if (query && query.trim()) {
params.append('search', query.trim())
}
if (filters.difficulty) {
params.append('difficulty', filters.difficulty)
}
if (filters.category) {
params.append('category', filters.category)
}
if (filters.onlyWithAudio) {
params.append('onlyWithAudio', 'true')
}
if (filters.difficulty) {
params.append('difficulty', filters.difficulty)
}
if (filters.onlyWithAudio) {
params.append('onlyWithAudio', 'true')
}
const endpoint = params.toString() ? `/texts?${params.toString()}` : '/texts'
return await this.fetchAPI(endpoint)
} catch (error) {
throw new Error(`Erreur lors de la recherche: ${error.message}`)
}
}
const endpoint = params.toString() ? `/texts?${params.toString()}` : '/texts'
return await this.fetchAPI(endpoint)
} catch (error) {
throw new Error(`Erreur lors de la recherche: ${error.message}`)
}
const authors = new Set()
const categories = new Set()
let withAudio = 0
for (const text of this.textsList) {
if (text.metadata.auteur) authors.add(text.metadata.auteur)
try {
return await this.fetchAPI('/random')
} catch (error) {
throw new Error(`Impossible d'obtenir un texte aléatoire: ${error.message}`)
/**
* Obtient un texte aléatoire
*/
async getRandomText() {
try {
return await this.fetchAPI('/random')
} catch (error) {
throw new Error(`Impossible d'obtenir un texte aléatoire: ${error.message}`)
}
}
/**
* Obtient les statistiques de la collection
*/
async getStats() {
const cacheKey = 'stats'
// Vérifier le cache
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey)
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data
}
}
try {
const stats = await this.fetchAPI('/stats')
// Mettre en cache
this.cache.set(cacheKey, {
data: stats,
timestamp: Date.now()
})
return stats
} catch (error) {
throw new Error(`Impossible de charger les statistiques: ${error.message}`)
}
}
/**
* Obtient l'URL du fichier audio pour un texte
*/
getAudioUrl(textId) {
return `${API_BASE_URL}/texts/${textId}/audio`
}
/**
* Vérifie si l'API backend est accessible
*/
async checkHealth() {
try {
const health = await this.fetchAPI('/health')
return health.status === 'OK'
} catch (error) {
console.warn('API backend non accessible:', error.message)
return false
}
}
/**
* Vide le cache (utile pour forcer le rechargement)
*/
clearCache() {
this.cache.clear()
}
}
}
// Instance singleton
export const textService = new TextService()
const cacheKey = 'stats'
// Vérifier le cache
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey)
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
return cached.data
}

View File

@@ -105,10 +105,10 @@
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.titre_fr }}</h3>
<p class="text-sm mb-3" style="color: #6b7280">{{ text.titre_pt }}</p>
<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">
<span>{{ text.auteur }}</span>
<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">
<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"/>

View File

@@ -1,135 +0,0 @@
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="max-w-4xl mx-auto px-4">
<!-- En-tête -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-black mb-4">Texte au Hasard</h1>
<p class="mb-6" style="color: #6b7280">
Découvrez un texte choisi aléatoirement dans notre collection
</p>
<button
@click="loadRandomText"
:disabled="loading"
class="btn-primary"
>
<span v-if="loading" class="flex items-center">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Chargement...
</span>
<span v-else>
Nouveau texte au hasard
</span>
</button>
</div>
<!-- Contenu du texte -->
<div v-if="randomText && !loading">
<!-- Navigation vers le texte complet -->
<div class="text-center mb-6">
<router-link
:to="`/texte/${randomText.id}`"
class="btn-secondary"
>
Voir le texte complet
</router-link>
</div>
<!-- Affichage du texte -->
<TextDisplay
:patois-text="randomText.patoisText"
:french-text="randomText.frenchText"
:metadata="randomText.metadata"
/>
<!-- Actions -->
<div class="mt-8 flex justify-center space-x-4">
<button
@click="loadRandomText"
class="btn-primary"
>
Autre texte au hasard
</button>
<router-link
:to="`/texte/${randomText.id}`"
class="btn-secondary"
>
Lire avec audio
</router-link>
</div>
</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-if="error" class="text-center py-20">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Erreur</h3>
<p class="text-gray-500 mb-4">{{ error }}</p>
<button
@click="loadRandomText"
class="btn-primary"
>
Réessayer
</button>
</div>
<!-- Message si aucun texte -->
<div v-if="!randomText && !loading && !error" class="text-center py-20">
<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 disponible</h3>
<p class="text-gray-500">La collection est vide pour le moment.</p>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { textService } from '../services/textService.js'
import TextDisplay from '../components/TextDisplay.vue'
export default {
name: 'Random',
components: {
TextDisplay
},
setup() {
const randomText = ref(null)
const loading = ref(false)
const error = ref(null)
const loadRandomText = async () => {
loading.value = true
error.value = null
try {
const text = await textService.getRandomText()
randomText.value = text
} catch (err) {
error.value = err.message
console.error('Erreur lors du chargement du texte aléatoire:', err)
} finally {
loading.value = false
}
}
onMounted(() => {
loadRandomText()
})
return {
randomText,
loading,
error,
loadRandomText
}
}
}
</script>

View File

@@ -4,7 +4,7 @@
<!-- Navigation de retour -->
<div class="mb-6">
<button
@click="$router.go(-1)"
@click="$router.push('/textes')"
class="flex items-center hover:text-black transition-colors" style="color: #6b7280"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -17,10 +17,10 @@
<!-- Titre du texte -->
<div v-if="textData" class="text-center mb-8">
<h1 class="text-4xl font-bold text-black mb-2">
{{ textData.metadata.titre_fr }}
{{ textData.metadata.titre_pt }}
</h1>
<h2 class="text-2xl" style="color: #6b7280">
{{ textData.metadata.titre_pt }}
{{ textData.metadata.titre_fr }}
</h2>
</div>
@@ -107,8 +107,8 @@ export default {
const audioSrc = computed(() => {
if (!textData.value || !textData.value.hasAudio) return null
// Le chemin vers le fichier audio sera construit dynamiquement
return `/texts/${props.id}/audio.mp3`
// Utiliser l'URL de l'API backend pour le fichier audio
return textService.getAudioUrl(props.id)
})
const loadText = async () => {

View File

@@ -200,38 +200,8 @@ export default {
})
const filteredTexts = computed(() => {
let filtered = allTexts.value
// Recherche textuelle
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
filtered = filtered.filter(text => {
return (
text.metadata.titre_fr?.toLowerCase().includes(query) ||
text.metadata.titre_pt?.toLowerCase().includes(query) ||
text.metadata.auteur?.toLowerCase().includes(query) ||
text.frenchText?.toLowerCase().includes(query) ||
text.patoisText?.toLowerCase().includes(query)
)
})
}
// Filtre par catégorie
if (selectedCategory.value) {
filtered = filtered.filter(text => text.metadata.categorie === selectedCategory.value)
}
// Filtre par difficulté
if (selectedDifficulty.value) {
filtered = filtered.filter(text => text.metadata.difficulte === selectedDifficulty.value)
}
// Filtre audio seulement
if (onlyWithAudio.value) {
filtered = filtered.filter(text => text.hasAudio)
}
return filtered
// Ne pas filtrer localement - l'API backend s'en charge
return allTexts.value
})
const totalPages = computed(() => {
@@ -246,23 +216,39 @@ export default {
const loadTexts = async () => {
try {
const texts = await textService.loadAllTexts()
// 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
} 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 = []
}
}
}
const performSearch = () => {
const performSearch = async () => {
currentPage.value = 1
await loadTexts()
}
const clearFilters = () => {
const clearFilters = async () => {
searchQuery.value = ''
selectedCategory.value = ''
selectedDifficulty.value = ''
onlyWithAudio.value = false
currentPage.value = 1
// IMPORTANT: Recharger via l'API après avoir vidé les filtres
await loadTexts()
}
const getTextPreview = (text) => {