Init du front

This commit is contained in:
ElPoyo
2025-09-26 16:37:33 +02:00
commit 65846640cf
32 changed files with 4159 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>patois</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2239
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "patois",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/forms": "^0.5.10",
"vue": "^3.5.21",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.13",
"vite": "^7.1.7"
},
"description": "This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.",
"main": "postcss.config.js",
"keywords": [],
"author": "",
"license": "ISC"
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

38
src/App.vue Normal file
View File

@@ -0,0 +1,38 @@
<template>
<div id="app" class="min-h-screen flex flex-col">
<NavigationBar />
<main class="flex-1">
<router-view />
</main>
<FooterComponent />
</div>
</template>
<script setup>
import NavigationBar from './components/NavigationBar.vue'
import FooterComponent from './components/FooterComponent.vue'
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
html, body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
}
</style>

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,275 @@
<template>
<div class="bg-gray-50 rounded-lg p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">Lecture Audio</h3>
<div class="flex items-center space-x-2">
<!-- Contrôle du volume -->
<svg class="h-4 w-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.78L4.137 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.137l4.246-3.78z" clip-rule="evenodd"/>
<path d="M11.738 9.222a4.002 4.002 0 000-0.444c0.779-0.771 1.278-1.834 1.278-3.001s-0.499-2.23-1.278-3.001A1 1 0 0111.738 4.222a2 2 0 010 3.556A1 1 0 0011.738 9.222z"/>
</svg>
<input
v-model="volume"
@input="updateVolume"
type="range"
min="0"
max="100"
class="w-20 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
/>
<span class="text-sm text-gray-500 w-8">{{ volume }}%</span>
</div>
</div>
<!-- Contrôles de lecture -->
<div class="flex items-center justify-center space-x-4 mb-4">
<button
@click="rewind"
class="p-2 rounded-full hover:bg-gray-200 transition-colors"
:disabled="!hasAudio"
>
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M8.445 14.832A1 1 0 0010 14v-2.798l5.445 3.63A1 1 0 0017 14V6a1 1 0 00-1.555-.832L10 8.798V6a1 1 0 00-1.555-.832l-6 4a1 1 0 000 1.664l6 4z"/>
</svg>
</button>
<button
@click="togglePlay"
class="btn-primary p-4 rounded-full"
:disabled="!hasAudio"
>
<svg v-if="!isPlaying" class="h-8 w-8" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
</svg>
<svg v-else class="h-8 w-8" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 002 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</button>
<button
@click="fastForward"
class="p-2 rounded-full hover:bg-gray-200 transition-colors"
:disabled="!hasAudio"
>
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M4.555 5.168A1 1 0 003 6v8a1 1 0 001.555.832L10 11.202V14a1 1 0 001.555.832l6-4a1 1 0 000-1.664l-6-4A1 1 0 0010 6v2.798l-5.445-3.63z"/>
</svg>
</button>
</div>
<!-- Barre de progression -->
<div class="mb-4">
<div class="flex items-center justify-between text-sm text-gray-500 mb-2">
<span>{{ formatTime(currentTime) }}</span>
<span>{{ formatTime(duration) }}</span>
</div>
<div
class="w-full h-2 bg-gray-200 rounded-full cursor-pointer relative"
@click="seekTo"
ref="progressBar"
>
<div
class="h-full bg-black rounded-full transition-all duration-100"
:style="{ width: progress + '%' }"
></div>
</div>
</div>
<!-- Vitesse de lecture -->
<div class="flex items-center justify-center space-x-2">
<span class="text-sm text-gray-500">Vitesse:</span>
<select
v-model="playbackRate"
@change="updatePlaybackRate"
class="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:border-black"
:disabled="!hasAudio"
>
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
</div>
<!-- Message si pas d'audio -->
<div v-if="!hasAudio" class="text-center text-gray-500 mt-4">
<p class="text-sm">Aucun fichier audio disponible pour ce texte</p>
</div>
<!-- Audio element (caché) -->
<audio
ref="audioPlayer"
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnded"
preload="metadata"
>
<source :src="audioSrc" type="audio/mpeg" v-if="audioSrc">
Votre navigateur ne supporte pas l'audio HTML5.
</audio>
</div>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
export default {
name: 'AudioPlayer',
props: {
audioSrc: {
type: String,
default: null
}
},
setup(props) {
const audioPlayer = ref(null)
const progressBar = ref(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(70)
const playbackRate = ref(1)
const hasAudio = computed(() => !!props.audioSrc)
const progress = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const formatTime = (seconds) => {
if (isNaN(seconds)) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const togglePlay = () => {
if (!audioPlayer.value || !hasAudio.value) return
if (isPlaying.value) {
audioPlayer.value.pause()
isPlaying.value = false
} else {
audioPlayer.value.play()
isPlaying.value = true
}
}
const rewind = () => {
if (!audioPlayer.value) return
audioPlayer.value.currentTime = Math.max(0, audioPlayer.value.currentTime - 10)
}
const fastForward = () => {
if (!audioPlayer.value) return
audioPlayer.value.currentTime = Math.min(duration.value, audioPlayer.value.currentTime + 10)
}
const seekTo = (event) => {
if (!audioPlayer.value || !progressBar.value) return
const rect = progressBar.value.getBoundingClientRect()
const clickX = event.clientX - rect.left
const percentage = clickX / rect.width
const newTime = percentage * duration.value
audioPlayer.value.currentTime = newTime
}
const updateVolume = () => {
if (!audioPlayer.value) return
audioPlayer.value.volume = volume.value / 100
}
const updatePlaybackRate = () => {
if (!audioPlayer.value) return
audioPlayer.value.playbackRate = playbackRate.value
}
const onLoadedMetadata = () => {
if (audioPlayer.value) {
duration.value = audioPlayer.value.duration
updateVolume()
updatePlaybackRate()
}
}
const onTimeUpdate = () => {
if (audioPlayer.value) {
currentTime.value = audioPlayer.value.currentTime
}
}
const onEnded = () => {
isPlaying.value = false
currentTime.value = 0
if (audioPlayer.value) {
audioPlayer.value.currentTime = 0
}
}
// Watcher pour recharger l'audio quand l'src change
watch(() => props.audioSrc, () => {
if (audioPlayer.value) {
isPlaying.value = false
currentTime.value = 0
duration.value = 0
if (props.audioSrc) {
audioPlayer.value.load()
}
}
})
onMounted(() => {
if (audioPlayer.value && props.audioSrc) {
audioPlayer.value.load()
}
})
return {
audioPlayer,
progressBar,
isPlaying,
currentTime,
duration,
volume,
playbackRate,
hasAudio,
progress,
formatTime,
togglePlay,
rewind,
fastForward,
seekTo,
updateVolume,
updatePlaybackRate,
onLoadedMetadata,
onTimeUpdate,
onEnded
}
}
}
</script>
<style scoped>
.slider::-webkit-slider-thumb {
appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #000;
cursor: pointer;
}
.slider::-moz-range-thumb {
height: 16px;
width: 16px;
border-radius: 50%;
background: #000;
cursor: pointer;
border: none;
}
</style>

View File

@@ -0,0 +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">
<div class="text-center">
<p class="text-gray-600 mb-2">
Site développé pour la préservation du patrimoine linguistique
</p>
<p class="text-black font-medium">
© 2025 Association du Patois Franco-Provençal
</p>
<p class="text-sm text-gray-500 mt-2">
Tous droits réservés - Conservation et transmission de notre langue régionale
</p>
</div>
</div>
</footer>
</template>
<script>
export default {
name: 'FooterComponent'
}
</script>

View File

@@ -0,0 +1,43 @@
<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

@@ -0,0 +1,139 @@
<template>
<nav class="bg-white border-b border-gray-200 sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo/Titre -->
<div class="flex items-center">
<router-link to="/" class="text-2xl font-bold text-black transition-colors" style="color: black; --hover-color: #6b7280;" onmouseover="this.style.color='#6b7280'" onmouseout="this.style.color='black'">
Patois Franco-Provençal
</router-link>
</div>
<!-- Navigation principale -->
<div class="hidden md:flex items-center space-x-8">
<router-link
to="/"
class="nav-link"
:class="{ 'active': $route.name === 'Home' }"
>
Accueil
</router-link>
<router-link
to="/textes"
class="nav-link"
:class="{ 'active': $route.name === 'Texts' }"
>
Textes
</router-link>
<router-link
to="/au-hasard"
class="nav-link"
:class="{ 'active': $route.name === 'Random' }"
>
Au Hasard
</router-link>
<!-- Barre de recherche -->
<div class="relative">
<input
v-model="searchQuery"
@keyup.enter="performSearch"
type="text"
placeholder="Rechercher..."
class="w-64 px-4 py-2 pl-10 pr-4 text-sm border border-gray-300 rounded-full focus:outline-none focus:border-black focus:ring-1 focus:ring-black"
/>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
</div>
<!-- Menu mobile -->
<div class="md:hidden">
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="text-black focus:outline-none" style="color: black; --hover-color: #6b7280;" onmouseover="this.style.color='#6b7280'" onmouseout="this.style.color='black'"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
<!-- Menu mobile déplié -->
<div v-if="mobileMenuOpen" class="md:hidden border-t border-gray-200 pt-4 pb-4">
<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 class="pt-2">
<input
v-model="searchQuery"
@keyup.enter="performSearch"
type="text"
placeholder="Rechercher..."
class="w-full px-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:border-black"
/>
</div>
</div>
</div>
</div>
</nav>
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'NavigationBar',
setup() {
const router = useRouter()
const searchQuery = ref('')
const mobileMenuOpen = ref(false)
const performSearch = () => {
if (searchQuery.value.trim()) {
router.push({
name: 'Texts',
query: { search: searchQuery.value.trim() }
})
mobileMenuOpen.value = false
}
}
return {
searchQuery,
mobileMenuOpen,
performSearch
}
}
}
</script>
<style scoped>
.nav-link {
@apply font-medium transition-colors duration-200 pb-1;
color: #6b7280;
}
.nav-link:hover {
color: black;
}
.nav-link.active {
@apply text-black border-b-2 border-black;
}
.nav-link-mobile {
@apply font-medium transition-colors duration-200 py-2;
color: #6b7280;
}
.nav-link-mobile:hover {
color: black;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<!-- En-tête avec les titres -->
<div class="grid grid-cols-2 gap-8 mb-6 pb-4 border-b border-gray-200">
<div class="text-center">
<h3 class="text-xl font-semibold text-gray-900">Patois Franco-Provençal</h3>
<p class="text-sm mt-1" style="color: #6b7280">{{ metadata?.titre_pt || 'Texte en patois' }}</p>
</div>
<div class="text-center">
<h3 class="text-xl font-semibold text-gray-900">Français</h3>
<p class="text-sm mt-1" style="color: #6b7280">{{ metadata?.titre_fr || 'Texte en français' }}</p>
</div>
</div>
<!-- Contenu des textes alignés -->
<div class="grid grid-cols-2 gap-8">
<!-- Colonne Patois -->
<div class="space-y-3">
<div
v-for="(line, index) in patoisLines"
:key="`patois-${index}`"
class="text-patois py-2 px-3 rounded hover:bg-gray-50 transition-colors cursor-pointer"
:class="{ 'bg-yellow-50 border-l-4 border-yellow-400': highlightedLine === index }"
@click="highlightLine(index)"
>
{{ line }}
</div>
</div>
<!-- Colonne Français -->
<div class="space-y-3">
<div
v-for="(line, index) in frenchLines"
:key="`french-${index}`"
class="text-french py-2 px-3 rounded hover:bg-gray-50 transition-colors cursor-pointer"
:class="{ 'bg-yellow-50 border-l-4 border-yellow-400': highlightedLine === index }"
@click="highlightLine(index)"
>
{{ line }}
</div>
</div>
</div>
<!-- Informations supplémentaires -->
<div v-if="metadata" class="mt-8 pt-6 border-t border-gray-200">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<div v-if="metadata.auteur" class="flex items-center space-x-2">
<span class="font-medium" style="color: #6b7280">Auteur:</span>
<span style="color: #6b7280">{{ metadata.auteur }}</span>
</div>
<div v-if="metadata.traducteur" class="flex items-center space-x-2">
<span class="font-medium" style="color: #6b7280">Traducteur:</span>
<span style="color: #6b7280">{{ metadata.traducteur }}</span>
</div>
<div v-if="metadata.categorie" class="flex items-center space-x-2">
<span class="font-medium" style="color: #6b7280">Catégorie:</span>
<span style="color: #6b7280">{{ metadata.categorie }}</span>
</div>
<div v-if="metadata.difficulte" class="flex items-center space-x-2">
<span class="font-medium" style="color: #6b7280">Difficulté:</span>
<span class="px-2 py-1 rounded-full text-xs" :class="getDifficultyClass(metadata.difficulte)">
{{ metadata.difficulte }}
</span>
</div>
<div v-if="metadata.date_creation" class="flex items-center space-x-2">
<span class="font-medium" style="color: #6b7280">Date:</span>
<span style="color: #6b7280">{{ formatDate(metadata.date_creation) }}</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-center space-x-4">
<button
@click="copyPatoisText"
class="btn-secondary text-sm"
>
Copier le texte patois
</button>
<button
@click="copyFrenchText"
class="btn-secondary text-sm"
>
Copier le texte français
</button>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
export default {
name: 'TextDisplay',
props: {
patoisText: {
type: String,
default: ''
},
frenchText: {
type: String,
default: ''
},
metadata: {
type: Object,
default: () => ({})
}
},
setup(props) {
const highlightedLine = ref(null)
// Diviser les textes en lignes
const patoisLines = computed(() => {
return props.patoisText
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
})
const frenchLines = computed(() => {
return props.frenchText
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
})
// Fonctions utilitaires
const highlightLine = (index) => {
highlightedLine.value = highlightedLine.value === index ? null : index
}
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) => {
try {
const date = new Date(dateString)
return date.toLocaleDateString('fr-FR')
} catch {
return dateString
}
}
const copyPatoisText = async () => {
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)
}
}
const copyFrenchText = async () => {
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)
}
}
return {
highlightedLine,
patoisLines,
frenchLines,
highlightLine,
getDifficultyClass,
formatDate,
copyPatoisText,
copyFrenchText
}
}
}
</script>
<style scoped>
/* Assurer que les lignes correspondent visuellement */
.text-patois,
.text-french {
min-height: 2.5rem;
display: flex;
align-items: center;
}
</style>

