V1 Fonctionnelle (pas de secu de l'api, ni front quali)
This commit is contained in:
1394
backend/package-lock.json
generated
Normal file
1394
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
backend/package.json
Normal file
23
backend/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "patois-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API backend pour l'application Patois Franco-Provençal",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": ["patois", "franco-provençal", "api", "express"],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
430
backend/server.js
Normal file
430
backend/server.js
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const PORT = process.env.PORT || 3001
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors())
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// Chemin vers le dossier texts (dossier parent)
|
||||||
|
const TEXTS_DIR = process.env.TEXTS_PATH || path.join(__dirname, '..', 'texts')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service pour scanner et charger les textes depuis le dossier texts/
|
||||||
|
*/
|
||||||
|
class TextService {
|
||||||
|
constructor() {
|
||||||
|
this.cache = new Map()
|
||||||
|
this.lastScan = null
|
||||||
|
this.CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
// NOUVEAU : Index de recherche optimisé
|
||||||
|
this.searchIndex = new Map()
|
||||||
|
this.indexBuilt = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse le fichier metadata.txt
|
||||||
|
*/
|
||||||
|
parseMetadata(content) {
|
||||||
|
const metadata = {}
|
||||||
|
const lines = content.split('\n').filter(line => line.trim())
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const [key, ...valueParts] = line.split('=')
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
metadata[key.trim()] = valueParts.join('=').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un fichier existe
|
||||||
|
*/
|
||||||
|
async fileExists(filePath) {
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge un texte spécifique par son ID
|
||||||
|
*/
|
||||||
|
async loadText(textId) {
|
||||||
|
try {
|
||||||
|
const textDir = path.join(TEXTS_DIR, textId)
|
||||||
|
|
||||||
|
// Vérifier que le dossier existe
|
||||||
|
const dirExists = await this.fileExists(textDir)
|
||||||
|
if (!dirExists) {
|
||||||
|
throw new Error(`Texte "${textId}" non trouvé`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les fichiers principaux
|
||||||
|
const frPath = path.join(textDir, 'fr.txt')
|
||||||
|
const ptPath = path.join(textDir, 'pt.txt')
|
||||||
|
const metadataPath = path.join(textDir, 'metadata.txt')
|
||||||
|
|
||||||
|
const [frExists, ptExists, metadataExists] = await Promise.all([
|
||||||
|
this.fileExists(frPath),
|
||||||
|
this.fileExists(ptPath),
|
||||||
|
this.fileExists(metadataPath)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!frExists || !ptExists || !metadataExists) {
|
||||||
|
throw new Error(`Fichiers manquants pour le texte "${textId}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lire les fichiers
|
||||||
|
const [frenchText, patoisText, metadataContent] = await Promise.all([
|
||||||
|
fs.readFile(frPath, 'utf-8'),
|
||||||
|
fs.readFile(ptPath, 'utf-8'),
|
||||||
|
fs.readFile(metadataPath, 'utf-8')
|
||||||
|
])
|
||||||
|
|
||||||
|
// Vérifier si un fichier audio existe
|
||||||
|
const audioPath = path.join(textDir, 'audio.mp3')
|
||||||
|
const hasAudio = await this.fileExists(audioPath)
|
||||||
|
|
||||||
|
const metadata = this.parseMetadata(metadataContent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: textId,
|
||||||
|
frenchText: frenchText.trim(),
|
||||||
|
patoisText: patoisText.trim(),
|
||||||
|
metadata,
|
||||||
|
hasAudio
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors du chargement du texte ${textId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanne le dossier texts/ pour découvrir tous les textes disponibles
|
||||||
|
*/
|
||||||
|
async scanTexts() {
|
||||||
|
try {
|
||||||
|
// Vérifier si le cache est encore valide
|
||||||
|
if (this.lastScan && (Date.now() - this.lastScan) < this.CACHE_DURATION) {
|
||||||
|
return Array.from(this.cache.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirExists = await this.fileExists(TEXTS_DIR)
|
||||||
|
if (!dirExists) {
|
||||||
|
console.warn(`Dossier texts/ non trouvé: ${TEXTS_DIR}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(TEXTS_DIR, { withFileTypes: true })
|
||||||
|
const textDirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name)
|
||||||
|
|
||||||
|
const texts = []
|
||||||
|
for (const textId of textDirs) {
|
||||||
|
try {
|
||||||
|
const textData = await this.loadText(textId)
|
||||||
|
texts.push(textData)
|
||||||
|
this.cache.set(textId, textData)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Impossible de charger le texte ${textId}:`, error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastScan = Date.now()
|
||||||
|
|
||||||
|
// NOUVEAU : Construction de l'index de recherche après le scan
|
||||||
|
this.buildSearchIndex(texts)
|
||||||
|
|
||||||
|
return texts
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du scan des textes:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOUVEAU : Construit un index de recherche optimisé
|
||||||
|
*/
|
||||||
|
buildSearchIndex(texts) {
|
||||||
|
console.log('🔍 Construction de l\'index de recherche...')
|
||||||
|
this.searchIndex.clear()
|
||||||
|
|
||||||
|
for (const text of texts) {
|
||||||
|
const searchableContent = [
|
||||||
|
text.metadata.titre_fr || '',
|
||||||
|
text.metadata.titre_pt || '',
|
||||||
|
text.metadata.auteur || '',
|
||||||
|
text.metadata.categorie || '',
|
||||||
|
text.frenchText || '',
|
||||||
|
text.patoisText || ''
|
||||||
|
].join(' ').toLowerCase()
|
||||||
|
|
||||||
|
// Tokenisation simple mais efficace
|
||||||
|
const words = searchableContent
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(word => word.length > 2) // Ignore les mots de moins de 3 caractères
|
||||||
|
.map(word => word.replace(/[^\w]/g, '')) // Nettoie la ponctuation
|
||||||
|
.filter(word => word.length > 0)
|
||||||
|
|
||||||
|
// Indexation par mots
|
||||||
|
for (const word of words) {
|
||||||
|
if (!this.searchIndex.has(word)) {
|
||||||
|
this.searchIndex.set(word, new Set())
|
||||||
|
}
|
||||||
|
this.searchIndex.get(word).add(text.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.indexBuilt = true
|
||||||
|
console.log(`✅ Index construit : ${this.searchIndex.size} mots uniques indexés`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPTIMISÉ : Recherche rapide utilisant l'index
|
||||||
|
*/
|
||||||
|
searchWithIndex(query, allTexts) {
|
||||||
|
if (!query || !query.trim()) return allTexts
|
||||||
|
|
||||||
|
const searchTerms = query.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(term => term.length > 2)
|
||||||
|
.map(term => term.replace(/[^\w]/g, ''))
|
||||||
|
|
||||||
|
if (searchTerms.length === 0) return allTexts
|
||||||
|
|
||||||
|
// Recherche dans l'index pour chaque terme
|
||||||
|
let matchingIds = null
|
||||||
|
|
||||||
|
for (const term of searchTerms) {
|
||||||
|
const idsForTerm = new Set()
|
||||||
|
|
||||||
|
// Recherche exacte et préfixe
|
||||||
|
for (const [indexedWord, ids] of this.searchIndex) {
|
||||||
|
if (indexedWord.includes(term)) {
|
||||||
|
for (const id of ids) {
|
||||||
|
idsForTerm.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersection des résultats (ET logique)
|
||||||
|
if (matchingIds === null) {
|
||||||
|
matchingIds = idsForTerm
|
||||||
|
} else {
|
||||||
|
matchingIds = new Set([...matchingIds].filter(id => idsForTerm.has(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si aucun résultat, arrêter
|
||||||
|
if (matchingIds.size === 0) break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retourner les textes correspondants
|
||||||
|
return allTexts.filter(text => matchingIds && matchingIds.has(text.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche dans les textes
|
||||||
|
*/
|
||||||
|
async searchTexts(query, filters = {}) {
|
||||||
|
const allTexts = await this.scanTexts()
|
||||||
|
let results = [...allTexts]
|
||||||
|
|
||||||
|
// Recherche textuelle
|
||||||
|
if (query && query.trim()) {
|
||||||
|
// Utiliser l'index de recherche si construit
|
||||||
|
if (this.indexBuilt) {
|
||||||
|
results = this.searchWithIndex(query, results)
|
||||||
|
} else {
|
||||||
|
const searchTerm = query.toLowerCase().trim()
|
||||||
|
results = results.filter(text => {
|
||||||
|
return (
|
||||||
|
text.metadata.titre_fr?.toLowerCase().includes(searchTerm) ||
|
||||||
|
text.metadata.titre_pt?.toLowerCase().includes(searchTerm) ||
|
||||||
|
text.metadata.auteur?.toLowerCase().includes(searchTerm) ||
|
||||||
|
text.frenchText?.toLowerCase().includes(searchTerm) ||
|
||||||
|
text.patoisText?.toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtres
|
||||||
|
if (filters.category) {
|
||||||
|
results = results.filter(text => text.metadata.categorie === filters.category)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.difficulty) {
|
||||||
|
results = results.filter(text => text.metadata.difficulte === filters.difficulty)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.onlyWithAudio === 'true') {
|
||||||
|
results = results.filter(text => text.hasAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOUVEAU : Obtient toutes les catégories disponibles
|
||||||
|
*/
|
||||||
|
async getCategories() {
|
||||||
|
const allTexts = await this.scanTexts()
|
||||||
|
const categories = new Set()
|
||||||
|
|
||||||
|
for (const text of allTexts) {
|
||||||
|
if (text.metadata.categorie) {
|
||||||
|
categories.add(text.metadata.categorie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(categories).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques
|
||||||
|
*/
|
||||||
|
async getStats() {
|
||||||
|
const allTexts = await this.scanTexts()
|
||||||
|
const authors = new Set()
|
||||||
|
const categories = new Set()
|
||||||
|
let withAudio = 0
|
||||||
|
|
||||||
|
for (const text of allTexts) {
|
||||||
|
if (text.metadata.auteur) authors.add(text.metadata.auteur)
|
||||||
|
if (text.metadata.categorie) categories.add(text.metadata.categorie)
|
||||||
|
if (text.hasAudio) withAudio++
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTexts: allTexts.length,
|
||||||
|
withAudio,
|
||||||
|
authors: authors.size,
|
||||||
|
categories: categories.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance du service
|
||||||
|
const textService = new TextService()
|
||||||
|
|
||||||
|
// ==================== ROUTES API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/texts - Liste tous les textes
|
||||||
|
*/
|
||||||
|
app.get('/api/texts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { search, category, difficulty, onlyWithAudio } = req.query
|
||||||
|
|
||||||
|
if (search || category || difficulty || onlyWithAudio) {
|
||||||
|
// Recherche avec filtres
|
||||||
|
const results = await textService.searchTexts(search, {
|
||||||
|
category,
|
||||||
|
difficulty,
|
||||||
|
onlyWithAudio
|
||||||
|
})
|
||||||
|
res.json(results)
|
||||||
|
} else {
|
||||||
|
// Liste complète
|
||||||
|
const texts = await textService.scanTexts()
|
||||||
|
res.json(texts)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur GET /api/texts:', error)
|
||||||
|
res.status(500).json({ error: 'Erreur lors du chargement des textes' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/texts/:id - Détails d'un texte spécifique
|
||||||
|
*/
|
||||||
|
app.get('/api/texts/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const text = await textService.loadText(id)
|
||||||
|
res.json(text)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur GET /api/texts/${req.params.id}:`, error)
|
||||||
|
res.status(404).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/texts/random - Texte aléatoire
|
||||||
|
*/
|
||||||
|
app.get('/api/random', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const randomText = await textService.getRandomText()
|
||||||
|
res.json(randomText)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur GET /api/random:', error)
|
||||||
|
res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stats - Statistiques de la collection
|
||||||
|
*/
|
||||||
|
app.get('/api/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await textService.getStats()
|
||||||
|
res.json(stats)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur GET /api/stats:', error)
|
||||||
|
res.status(500).json({ error: 'Erreur lors du chargement des statistiques' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/texts/:id/audio - Fichier audio
|
||||||
|
*/
|
||||||
|
app.get('/api/texts/:id/audio', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const audioPath = path.join(TEXTS_DIR, id, 'audio.mp3')
|
||||||
|
|
||||||
|
const exists = await textService.fileExists(audioPath)
|
||||||
|
if (!exists) {
|
||||||
|
return res.status(404).json({ error: 'Fichier audio non trouvé' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servir le fichier audio
|
||||||
|
res.sendFile(audioPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur GET /api/texts/${req.params.id}/audio:`, error)
|
||||||
|
res.status(500).json({ error: 'Erreur lors du chargement du fichier audio' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Route de santé
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'OK', timestamp: new Date().toISOString() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Démarrage du serveur
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Serveur API Patois démarré sur http://localhost:${PORT}`)
|
||||||
|
console.log(`📁 Dossier texts: ${TEXTS_DIR}`)
|
||||||
|
console.log(`🔍 Endpoints disponibles:`)
|
||||||
|
console.log(` GET /api/texts - Liste des textes`)
|
||||||
|
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/texts/:id/audio - Fichier audio`)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-gray-600 mb-2">
|
<p class="text-gray-600 mb-2">
|
||||||
Site développé pour la préservation du patrimoine linguistique
|
Site développé avec soin par Paul Fournel
|
||||||
</p>
|
</p>
|
||||||
<p class="text-black font-medium">
|
<p class="text-black font-medium">
|
||||||
© 2025 Association du Patois Franco-Provençal
|
© 2025 Association du Patois Franco-Provençal
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -16,21 +16,36 @@
|
|||||||
class="nav-link"
|
class="nav-link"
|
||||||
:class="{ 'active': $route.name === 'Home' }"
|
: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>
|
||||||
<router-link
|
<router-link
|
||||||
to="/textes"
|
to="/textes"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:class="{ 'active': $route.name === 'Texts' }"
|
: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>
|
||||||
<router-link
|
<router-link
|
||||||
to="/au-hasard"
|
to="/au-hasard"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
:class="{ 'active': $route.name === 'Random' }"
|
: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>
|
</router-link>
|
||||||
|
|
||||||
<!-- Barre de recherche -->
|
<!-- Barre de recherche -->
|
||||||
|
|||||||
@@ -73,15 +73,37 @@
|
|||||||
<div class="mt-6 flex justify-center space-x-4">
|
<div class="mt-6 flex justify-center space-x-4">
|
||||||
<button
|
<button
|
||||||
@click="copyPatoisText"
|
@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>
|
||||||
<button
|
<button
|
||||||
@click="copyFrenchText"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +130,8 @@ export default {
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const highlightedLine = ref(null)
|
const highlightedLine = ref(null)
|
||||||
|
const copyingPatois = ref(false)
|
||||||
|
const copyingFrench = ref(false)
|
||||||
|
|
||||||
// Diviser les textes en lignes
|
// Diviser les textes en lignes
|
||||||
const patoisLines = computed(() => {
|
const patoisLines = computed(() => {
|
||||||
@@ -153,20 +177,28 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyPatoisText = async () => {
|
const copyPatoisText = async () => {
|
||||||
|
copyingPatois.value = true
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(props.patoisText)
|
await navigator.clipboard.writeText(props.patoisText)
|
||||||
// Ici vous pourriez ajouter une notification de succès
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la copie:', err)
|
console.error('Erreur lors de la copie:', err)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
copyingPatois.value = false
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyFrenchText = async () => {
|
const copyFrenchText = async () => {
|
||||||
|
copyingFrench.value = true
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(props.frenchText)
|
await navigator.clipboard.writeText(props.frenchText)
|
||||||
// Ici vous pourriez ajouter une notification de succès
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la copie:', err)
|
console.error('Erreur lors de la copie:', err)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
copyingFrench.value = false
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +210,9 @@ export default {
|
|||||||
getDifficultyClass,
|
getDifficultyClass,
|
||||||
formatDate,
|
formatDate,
|
||||||
copyPatoisText,
|
copyPatoisText,
|
||||||
copyFrenchText
|
copyFrenchText,
|
||||||
|
copyingPatois,
|
||||||
|
copyingFrench
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export class TextService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.cache = new Map()
|
this.cache = new Map()
|
||||||
this.CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
if (!response.ok) {
|
||||||
this.cache.set(cacheKey, {
|
const errorData = await response.json().catch(() => ({}))
|
||||||
data: textData,
|
throw new Error(errorData.error || `Erreur HTTP: ${response.status}`)
|
||||||
timestamp: Date.now()
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return textData
|
return await response.json()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Impossible de charger le texte "${textId}": ${error.message}`)
|
console.error(`Erreur API ${endpoint}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ni
|
/**
|
||||||
|
* Charge un texte spécifique par son ID
|
||||||
|
*/
|
||||||
|
async loadText(textId) {
|
||||||
|
const cacheKey = `text-${textId}`
|
||||||
|
|
||||||
/**
|
// Vérifier le cache
|
||||||
* Recherche dans les textes
|
if (this.cache.has(cacheKey)) {
|
||||||
const cacheKey = 'all-texts'
|
const cached = this.cache.get(cacheKey)
|
||||||
|
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
||||||
|
return cached.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier le cache
|
try {
|
||||||
if (this.cache.has(cacheKey)) {
|
const textData = await this.fetchAPI(`/texts/${textId}`)
|
||||||
const cached = this.cache.get(cacheKey)
|
|
||||||
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
// Mettre en cache
|
||||||
return cached.data
|
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, {
|
* Recherche dans les textes avec filtres
|
||||||
data: texts,
|
*/
|
||||||
timestamp: Date.now()
|
async searchTexts(query, filters = {}) {
|
||||||
})
|
try {
|
||||||
text.metadata.titre_pt?.toLowerCase().includes(searchTerm) ||
|
const params = new URLSearchParams()
|
||||||
text.frenchText?.toLowerCase().includes(searchTerm) ||
|
|
||||||
)
|
|
||||||
throw new Error(`Impossible de charger la liste des textes: ${error.message}`)
|
|
||||||
|
|
||||||
// Filtres
|
if (query && query.trim()) {
|
||||||
if (filters.category) {
|
params.append('search', query.trim())
|
||||||
results = results.filter(text => text.metadata.categorie === filters.category)
|
}
|
||||||
* Recherche dans les textes avec filtres
|
|
||||||
|
|
||||||
if (filters.difficulty) {
|
if (filters.category) {
|
||||||
try {
|
params.append('category', filters.category)
|
||||||
const params = new URLSearchParams()
|
}
|
||||||
|
|
||||||
if (query && query.trim()) {
|
if (filters.difficulty) {
|
||||||
params.append('search', query.trim())
|
params.append('difficulty', filters.difficulty)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.category) {
|
if (filters.onlyWithAudio) {
|
||||||
params.append('category', filters.category)
|
params.append('onlyWithAudio', 'true')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.difficulty) {
|
const endpoint = params.toString() ? `/texts?${params.toString()}` : '/texts'
|
||||||
params.append('difficulty', filters.difficulty)
|
return await this.fetchAPI(endpoint)
|
||||||
}
|
} catch (error) {
|
||||||
|
throw new Error(`Erreur lors de la recherche: ${error.message}`)
|
||||||
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 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)
|
* Obtient un texte aléatoire
|
||||||
try {
|
*/
|
||||||
return await this.fetchAPI('/random')
|
async getRandomText() {
|
||||||
} catch (error) {
|
try {
|
||||||
throw new Error(`Impossible d'obtenir un texte aléatoire: ${error.message}`)
|
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
|
// Instance singleton
|
||||||
export const textService = new TextService()
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,10 +105,10 @@
|
|||||||
class="bg-white rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
class="bg-white rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
@click="$router.push(`/texte/${text.id}`)"
|
@click="$router.push(`/texte/${text.id}`)"
|
||||||
>
|
>
|
||||||
<h3 class="font-semibold mb-2">{{ text.titre_fr }}</h3>
|
<h3 class="font-semibold mb-2">{{ text.metadata.titre_fr }}</h3>
|
||||||
<p class="text-sm mb-3" style="color: #6b7280">{{ text.titre_pt }}</p>
|
<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">
|
<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">
|
<span v-if="text.hasAudio" class="flex items-center">
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v6.114A4.369 4.369 0 005 11c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.369 4.369 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z"/>
|
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v6.114A4.369 4.369 0 005 11c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.369 4.369 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z"/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<!-- Navigation de retour -->
|
<!-- Navigation de retour -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button
|
<button
|
||||||
@click="$router.go(-1)"
|
@click="$router.push('/textes')"
|
||||||
class="flex items-center hover:text-black transition-colors" style="color: #6b7280"
|
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">
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
<!-- Titre du texte -->
|
<!-- Titre du texte -->
|
||||||
<div v-if="textData" class="text-center mb-8">
|
<div v-if="textData" class="text-center mb-8">
|
||||||
<h1 class="text-4xl font-bold text-black mb-2">
|
<h1 class="text-4xl font-bold text-black mb-2">
|
||||||
{{ textData.metadata.titre_fr }}
|
{{ textData.metadata.titre_pt }}
|
||||||
</h1>
|
</h1>
|
||||||
<h2 class="text-2xl" style="color: #6b7280">
|
<h2 class="text-2xl" style="color: #6b7280">
|
||||||
{{ textData.metadata.titre_pt }}
|
{{ textData.metadata.titre_fr }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,8 +107,8 @@ export default {
|
|||||||
|
|
||||||
const audioSrc = computed(() => {
|
const audioSrc = computed(() => {
|
||||||
if (!textData.value || !textData.value.hasAudio) return null
|
if (!textData.value || !textData.value.hasAudio) return null
|
||||||
// Le chemin vers le fichier audio sera construit dynamiquement
|
// Utiliser l'URL de l'API backend pour le fichier audio
|
||||||
return `/texts/${props.id}/audio.mp3`
|
return textService.getAudioUrl(props.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadText = async () => {
|
const loadText = async () => {
|
||||||
|
|||||||
@@ -200,38 +200,8 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filteredTexts = computed(() => {
|
const filteredTexts = computed(() => {
|
||||||
let filtered = allTexts.value
|
// Ne pas filtrer localement - l'API backend s'en charge
|
||||||
|
return 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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
const totalPages = computed(() => {
|
||||||
@@ -246,23 +216,39 @@ export default {
|
|||||||
|
|
||||||
const loadTexts = async () => {
|
const loadTexts = async () => {
|
||||||
try {
|
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
|
allTexts.value = texts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des textes:', 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
|
currentPage.value = 1
|
||||||
|
await loadTexts()
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = async () => {
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedDifficulty.value = ''
|
selectedDifficulty.value = ''
|
||||||
onlyWithAudio.value = false
|
onlyWithAudio.value = false
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
// IMPORTANT: Recharger via l'API après avoir vidé les filtres
|
||||||
|
await loadTexts()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTextPreview = (text) => {
|
const getTextPreview = (text) => {
|
||||||
|
|||||||
28
start.bat
Normal file
28
start.bat
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@echo off
|
||||||
|
echo ==============================================
|
||||||
|
echo Demarrage de l'application Patois
|
||||||
|
echo Franco-Provencal (Backend + Frontend)
|
||||||
|
echo ==============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/2] Demarrage du serveur API backend...
|
||||||
|
cd /d "%~dp0backend"
|
||||||
|
start "API Backend" cmd /k "npm start"
|
||||||
|
|
||||||
|
echo [2/2] Attente de 3 secondes pour le demarrage de l'API...
|
||||||
|
timeout /t 3 /nobreak > nul
|
||||||
|
|
||||||
|
echo [2/2] Demarrage du frontend Vue.js...
|
||||||
|
cd /d "%~dp0"
|
||||||
|
start "Frontend Vue.js" cmd /k "npm run dev"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==============================================
|
||||||
|
echo Application demarree avec succes !
|
||||||
|
echo.
|
||||||
|
echo Frontend: http://localhost:5173
|
||||||
|
echo API: http://localhost:3001
|
||||||
|
echo ==============================================
|
||||||
|
echo.
|
||||||
|
echo Fermez cette fenetre quand vous voulez arreter l'application.
|
||||||
|
pause
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
l était une fois deux frères, Pierino et Maurice, qui habitaient le même village, dans deux maisons proches l'une de l'autre. Un jour Pierino propose : « Qu’en penses-tu si on rassemblait nos épargnes et que l’on achetait une vache ? ». Maurice accepte et le lendemain ils se mettent en route pour aller à la foire du village voisin.
|
Il était une fois deux frères, Pierino et Maurice, qui habitaient le même village, dans deux maisons proches l'une de l'autre. Un jour Pierino propose : « Qu’en penses-tu si on rassemblait nos épargnes et que l’on achetait une vache ? ». Maurice accepte et le lendemain ils se mettent en route pour aller à la foire du village voisin.
|
||||||
En marchant Pierino, qui se croyait bien plus malin que Maurice, pense : « Je te mettrais au pas ».
|
En marchant Pierino, qui se croyait bien plus malin que Maurice, pense : « Je te mettrais au pas ».
|
||||||
A la foire, après avoir bien examiné toutes les vaches, des pie noirs à la tête blanche jusqu’aux châtaines, des reines jusqu’aux vaches à lait, des vaches aux cornes bouclées, jusqu’à celles aux cornes retournées vers le haut, des vaches aux épaules abaissées jusqu’à celles à l’échine courbée, ils en choisissent une, Lenetta, une pie-rouge bien grasse et la payent avec l'argent qu'ils avaient mis en commun.
|
A la foire, après avoir bien examiné toutes les vaches, des pie noirs à la tête blanche jusqu’aux châtaines, des reines jusqu’aux vaches à lait, des vaches aux cornes bouclées, jusqu’à celles aux cornes retournées vers le haut, des vaches aux épaules abaissées jusqu’à celles à l’échine courbée, ils en choisissent une, Lenetta, une pie-rouge bien grasse et la payent avec l'argent qu'ils avaient mis en commun.
|
||||||
Quand la foire est finie, les deux frères retournent à la maison : « On l’amènera dans mon étable, elle est plus grande et sèche », dit Pierino. Mais ils commencent tout de suite à discuter pour savoir dans quelle étable mettre la vache.
|
Quand la foire est finie, les deux frères retournent à la maison : « On l’amènera dans mon étable, elle est plus grande et sèche », dit Pierino. Mais ils commencent tout de suite à discuter pour savoir dans quelle étable mettre la vache.
|
||||||
|
|||||||
Reference in New Issue
Block a user