Compare commits
7 Commits
main
..
03ffc846bf
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ffc846bf | |||
| b7f55e0707 | |||
| 4e11fd9a06 | |||
| 5d0993bda3 | |||
| 45f76cc5c8 | |||
| b4a468c14b | |||
| 65846640cf |
+12
-539
@@ -1,551 +1,24 @@
|
||||
# ---> Vue
|
||||
# gitignore template for Vue.js projects
|
||||
#
|
||||
# Recommended template: Node.gitignore
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
docs/_book
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
test/
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# ---> VisualStudio
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
API_URL=${VITE_TEXTS_API_URL:-http://localhost:3000/api}
|
||||
echo "Configuration du frontend avec l'API URL: $API_URL"
|
||||
|
||||
# Remplace l'URL de l'API dans les fichiers JS
|
||||
find /usr/share/nginx/html -name "*.js" -type f -exec sed -i "s|VITE_TEXTS_API_URL_PLACEHOLDER|$API_URL|g" {} \;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Étape de build
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copie des fichiers de dépendances
|
||||
COPY package*.json ./
|
||||
|
||||
# Installation des dépendances
|
||||
RUN npm install
|
||||
|
||||
# Copie des fichiers source
|
||||
COPY . .
|
||||
|
||||
# Argument de build pour l'URL de l'API
|
||||
ARG VITE_TEXTS_API_URL
|
||||
ENV VITE_TEXTS_API_URL=${VITE_TEXTS_API_URL}
|
||||
|
||||
# Build de l'application
|
||||
RUN echo "Building with VITE_TEXTS_API_URL=$VITE_TEXTS_API_URL"
|
||||
RUN npm run build
|
||||
|
||||
# Étape de production avec Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copie des fichiers buildés depuis l'étape précédente
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Exposition explicite du port utilisé par Nginx
|
||||
EXPOSE 80
|
||||
|
||||
# Démarrage de Nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,2 +1,5 @@
|
||||
# PatoisSMEH
|
||||
# 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).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
PORT=3000
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
Generated
+1407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
/** Configuration dotenv
|
||||
* Permet de charger les variables d'environnement depuis un fichier .env
|
||||
* */
|
||||
dotenv.config()
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT
|
||||
|
||||
// Middleware
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// Chemin vers le dossier texts (dossier parent)
|
||||
const TEXTS_DIR = process.env.TEXTS_PATH || "C:\\Users\\paulf\\Documents\\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
|
||||
}
|
||||
}
|
||||
|
||||
async getRandomText() {
|
||||
const allTexts = await this.scanTexts()
|
||||
if (allTexts.length === 0) {
|
||||
throw new Error('Aucun texte disponible')
|
||||
}
|
||||
const randomIndex = Math.floor(Math.random() * allTexts.length)
|
||||
return allTexts[randomIndex]
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
api:
|
||||
image: elpoyo/patois-api:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "${API_PORT:-3000}:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- TEXTS_PATH=/app/texts
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- ${TEXTS_PATH:-./texts}:/app/texts
|
||||
|
||||
frontend:
|
||||
image: elpoyo/patois-frontend:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-8080}:80"
|
||||
environment:
|
||||
- VITE_TEXTS_API_URL=${API_URL}
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
# Configuration via variables d'environnement :
|
||||
# API_PORT=3000 # Port externe de l'API
|
||||
# FRONTEND_PORT=8080 # Port externe du frontend
|
||||
# API_URL=http://ip:port/api # URL complète de l'API pour le frontend
|
||||
# TEXTS_PATH=/chemin/vers/textes # Chemin vers le dossier des textes
|
||||
#
|
||||
# Exemple d'utilisation :
|
||||
# API_PORT=52000 FRONTEND_PORT=52001 API_URL=http://192.168.1.34:52000/api TEXTS_PATH=/mnt/configs/patois docker-compose up -d
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+13
@@ -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>
|
||||
Generated
+2239
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 |
@@ -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(100)
|
||||
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>
|
||||
|
||||
@@ -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é avec soin par Paul Fournel
|
||||
</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>
|
||||
@@ -0,0 +1,250 @@
|
||||
<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' }"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||
</svg>
|
||||
<span>Accueil</span>
|
||||
</div>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/textes"
|
||||
class="nav-link"
|
||||
:class="{ 'active': $route.name === 'Texts' }"
|
||||
>
|
||||
<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>
|
||||
<template v-if="!useDropdown">
|
||||
<router-link
|
||||
v-for="category in dynamicCategories"
|
||||
:key="category.id"
|
||||
:to="getCategoryLink(category)"
|
||||
class="nav-link"
|
||||
:class="{ 'active': isCategoryActive(category) }"
|
||||
>
|
||||
<span>{{ category.name }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else class="relative group">
|
||||
<button
|
||||
class="nav-link"
|
||||
:class="{ 'active': isBlogActive }"
|
||||
type="button"
|
||||
>
|
||||
Blog
|
||||
</button>
|
||||
<div class="absolute left-0 mt-2 w-56 bg-white border border-gray-200 rounded-lg shadow-lg py-2 hidden group-hover:block">
|
||||
<router-link
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:to="getCategoryLink(category)"
|
||||
class="dropdown-link"
|
||||
>
|
||||
{{ category.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div v-if="categories.length" class="pt-2 border-t border-gray-200">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 mb-2">Blog</div>
|
||||
<router-link
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:to="getCategoryLink(category)"
|
||||
class="nav-link-mobile"
|
||||
>
|
||||
{{ category.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="pt-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@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, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { wordpressService } from '../services/wordpressService.js'
|
||||
|
||||
export default {
|
||||
name: 'NavigationBar',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const searchQuery = ref('')
|
||||
const mobileMenuOpen = ref(false)
|
||||
const categories = ref([])
|
||||
const categoriesLoading = ref(false)
|
||||
|
||||
const performSearch = () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
router.push({
|
||||
name: 'Texts',
|
||||
query: { search: searchQuery.value.trim() }
|
||||
})
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
categoriesLoading.value = true
|
||||
try {
|
||||
categories.value = await wordpressService.getCategories()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des catégories:', error)
|
||||
} finally {
|
||||
categoriesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicCategories = computed(() => {
|
||||
return categories.value.slice(0, 4)
|
||||
})
|
||||
|
||||
const useDropdown = computed(() => {
|
||||
return categories.value.length > 4
|
||||
})
|
||||
|
||||
const getCategoryLink = (category) => {
|
||||
return category.slug === 'actualites' ? '/actualites' : `/blog/${category.slug}`
|
||||
}
|
||||
|
||||
const isCategoryActive = (category) => {
|
||||
if (category.slug === 'actualites') {
|
||||
return route.name === 'News' || route.name === 'NewsArticle'
|
||||
}
|
||||
|
||||
return route.name === 'BlogCategory' && route.params.slug === category.slug
|
||||
}
|
||||
|
||||
const isBlogActive = computed(() => {
|
||||
return route.name === 'News' || route.name === 'BlogCategory' || route.name === 'NewsArticle'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
})
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
mobileMenuOpen,
|
||||
categories,
|
||||
categoriesLoading,
|
||||
dynamicCategories,
|
||||
useDropdown,
|
||||
isBlogActive,
|
||||
getCategoryLink,
|
||||
isCategoryActive,
|
||||
performSearch
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
padding-bottom: 0.25rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: black;
|
||||
border-bottom: 2px solid black;
|
||||
}
|
||||
|
||||
.nav-link-mobile {
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.nav-link-mobile:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.dropdown-link {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #374151;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-link:hover {
|
||||
color: black;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<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"
|
||||
:disabled="copyingPatois"
|
||||
class="btn-secondary text-sm relative overflow-hidden"
|
||||
:class="{ 'animate-pulse': copyingPatois }"
|
||||
>
|
||||
<span class="flex items-center space-x-2">
|
||||
<svg v-if="!copyingPatois" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ copyingPatois ? 'Copie...' : 'Copier le texte patois' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="copyFrenchText"
|
||||
:disabled="copyingFrench"
|
||||
class="btn-secondary text-sm relative overflow-hidden"
|
||||
:class="{ 'animate-pulse': copyingFrench }"
|
||||
>
|
||||
<span class="flex items-center space-x-2">
|
||||
<svg v-if="!copyingFrench" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ copyingFrench ? 'Copie...' : 'Copier le texte français' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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)
|
||||
const copyingPatois = ref(false)
|
||||
const copyingFrench = ref(false)
|
||||
|
||||
// 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 () => {
|
||||
copyingPatois.value = true
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.patoisText)
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la copie:', err)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
copyingPatois.value = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const copyFrenchText = async () => {
|
||||
copyingFrench.value = true
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.frenchText)
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la copie:', err)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
copyingFrench.value = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
highlightedLine,
|
||||
patoisLines,
|
||||
frenchLines,
|
||||
highlightLine,
|
||||
getDifficultyClass,
|
||||
formatDate,
|
||||
copyPatoisText,
|
||||
copyFrenchText,
|
||||
copyingPatois,
|
||||
copyingFrench
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Assurer que les lignes correspondent visuellement */
|
||||
.text-patois,
|
||||
.text-french {
|
||||
min-height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -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')
|
||||
@@ -0,0 +1,49 @@
|
||||
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 News from '../views/News.vue'
|
||||
import NewsArticle from '../views/NewsArticle.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: '/actualites',
|
||||
name: 'News',
|
||||
component: News
|
||||
},
|
||||
{
|
||||
path: '/blog/:slug',
|
||||
name: 'BlogCategory',
|
||||
component: News
|
||||
},
|
||||
{
|
||||
path: '/actualite/:id',
|
||||
name: 'NewsArticle',
|
||||
component: NewsArticle,
|
||||
props: true
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Service pour communiquer avec l'API backend des textes
|
||||
*/
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_TEXTS_API_URL || 'https://patois.lagaudiere.uk/api' || 'http://localhost:3000/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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dans les textes avec filtres
|
||||
*/
|
||||
async searchTexts(query, filters = {}) {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient un texte aléatoire
|
||||
*/
|
||||
async getRandomText() {
|
||||
try {
|
||||
return await this.fetchAPI('/random')
|
||||
} catch (error) {
|
||||
throw new Error(`Impossible d'obtenir un texte aléatoire: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les statistiques de la collection
|
||||
*/
|
||||
async getStats() {
|
||||
const cacheKey = 'stats'
|
||||
|
||||
// Vérifier le cache
|
||||
if (this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey)
|
||||
if (Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
||||
return cached.data
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await this.fetchAPI('/stats')
|
||||
|
||||
// Mettre en cache
|
||||
this.cache.set(cacheKey, {
|
||||
data: stats,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return stats
|
||||
} catch (error) {
|
||||
throw new Error(`Impossible de charger les statistiques: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient l'URL du fichier audio pour un texte
|
||||
*/
|
||||
getAudioUrl(textId) {
|
||||
return `${API_BASE_URL}/texts/${textId}/audio`
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'API backend est accessible
|
||||
*/
|
||||
async checkHealth() {
|
||||
try {
|
||||
const health = await this.fetchAPI('/health')
|
||||
return health.status === 'OK'
|
||||
} catch (error) {
|
||||
console.warn('API backend non accessible:', error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vide le cache (utile pour forcer le rechargement)
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Instance singleton
|
||||
export const textService = new TextService()
|
||||
@@ -0,0 +1,181 @@
|
||||
const API_BASE_URL = 'https://admin-afpl.federation-ouest-francoprovencal.fr/wp-json/wp/v2'
|
||||
const ACTUALITES_CATEGORY_ID = 3
|
||||
|
||||
/**
|
||||
* Service pour interagir avec l'API WordPress (Headless CMS)
|
||||
*/
|
||||
export const wordpressService = {
|
||||
/**
|
||||
* Récupère les catégories principales (parent = 0)
|
||||
* @returns {Promise<Array>} - Liste des catégories
|
||||
*/
|
||||
async getCategories() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/categories?per_page=100&parent=0&hide_empty=true&orderby=name&order=asc`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP: ${response.status}`)
|
||||
}
|
||||
|
||||
const categories = await response.json()
|
||||
return categories
|
||||
.filter(category => category.slug !== 'uncategorized' && category.name.toLowerCase() !== 'uncategorized')
|
||||
.map(category => this.formatCategory(category))
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des catégories:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère une catégorie par son slug
|
||||
* @param {string} slug - Slug de la catégorie
|
||||
* @returns {Promise<Object|null>} - Catégorie formatée ou null
|
||||
*/
|
||||
async getCategoryBySlug(slug) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/categories?slug=${slug}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP: ${response.status}`)
|
||||
}
|
||||
|
||||
const categories = await response.json()
|
||||
if (!categories.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.formatCategory(categories[0])
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la récupération de la catégorie ${slug}:`, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère tous les posts de la catégorie "actualites"
|
||||
* @param {number} perPage - Nombre de posts par page (défaut: 10)
|
||||
* @param {number} page - Numéro de page (défaut: 1)
|
||||
* @returns {Promise<Object>} - { posts: Array, totalPages: number, total: number }
|
||||
*/
|
||||
async getNews(perPage = 10, page = 1) {
|
||||
return this.getPostsByCategory(ACTUALITES_CATEGORY_ID, perPage, page)
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère les posts d'une catégorie
|
||||
* @param {number} categoryId - ID de la catégorie
|
||||
* @param {number} perPage - Nombre de posts par page (défaut: 10)
|
||||
* @param {number} page - Numéro de page (défaut: 1)
|
||||
* @returns {Promise<Object>} - { posts: Array, totalPages: number, total: number }
|
||||
*/
|
||||
async getPostsByCategory(categoryId, perPage = 10, page = 1) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/posts?categories=${categoryId}&per_page=${perPage}&page=${page}&_embed`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP: ${response.status}`)
|
||||
}
|
||||
|
||||
const posts = await response.json()
|
||||
const totalPages = parseInt(response.headers.get('X-WP-TotalPages') || '1')
|
||||
const total = parseInt(response.headers.get('X-WP-Total') || '0')
|
||||
|
||||
return {
|
||||
posts: posts.map(post => this.formatPost(post)),
|
||||
totalPages,
|
||||
total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des posts:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère un post spécifique par son ID
|
||||
* @param {number} id - ID du post
|
||||
* @returns {Promise<Object>} - Post formaté
|
||||
*/
|
||||
async getNewsById(id) {
|
||||
return this.getPostById(id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère un post spécifique par son ID
|
||||
* @param {number} id - ID du post
|
||||
* @returns {Promise<Object>} - Post formaté
|
||||
*/
|
||||
async getPostById(id) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/posts/${id}?_embed`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP: ${response.status}`)
|
||||
}
|
||||
|
||||
const post = await response.json()
|
||||
return this.formatPost(post)
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors de la récupération du post ${id}:`, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Formate une catégorie WordPress
|
||||
* @param {Object} category - Catégorie brute de l'API WordPress
|
||||
* @returns {Object} - Catégorie formatée
|
||||
*/
|
||||
formatCategory(category) {
|
||||
return {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
count: category.count,
|
||||
parent: category.parent
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Formate un post WordPress pour l'utilisation dans l'application
|
||||
* @param {Object} post - Post brut de l'API WordPress
|
||||
* @returns {Object} - Post formaté
|
||||
*/
|
||||
formatPost(post) {
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title.rendered,
|
||||
content: post.content.rendered,
|
||||
excerpt: post.excerpt.rendered,
|
||||
date: post.date,
|
||||
modified: post.modified,
|
||||
slug: post.slug,
|
||||
link: post.link,
|
||||
author: post._embedded?.author?.[0]?.name || 'Auteur inconnu',
|
||||
featuredImage: post._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
|
||||
featuredImageAlt: post._embedded?.['wp:featuredmedia']?.[0]?.alt_text || '',
|
||||
categories: post._embedded?.['wp:term']?.[0]?.map(cat => cat.name) || [],
|
||||
tags: post._embedded?.['wp:term']?.[1]?.map(tag => tag.name) || []
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Formate une date pour l'affichage
|
||||
* @param {string} dateString - Date au format ISO
|
||||
* @returns {string} - Date formatée en français
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@import "tailwindcss";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
|
||||
/* Ajouter les couleurs personnalisées si nécessaire */
|
||||
--color-gray-custom: #6b7280;
|
||||
--color-gray-custom-light: #9ca3af;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: white;
|
||||
color: black;
|
||||
line-height: 1.625;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border: 2px solid black;
|
||||
color: black;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-patois {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.text-french {
|
||||
font-weight: 400;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.625;
|
||||
color: var(--color-gray-custom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<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>
|
||||
</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.metadata.titre_fr }}</h3>
|
||||
<p class="text-sm mb-3" style="color: #6b7280">{{ text.metadata.titre_pt }}</p>
|
||||
<div class="flex justify-between items-center text-xs text-gray-500">
|
||||
<span>{{ text.metadata.auteur }}</span>
|
||||
<span v-if="text.hasAudio" class="flex items-center">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v6.114A4.369 4.369 0 005 11c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.369 4.369 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z"/>
|
||||
</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>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white py-8">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<!-- En-tête -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-black mb-4">
|
||||
{{ categoryTitle }}
|
||||
</h1>
|
||||
<p class="text-gray-600">
|
||||
{{ categoryDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-black"></div>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p class="text-red-600 mb-2">{{ error }}</p>
|
||||
<button
|
||||
@click="loadNews"
|
||||
class="text-sm text-red-700 hover:text-red-900 underline"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste des articles -->
|
||||
<div v-else-if="newsList.length > 0" class="space-y-6">
|
||||
<article
|
||||
v-for="news in newsList"
|
||||
:key="news.id"
|
||||
class="bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer"
|
||||
@click="goToNews(news.id)"
|
||||
>
|
||||
<div class="md:flex">
|
||||
<!-- Image à la une -->
|
||||
<div v-if="news.featuredImage" class="md:w-1/3">
|
||||
<img
|
||||
:src="news.featuredImage"
|
||||
:alt="news.featuredImageAlt || news.title"
|
||||
class="w-full h-64 md:h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="p-6 md:w-2/3">
|
||||
<!-- Date et auteur -->
|
||||
<div class="flex items-center text-sm text-gray-500 mb-3">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ formatDate(news.date) }}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ news.author }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Titre -->
|
||||
<h2 class="text-2xl font-bold text-black mb-3 hover:text-gray-700 transition-colors">
|
||||
{{ news.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Extrait -->
|
||||
<div
|
||||
class="text-gray-600 mb-4 line-clamp-3"
|
||||
v-html="news.excerpt"
|
||||
></div>
|
||||
|
||||
<!-- Catégories/Tags -->
|
||||
<div v-if="news.tags.length > 0" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tag in news.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
class="px-3 py-1 bg-gray-100 text-gray-700 text-xs rounded-full"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Lien "Lire la suite" -->
|
||||
<div class="mt-4">
|
||||
<span class="text-black font-medium hover:underline inline-flex items-center">
|
||||
Lire la suite
|
||||
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center items-center space-x-4 mt-8">
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white border border-gray-300 text-black hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
@click="goToPage(page)"
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg font-medium transition-colors',
|
||||
page === currentPage
|
||||
? 'bg-black text-white'
|
||||
: 'bg-white border border-gray-300 text-black hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white border border-gray-300 text-black hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aucun article -->
|
||||
<div v-else class="text-center py-20">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
<p class="text-gray-600 text-lg">Aucun article disponible pour le moment</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { wordpressService } from '../services/wordpressService.js'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const newsList = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const total = ref(0)
|
||||
const perPage = 10
|
||||
const category = ref(null)
|
||||
|
||||
const categorySlug = computed(() => {
|
||||
return route.params.slug || 'actualites'
|
||||
})
|
||||
|
||||
const categoryTitle = computed(() => {
|
||||
return category.value?.name || 'Actualités'
|
||||
})
|
||||
|
||||
const categoryDescription = computed(() => {
|
||||
return category.value?.description || 'Découvrez les derniers articles de cette catégorie'
|
||||
})
|
||||
|
||||
// Charger la catégorie
|
||||
const loadCategory = async () => {
|
||||
category.value = await wordpressService.getCategoryBySlug(categorySlug.value)
|
||||
if (!category.value) {
|
||||
throw new Error('Catégorie introuvable')
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les articles
|
||||
const loadNews = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (!category.value) {
|
||||
await loadCategory()
|
||||
}
|
||||
|
||||
const result = await wordpressService.getPostsByCategory(
|
||||
category.value.id,
|
||||
perPage,
|
||||
currentPage.value
|
||||
)
|
||||
newsList.value = result.posts
|
||||
totalPages.value = result.totalPages
|
||||
total.value = result.total
|
||||
} catch (err) {
|
||||
error.value = 'Impossible de charger les articles. Veuillez réessayer plus tard.'
|
||||
console.error('Erreur lors du chargement des articles:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation vers une actualité
|
||||
const goToNews = (id) => {
|
||||
router.push({
|
||||
name: 'NewsArticle',
|
||||
params: { id },
|
||||
query: { category: category.value?.slug || categorySlug.value }
|
||||
})
|
||||
}
|
||||
|
||||
// Navigation entre les pages
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
// Pages visibles pour la pagination
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const maxVisible = 5
|
||||
let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
|
||||
let end = Math.min(totalPages.value, start + maxVisible - 1)
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
// Formater la date
|
||||
const formatDate = (dateString) => {
|
||||
return wordpressService.formatDate(dateString)
|
||||
}
|
||||
|
||||
// Charger au montage
|
||||
onMounted(() => {
|
||||
loadNews()
|
||||
})
|
||||
|
||||
// Recharger lors du changement de page
|
||||
watch(currentPage, () => {
|
||||
loadNews()
|
||||
})
|
||||
|
||||
// Recharger lors du changement de catégorie
|
||||
watch(categorySlug, () => {
|
||||
category.value = null
|
||||
currentPage.value = 1
|
||||
loadNews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Nettoyer le HTML de l'extrait */
|
||||
:deep(.line-clamp-3 p) {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white">
|
||||
<!-- Chargement -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-black"></div>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="error" class="max-w-4xl mx-auto px-4 py-20">
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p class="text-red-600 mb-4">{{ error }}</p>
|
||||
<div class="flex justify-center space-x-4">
|
||||
<button
|
||||
@click="loadArticle"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
<router-link
|
||||
to="/actualites"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Retour aux actualités
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Article -->
|
||||
<article v-else-if="article" class="pb-16">
|
||||
<!-- Image à la une -->
|
||||
<div v-if="article.featuredImage" class="w-full h-96 overflow-hidden">
|
||||
<img
|
||||
:src="article.featuredImage"
|
||||
:alt="article.featuredImageAlt || article.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="py-6 text-sm">
|
||||
<ol class="flex items-center space-x-2 text-gray-600">
|
||||
<li>
|
||||
<router-link to="/" class="hover:text-black transition-colors">
|
||||
Accueil
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="categoryLink" class="hover:text-black transition-colors">
|
||||
{{ categoryTitle }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</li>
|
||||
<li class="text-black font-medium truncate">
|
||||
{{ article.title }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- En-tête de l'article -->
|
||||
<header class="mb-8">
|
||||
<!-- Catégories -->
|
||||
<div v-if="article.categories.length > 0" class="flex flex-wrap gap-2 mb-4">
|
||||
<span
|
||||
v-for="category in article.categories"
|
||||
:key="category"
|
||||
class="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||
>
|
||||
{{ category }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Titre -->
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-black mb-6">
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-gray-600">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>{{ article.author }}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ formatDate(article.date) }}</span>
|
||||
</div>
|
||||
<span v-if="article.modified !== article.date">•</span>
|
||||
<div v-if="article.modified !== article.date" class="flex items-center text-sm">
|
||||
<span>Mis à jour le {{ formatDate(article.modified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Contenu de l'article -->
|
||||
<div
|
||||
class="prose prose-lg max-w-none article-content"
|
||||
v-html="article.content"
|
||||
></div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="article.tags.length > 0" class="mt-12 pt-8 border-t border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Mots-clés :</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tag in article.tags"
|
||||
:key="tag"
|
||||
class="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||
>
|
||||
#{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||
<router-link
|
||||
:to="categoryLink"
|
||||
class="inline-flex items-center text-black hover:text-gray-700 font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour aux articles
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { wordpressService } from '../services/wordpressService.js'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const article = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const category = ref(null)
|
||||
|
||||
const categorySlug = computed(() => {
|
||||
return route.query.category || 'actualites'
|
||||
})
|
||||
|
||||
const categoryTitle = computed(() => {
|
||||
return category.value?.name || 'Actualités'
|
||||
})
|
||||
|
||||
const categoryLink = computed(() => {
|
||||
return categorySlug.value === 'actualites'
|
||||
? '/actualites'
|
||||
: `/blog/${categorySlug.value}`
|
||||
})
|
||||
|
||||
// Charger l'article
|
||||
const loadArticle = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const id = parseInt(route.params.id)
|
||||
if (isNaN(id)) {
|
||||
throw new Error('ID d\'article invalide')
|
||||
}
|
||||
|
||||
article.value = await wordpressService.getPostById(id)
|
||||
|
||||
category.value = await wordpressService.getCategoryBySlug(categorySlug.value)
|
||||
|
||||
// Mettre à jour le titre de la page
|
||||
if (article.value) {
|
||||
document.title = `${article.value.title} - Patois Franco-Provençal`
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Impossible de charger cet article. Il n\'existe peut-être pas ou n\'est plus disponible.'
|
||||
console.error('Erreur lors du chargement de l\'article:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Formater la date
|
||||
const formatDate = (dateString) => {
|
||||
return wordpressService.formatDate(dateString)
|
||||
}
|
||||
|
||||
// Charger au montage
|
||||
onMounted(() => {
|
||||
loadArticle()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Styles pour le contenu de l'article WordPress */
|
||||
.article-content {
|
||||
color: #1f2937;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.article-content :deep(h1),
|
||||
.article-content :deep(h2),
|
||||
.article-content :deep(h3),
|
||||
.article-content :deep(h4),
|
||||
.article-content :deep(h5),
|
||||
.article-content :deep(h6) {
|
||||
font-weight: 700;
|
||||
color: black;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.article-content :deep(h1) { font-size: 1.875rem; }
|
||||
.article-content :deep(h2) { font-size: 1.5rem; }
|
||||
.article-content :deep(h3) { font-size: 1.25rem; }
|
||||
.article-content :deep(h4) { font-size: 1.125rem; }
|
||||
|
||||
.article-content :deep(p) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.article-content :deep(a) {
|
||||
color: black;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.article-content :deep(a:hover) {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.article-content :deep(ul),
|
||||
.article-content :deep(ol) {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.article-content :deep(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.article-content :deep(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.article-content :deep(li) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.article-content :deep(blockquote) {
|
||||
border-left: 4px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
font-style: italic;
|
||||
margin: 1.5rem 0;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.article-content :deep(img) {
|
||||
border-radius: 0.5rem;
|
||||
margin: 1.5rem 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.article-content :deep(figure) {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.article-content :deep(figcaption) {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.article-content :deep(pre) {
|
||||
background-color: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.article-content :deep(code) {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.article-content :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.article-content :deep(th),
|
||||
.article-content :deep(td) {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.article-content :deep(th) {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.article-content :deep(hr) {
|
||||
margin: 2rem 0;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.article-content :deep(strong),
|
||||
.article-content :deep(b) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.article-content :deep(em),
|
||||
.article-content :deep(i) {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -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.push('/textes')"
|
||||
class="flex items-center hover:text-black transition-colors" style="color: #6b7280"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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_pt }}
|
||||
</h1>
|
||||
<h2 class="text-2xl" style="color: #6b7280">
|
||||
{{ textData.metadata.titre_fr }}
|
||||
</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
|
||||
// Utiliser l'URL de l'API backend pour le fichier audio
|
||||
return textService.getAudioUrl(props.id)
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,311 @@
|
||||
<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(() => {
|
||||
// Ne pas filtrer localement - l'API backend s'en charge
|
||||
return allTexts.value
|
||||
})
|
||||
|
||||
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 {
|
||||
// Utiliser l'API de recherche avec les filtres actuels
|
||||
const texts = await textService.searchTexts(searchQuery.value, {
|
||||
category: selectedCategory.value,
|
||||
difficulty: selectedDifficulty.value,
|
||||
onlyWithAudio: onlyWithAudio.value
|
||||
})
|
||||
allTexts.value = texts
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des textes:', error)
|
||||
// Fallback : charger tous les textes si la recherche échoue
|
||||
try {
|
||||
const allTextsData = await textService.loadAllTexts()
|
||||
allTexts.value = allTextsData
|
||||
} catch (fallbackError) {
|
||||
console.error('Erreur fallback:', fallbackError)
|
||||
allTexts.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const performSearch = async () => {
|
||||
currentPage.value = 1
|
||||
await loadTexts()
|
||||
}
|
||||
|
||||
const clearFilters = async () => {
|
||||
searchQuery.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedDifficulty.value = ''
|
||||
onlyWithAudio.value = false
|
||||
currentPage.value = 1
|
||||
// IMPORTANT: Recharger via l'API après avoir vidé les filtres
|
||||
await loadTexts()
|
||||
}
|
||||
|
||||
const getTextPreview = (text) => {
|
||||
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>
|
||||
@@ -0,0 +1,25 @@
|
||||
@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.
|
||||
echo Fermez cette fenetre quand vous voulez arreter l'application.
|
||||
pause
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
api:
|
||||
image: elpoyo/patois-api:latest
|
||||
restart: always
|
||||
ports:
|
||||
- '52000:3000'
|
||||
environment:
|
||||
- PORT=3000
|
||||
- TEXTS_PATH=/app/texts
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- /mnt/Data/configs/patois:/app/texts
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- VITE_TEXTS_API_URL=https://patois.lagaudiere.uk/api
|
||||
restart: always
|
||||
ports:
|
||||
- '52001:80'
|
||||
depends_on:
|
||||
- api
|
||||
@@ -0,0 +1,36 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
api:
|
||||
image: elpoyo/patois-api:latest
|
||||
restart: always
|
||||
ports:
|
||||
- '52002:3000'
|
||||
environment:
|
||||
- PORT=3000
|
||||
- TEXTS_PATH=/app/texts
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- /mnt/configs/patois:/app/texts
|
||||
|
||||
frontend:
|
||||
image: elpoyo/patois-frontend:latest
|
||||
restart: always
|
||||
ports:
|
||||
- '52001:80'
|
||||
environment:
|
||||
- VITE_TEXTS_API_URL=http://192.168.1.34:52002/api
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
# Configuration pour TrueNAS SCALE
|
||||
#
|
||||
# API accessible sur : http://192.168.1.34:52002/api
|
||||
# Frontend accessible sur : http://192.168.1.34:52001
|
||||
#
|
||||
# Dossier des textes : /mnt/configs/patois (doit exister sur TrueNAS)
|
||||
#
|
||||
# Pour déployer :
|
||||
# 1. Créer le répertoire /mnt/configs/patois sur TrueNAS
|
||||
# 2. Copier ce fichier sur TrueNAS
|
||||
# 3. Déployer avec : docker-compose -f truenas-deploy.yml up -d
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: parseInt(env.VITE_PORT) || 8080,
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user