6
src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')

44
src/router/index.js Normal file
View File

@@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Texts from '../views/Texts.vue'
import TextReader from '../views/TextReader.vue'
import { textService } from '../services/textService.js'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/textes',
name: 'Texts',
component: Texts
},
{
path: '/texte/:id',
name: 'TextReader',
component: TextReader,
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')
}
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

148
src/services/textService.js Normal file
View File

@@ -0,0 +1,148 @@
/**
* Service pour communiquer avec l'API backend des textes en patois
*/
const API_BASE_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
}
}
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}`)
}
}
ni
/**
* 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
}
}
}
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}`)
// Filtres
if (filters.category) {
results = results.filter(text => text.metadata.categorie === filters.category)
* Recherche dans les textes avec filtres
if (filters.difficulty) {
try {
const params = new URLSearchParams()
if (query && query.trim()) {
params.append('search', query.trim())
}
if (filters.category) {
params.append('category', filters.category)
}
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 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}`)
}
}
}
// 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
}

33
src/style.css Normal file
View File

@@ -0,0 +1,33 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@theme {
--color-gray-custom: #6b7280;
--color-gray-custom-light: #9ca3af;
}
@layer base {
body {
font-family: 'Inter', sans-serif;
@apply bg-white text-black leading-relaxed;
}
}
@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;
}
.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;
}
.text-patois {
@apply font-medium text-lg leading-relaxed;
}
.text-french {
@apply font-normal text-lg leading-relaxed;
color: var(--color-gray-custom);
}
}

174
src/views/Home.vue Normal file
View File

@@ -0,0 +1,174 @@
<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-5xl font-bold text-black mb-6">
Patois Franco-Provençal
</h1>
<p class="text-xl 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.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<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 class="py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4">
<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">
<svg class="w-8 h-8 text-black" 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>
</div>
<h3 class="text-lg font-semibold mb-2">Textes Authentiques</h3>
<p style="color: #6b7280">
Collection de textes traditionnels et contemporains en patois franco-provençal
</p>
</div>
<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">
<svg class="w-8 h-8 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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">
Écoutez la prononciation authentique avec nos enregistrements audio
</p>
</div>
<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">
<svg class="w-8 h-8 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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">
Trouvez facilement des textes par mot-clé, auteur ou thématique
</p>
</div>
</div>
</div>
</section>
<!-- Section statistiques -->
<section class="py-16">
<div class="max-w-4xl mx-auto px-4 text-center">
<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>
<div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.withAudio }}</div>
<div 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>
<div>
<div class="text-4xl font-bold text-black mb-2">{{ stats.categories }}</div>
<div style="color: #6b7280">Catégories</div>
</div>
</div>
</div>
</section>
<!-- Section récents -->
<section class="py-16 bg-gray-50">
<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>
</div>
<div class="grid md:grid-cols-3 gap-6">
<div
v-for="text in recentTexts"
:key="text.id"
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>
<div class="flex justify-between items-center text-xs text-gray-500">
<span>{{ text.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"/>
</svg>
Audio
</span>
</div>
</div>
</div>
<div class="text-center mt-8">
<router-link to="/textes" class="btn-secondary">
Voir tous les textes
</router-link>
</div>
</div>
</section>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { textService } from '../services/textService.js'
export default {
name: 'Home',
setup() {
const stats = ref({
totalTexts: 0,
withAudio: 0,
authors: 0,
categories: 0
})
const recentTexts = ref([])
// Fonction pour charger les statistiques et textes récents
const loadData = async () => {
try {
// Charger les statistiques
const statsData = await textService.getStats()
stats.value = statsData
// Charger les textes récents (tous les textes pour l'instant)
const allTexts = await textService.loadAllTexts()
recentTexts.value = allTexts.slice(0, 3) // Limiter à 3 textes récents
} catch (error) {
console.error('Erreur lors du chargement des données:', error)
}
}
onMounted(() => {
loadData()
})
return {
stats,
recentTexts
}
}
}
</script>

135
src/views/Random.vue Normal file
View File

@@ -0,0 +1,135 @@
<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>

186
src/views/TextReader.vue Normal file
View File

@@ -0,0 +1,186 @@
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="max-w-6xl mx-auto px-4">
<!-- Navigation de retour -->
<div class="mb-6">
<button
@click="$router.go(-1)"
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Retour à la liste
</button>
</div>
<!-- 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 }}
</h1>
<h2 class="text-2xl" style="color: #6b7280">
{{ textData.metadata.titre_pt }}
</h2>
</div>
<!-- Lecteur audio -->
<AudioPlayer
v-if="textData && textData.hasAudio"
:audio-src="audioSrc"
class="mb-8"
/>
<!-- Affichage du texte -->
<TextDisplay
v-if="textData"
:patois-text="textData.patoisText"
:french-text="textData.frenchText"
:metadata="textData.metadata"
/>
<!-- Actions supplémentaires -->
<div v-if="textData" class="mt-8 flex justify-center space-x-4">
<button
@click="goToRandomText"
class="btn-secondary"
>
Texte au hasard
</button>
<button
@click="shareText"
class="btn-secondary"
>
Partager
</button>
</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 de chargement</h3>
<p class="text-gray-500 mb-4">{{ error }}</p>
<button
@click="loadText"
class="btn-primary"
>
Réessayer
</button>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { textService } from '../services/textService.js'
import AudioPlayer from '../components/AudioPlayer.vue'
import TextDisplay from '../components/TextDisplay.vue'
export default {
name: 'TextReader',
components: {
AudioPlayer,
TextDisplay
},
props: {
id: {
type: String,
required: true
}
},
setup(props) {
const route = useRoute()
const router = useRouter()
const textData = ref(null)
const loading = ref(true)
const error = ref(null)
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`
})
const loadText = async () => {
loading.value = true
error.value = null
try {
const text = await textService.loadText(props.id)
textData.value = text
} catch (err) {
error.value = err.message
console.error('Erreur lors du chargement du texte:', err)
} finally {
loading.value = false
}
}
const goToRandomText = async () => {
try {
const randomText = await textService.getRandomText()
router.push(`/texte/${randomText.id}`)
} catch (err) {
router.push('/au-hasard')
}
}
const shareText = async () => {
const url = window.location.href
const title = textData.value?.metadata?.titre_fr || 'Texte en patois'
if (navigator.share) {
try {
await navigator.share({
title: title,
url: url
})
} catch (err) {
// Fallback pour la copie dans le presse-papier
copyToClipboard(url)
}
} else {
copyToClipboard(url)
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
// Ici vous pourriez ajouter une notification de succès
} catch (err) {
console.error('Erreur lors de la copie:', err)
}
}
// Recharger le texte quand l'ID change
watch(() => props.id, () => {
loadText()
}, { immediate: true })
onMounted(() => {
loadText()
})
return {
textData,
loading,
error,
audioSrc,
loadText,
goToRandomText,
shareText
}
}
}
</script>

325
src/views/Texts.vue Normal file
View File

@@ -0,0 +1,325 @@
<template>
<div class="min-h-screen bg-white py-8">
<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>
<p class="text-gray-600 mb-6">
Explorez notre collection de textes en patois franco-provençal
</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"
>
<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"
>
<option value="">Toutes les difficultés</option>
<option value="Facile">Facile</option>
<option value="Moyen">Moyen</option>
<option value="Difficile">Difficile</option>
</select>
</div>
</div>
<!-- Filtres supplémentaires -->
<div class="flex flex-wrap gap-4 mt-4">
<label class="flex items-center">
<input
v-model="onlyWithAudio"
@change="performSearch"
type="checkbox"
class="rounded mr-2"
/>
<span class="text-sm">Seulement avec audio</span>
</label>
<button
@click="clearFilters"
class="text-sm text-gray-600 hover:text-black"
>
Effacer les filtres
</button>
</div>
</div>
</div>
<!-- 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>
</p>
</div>
<!-- Liste des textes -->
<div class="grid gap-6">
<div
v-for="text in paginatedTexts"
:key="text.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}`)"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<h2 class="text-xl font-semibold text-black mb-2">
{{ text.metadata.titre_fr }}
</h2>
<p class="text-lg mb-3" style="color: #6b7280">
{{ text.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>
<span v-if="text.metadata.traducteur">
<strong>Traducteur:</strong> {{ text.metadata.traducteur }}
</span>
<span v-if="text.metadata.categorie">
<strong>Catégorie:</strong> {{ text.metadata.categorie }}
</span>
</div>
<!-- Aperçu du texte -->
<p class="text-sm line-clamp-3" style="color: #6b7280">
{{ getTextPreview(text.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">
<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>
Audio
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="mt-8 flex justify-center">
<div class="flex space-x-2">
<button
@click="currentPage = Math.max(1, currentPage - 1)"
:disabled="currentPage === 1"
class="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Précédent
</button>
<span class="px-4 py-2 bg-black text-white rounded-lg">
{{ currentPage }} / {{ totalPages }}
</span>
<button
@click="currentPage = Math.min(totalPages, currentPage + 1)"
:disabled="currentPage === totalPages"
class="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Suivant
</button>
</div>
</div>
<!-- Message si aucun résultat -->
<div v-if="filteredTexts.length === 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>
<p class="text-gray-500">Essayez de modifier vos critères de recherche.</p>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { textService } from '../services/textService.js'
export default {
name: 'Texts',
setup() {
const route = useRoute()
const allTexts = ref([])
const searchQuery = ref('')
const selectedCategory = ref('')
const selectedDifficulty = ref('')
const onlyWithAudio = ref(false)
const currentPage = ref(1)
const textsPerPage = 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()
})
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
})
const totalPages = computed(() => {
return Math.ceil(filteredTexts.value.length / textsPerPage)
})
const paginatedTexts = computed(() => {
const start = (currentPage.value - 1) * textsPerPage
const end = start + textsPerPage
return filteredTexts.value.slice(start, end)
})
const loadTexts = async () => {
try {
const texts = await textService.loadAllTexts()
allTexts.value = texts
} catch (error) {
console.error('Erreur lors du chargement des textes:', error)
}
}
const performSearch = () => {
currentPage.value = 1
}
const clearFilters = () => {
searchQuery.value = ''
selectedCategory.value = ''
selectedDifficulty.value = ''
onlyWithAudio.value = false
currentPage.value = 1
}
const getTextPreview = (text) => {
if (!text) return ''
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'
}
}
// Initialiser la recherche depuis l'URL
watch(() => route.query.search, (newSearch) => {
if (newSearch) {
searchQuery.value = newSearch
}
}, { immediate: true })
onMounted(() => {
loadTexts()
})
return {
allTexts,
searchQuery,
selectedCategory,
selectedDifficulty,
onlyWithAudio,
currentPage,
categories,
filteredTexts,
totalPages,
paginatedTexts,
performSearch,
clearFilters,
getTextPreview,
getDifficultyClass
}
}
}
</script>
<style scoped>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

15
tailwind.config.js Normal file
View File

@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
'sans': ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,11 @@
Le Renard et le Corbeau
Il était une fois un corbeau qui avait trouvé un morceau de fromage.
Il se percha sur une branche d'arbre pour le manger tranquillement.
Un renard rusé passa par là et sentit l'odeur du fromage.
Il leva les yeux et vit le corbeau avec son trésor.
Le renard commença à flatter le corbeau sur sa beauté.
Il lui dit que sa voix devait être aussi belle que son plumage.
Le corbeau, flatté, ouvrit le bec pour chanter.
Le fromage tomba directement dans la gueule du renard.
Le renard s'enfuit en remerciant le corbeau pour ce repas.

View File

@@ -0,0 +1,8 @@
titre_fr=Le Renard et le Corbeau
titre_pt=Le Renat et le Corbeau
auteur=Jean de La Fontaine (adaptation)
traducteur=Exemple de traduction
categorie=Conte
difficulte=Facile
date_creation=2025-09-26

View File

@@ -0,0 +1,11 @@
Le Renat et le Corbeau
Y avait una fés un corbeau qu'avait trovâ un morciau de fromâjo.
Se perchit sur una brancha d'âbro por le mangiér tranquillament.
Un renat rusâ passit per lâ et sentit l'odour du fromâjo.
Levit los uelhs et vit le corbeau avouéc son trésôr.
Le renat comencit a flatar le corbeau sur sa biôtât.
Li dit que sa vouéx deviat étre aussi bèla que son plumâjo.
Le corbeau, flatâ, durbrit le bec por chantar.
Le fromâjo tombît directament dens la goula du renat.
Le renat s'enfugit en remerciant le corbeau por cél repas.

Binary file not shown.

View File

@@ -0,0 +1,7 @@
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 : « Quen penses-tu si on rassemblait nos épargnes et que lon 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 ».
A la foire, après avoir bien examiné toutes les vaches, des pie noirs à la tête blanche jusquaux châtaines, des reines jusquaux 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 lamè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.
Après sêtre bien disputé tout le long du chemin : « Voilà la solution, on va attacher la vache au milieu d'un pré situé entre nos maisons ».
Après cela, les deux frères décident aussi de se partager la vache. Pierino, le plus intelligent, veut le devant de la bête : « Je ne me salirai pas les mains et jaurai moins de travail ».
Donc il reste le derrière pour Maurice. « Tu croyais être le plus rusé - pense Maurice - tu as voulu la partie de la tête, maintenant il faut que tu ailles chercher du foin et de l'eau pour lui donner à manger et à boire…alors que tu nauras aucun gain; tandis que moi jirai chercher un seau pour traire la vache et vendre mon bon lait ».

View File

@@ -0,0 +1,8 @@
titre_fr=La vache partagée
titre_pt=La vatse a métchà
auteur=Rita Decime
traducteur= N/A
categorie=Conte
difficulte=Difficile
date_creation=1984

View File

@@ -0,0 +1,7 @@
Y ave én queu do frae, Piérino é Morise, quittévon ou mémo veladzo, dedeun do métcho én protcho dé l'otro. Én dzor Piérino propeuze : « Qué te nen di si no betissan énsembio lé seu é natsétissan eunna vatse ? ». Morise asette é lo dzor apré i parton a la féa dou veladzo vezén.
Én tsemenèn Piérino, qui ché crèyéve bièn peu fén qué Morise, pense : « Té fézo poué vére mè éa ! ».
A la féa, apré avé bièn avété totte lé vatse, di boutchanoye i tsatagnaye, di réne di corne i réne dou lasé, di vatse di corne boquie a selle di corne rébécoye, di vatse épaloye, i vatse couerbe, i nen cherdon eunna, Lenetta, na biantse é rodze bièn grâsa é la payon avoué lé seu qué y avon betó énsembio.
A la fén dé la féa, lé do frae tornon i métcho : « No la portèn poué a léteu dé mè, y é peu lardzo é peu sec qué lo tén », i di Piérino ; ma i coménson to sebeu a ché ruzà pé désidà ioù ivernà la vatse.
Apré avé bièn désquetó to lo lon dou tsemén : « Vouèlà la solechón, gropèn poué la vatse ou verzé éntrémé di do métcho ».
Dé sen, lé do frae désidon fénque dé ché partadzé én do la vatse. Piérino, lo peu savèn, i vou lo moro : « Paé, mémpouertso po poué lé man é né poué mouèn dé travai ».
A Morise, adón, i reste lo déré : « Te crèyéve détre lo peu fén, to voulù la téta, éa i té fo alà tsertsé dé fen é déve pé lle bayé rodzé é bée é... sensa gnen lle gagné ! Mè, i contréo, veu poué mé tsertsé én sezelén pé biètsé la vatse é dze pouì poué vendre dé bon lasé ».

7
vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})