61 Commits
Author SHA1 Message Date
cedric 4cc5c405bb feat: add ayisyen
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 31s
Déploiement FRONT PROD / deploy (push) Successful in 49s
2026-08-11 07:31:20 +04:00
cedric c8841a253f fix: rendre le proxy MIZIK plus robuste et tolérer l'ancien nommage des credentials
Déploiement FRONT BETA / check (push) Successful in 2m18s
Déploiement FRONT PROD / check (push) Successful in 2m8s
Déploiement FRONT BETA / deploy (push) Successful in 25s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-15 23:04:19 +04:00
cedric 4e6341c5d5 refactor: renommer middleware.js en proxy.js pour Next.js 16
Déploiement FRONT BETA / check (push) Successful in 2m8s
Déploiement FRONT PROD / check (push) Successful in 2m15s
Déploiement FRONT BETA / deploy (push) Successful in 24s
Déploiement FRONT PROD / deploy (push) Successful in 23s
2026-07-08 00:29:26 +04:00
cedric b5a3b278ba feat: ajouter les en-têtes de sécurité pour atteindre 100/100 sur MDN Observatory 2026-07-08 00:27:57 +04:00
cedric 99daeb014c feat: styliser le lien vers le fil BOKANTE
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m15s
Déploiement FRONT BETA / deploy (push) Successful in 24s
Déploiement FRONT PROD / deploy (push) Successful in 24s
2026-07-07 16:13:57 +04:00
cedric f5a4014e41 feat: ajouter un sélecteur d'instance Mastodon et expliciter BOKANTE 2026-07-07 16:11:46 +04:00
cedric d66c679fd1 revert: ne plus présélectionner selon la langue du navigateur
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-06 02:34:08 +04:00
cedric cab293d62e Merge pull request 'Sélecteur de langue : regroupement par continent, Afrique en tête' (#6) from feat/africa into master
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / deploy (push) Successful in 22s
Déploiement FRONT PROD / deploy (push) Successful in 19s
Reviewed-on: #6
2026-07-05 22:20:46 +00:00
cedric 5936de59ff fix: mettre en valeur l'option Transcription dans le select
Déploiement FRONT BETA / check (push) Successful in 2m7s
Vérification PR / check (pull_request) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Vérification PR / deploy-beta (pull_request) Successful in 19s
2026-07-06 02:01:44 +04:00
cedric 6804e3626f feat: remplacer les puces par un select groupé par continent
Déploiement FRONT BETA / check (push) Successful in 2m14s
Vérification PR / check (pull_request) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Vérification PR / deploy-beta (pull_request) Successful in 20s
2026-07-06 00:18:34 +04:00
cedric 461b063cff feat: regrouper les langues par continent, Afrique en tête 2026-07-06 00:10:34 +04:00
cedric 6601b00545 fix: ne plus exposer le jeton API au navigateur (recherche d'artiste)
Déploiement FRONT PROD / check (push) Successful in 2m13s
Déploiement FRONT PROD / deploy (push) Successful in 20s
Déploiement FRONT BETA / check (push) Successful in 2m6s
Déploiement FRONT BETA / deploy (push) Successful in 20s
2026-07-05 23:16:19 +04:00
cedric f88b0184ea feat: sélectionner la traduction par défaut selon la langue du navigateur
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-05 22:33:09 +04:00
cedric 43964ec91d feat: afficher les langues asiatiques avant les langues européennes 2026-07-05 22:28:57 +04:00
cedric ce19fa3b5d fix: remplacer le libellé de date par une icône universelle
Déploiement FRONT BETA / check (push) Successful in 2m10s
Déploiement FRONT PROD / check (push) Successful in 2m5s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 19s
2026-07-05 20:54:14 +04:00
cedric 33514bf633 feat: généraliser le formatage de date adaptatif à la locale 2026-07-05 20:47:52 +04:00
cedric 35c39a97f3 feat: adapter la date des commentaires à la locale du navigateur
Déploiement FRONT PROD / deploy (push) Successful in 20s
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
2026-07-05 20:39:50 +04:00
cedric 72b86fdc5c feat: mettre en valeur les mentions et afficher l'heure des commentaires
Déploiement FRONT PROD / deploy (push) Successful in 19s
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m9s
Déploiement FRONT BETA / deploy (push) Successful in 21s
2026-07-05 20:17:35 +04:00
cedric 53287f9c80 feat: remplacer les onglets de langue par des puces adaptatives
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-05 20:09:29 +04:00
cedric 8979970a90 feat: refonte visuelle des commentaires (cartes, compteur, date)
Déploiement FRONT BETA / check (push) Successful in 2m18s
Déploiement FRONT PROD / check (push) Successful in 2m7s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 19s
2026-07-05 19:50:54 +04:00
cedric 75c5979478 feat: ajouter le logo Mastodon au badge des commentaires fédérés
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Déploiement FRONT PROD / deploy (push) Successful in 22s
2026-07-05 19:35:40 +04:00
cedric 5778d865e2 docs: documenter les commentaires fédérés (bokante)
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-05 02:07:03 +04:00
cedric 0ac18faf5c Merge pull request 'Commentaires fédérés via Mastodon (bokante.o-k-i.net) — frontend' (#5) from feat/comment-fedi into master
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 29s
Déploiement FRONT BETA / check (push) Successful in 2m5s
Déploiement FRONT PROD / check (push) Successful in 2m12s
Reviewed-on: #5
2026-07-04 21:56:48 +00:00
cedric 21248b7138 feat: bouton Répondre via Mastodon (authorize_interaction)
Vérification PR / check (pull_request) Successful in 2m12s
Vérification PR / deploy-beta (pull_request) Successful in 21s
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT BETA / deploy (push) Successful in 31s
2026-07-05 01:15:55 +04:00
cedric eda30345c6 feat: afficher les commentaires (locaux et Mastodon) 2026-07-04 23:45:27 +04:00
cedric b40e2d71b0 fix: déplacer force-dynamic après les imports (import/first)
Déploiement FRONT PROD / check (push) Successful in 2m8s
Déploiement FRONT BETA / check (push) Successful in 2m19s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Déploiement FRONT PROD / deploy (push) Successful in 20s
Corrige une violation ESLint introduite par le commit précédent :
l'export de configuration de route doit venir après les imports.
2026-07-04 23:18:35 +04:00
cedric 4bc545ac72 fix: ne jamais redémarrer pm2 sur un build cassé
Ajoute set -e aux scripts de déploiement (prod, beta, pré-déploiement
PR) : si git pull, yarn install ou yarn build échoue, le script
s'arrête immédiatement au lieu de continuer et de redémarrer pm2 sur
un .next incomplet — l'ancien build en cours d'exécution reste servi
au lieu de crasher (502).
2026-07-04 23:15:47 +04:00
cedric 64cdd784c3 fix: rétablir force-dynamic sur la page d'accueil
Déploiement FRONT PROD / check (push) Failing after 2m6s
Déploiement FRONT PROD / deploy (push) Has been skipped
Déploiement FRONT BETA / check (push) Failing after 2m11s
Déploiement FRONT BETA / deploy (push) Has been skipped
Sa suppression (44e1a42) faisait basculer la page d'accueil en
génération statique au build : le moindre souci de récupération de
données à ce moment-là fait planter tout le build ("Export
encountered an error on /page: /"), et le script de déploiement
redémarre quand même pm2 sur un .next incomplet (502 en prod).
2026-07-04 23:12:52 +04:00
cedric d2d09c28b3 Merge pull request 'Audit qualité : corrections de bugs, nettoyage, lint 0 erreur, tests' (#4) from fix/audit-2026-07-04 into master
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 19s
Déploiement FRONT PROD / check (push) Successful in 2m7s
Déploiement FRONT PROD / deploy (push) Successful in 21s
Reviewed-on: #4
2026-07-04 17:01:35 +00:00
cedric eae8bb03a9 ci: rendre le lint à nouveau bloquant
Déploiement FRONT BETA / check (push) Successful in 2m6s
Déploiement FRONT BETA / deploy (push) Successful in 49s
Vérification PR / deploy-beta (pull_request) Successful in 20s
Vérification PR / check (pull_request) Successful in 2m6s
2026-07-04 14:35:08 +04:00
cedric 454eefbe58 fix: corriger toutes les erreurs de lint restantes (263 -> 0) 2026-07-04 14:34:50 +04:00
cedric b193914c57 fix: restaurer components/cgu supprimé par erreur (utilisé par /sipote) 2026-07-04 14:34:16 +04:00
cedric d700ee6def chore: déclarer qs et retirer les dépendances mortes (axios, next-auth...) 2026-07-04 11:48:09 +04:00
cedric 2132df3e0c ci: exécuter lint et tests sur chaque déploiement 2026-07-04 11:47:38 +04:00
cedric c7e3faf10a chore: supprimer le code mort (soumission, connexion, stats, CGU) 2026-07-04 11:46:46 +04:00
cedric 4c662e89b5 chore: retirer le state et l'UI morts de navigasyon.js 2026-07-04 11:46:00 +04:00
cedric 2bec90210a chore: retirer la config webpack SVG morte 2026-07-04 11:45:49 +04:00
cedric 44e1a426b0 fix: retirer force-dynamic sur la page d'accueil 2026-07-04 11:45:39 +04:00
cedric d2971870c1 fix: corriger les bugs du lecteur audio (rendus, effets dupliqués) 2026-07-04 11:45:22 +04:00
cedric 1d8edbbcd5 fix: éviter un crash si la transcription est vide 2026-07-04 11:45:03 +04:00
cedric a669abbfc2 refactor: extraire getAlias dans son propre module et le sécuriser 2026-07-04 11:44:32 +04:00
cedric 69715a1a22 fix: remplacer router.asPath mort et sécuriser les iframes tierces 2026-07-04 11:43:49 +04:00
cedric 7b9968cb82 fix: corriger la détection d'environnement dans robots.js 2026-07-04 11:43:30 +04:00
cedric 77f8cea399 fix: corriger la casse des slots de scroll du Tabs MUI 2026-07-04 11:43:20 +04:00
cedric c407a1fad9 fix: ne plus exposer les identifiants Funkwhale côté client 2026-07-04 11:42:53 +04:00
cedric c7104c6a0b fix: paginer entièrement le sitemap et retirer le lien mort /pwopose 2026-07-04 01:27:56 +04:00
cedric c4dca87b61 fix: unifier la logique de choix de taille d'image via formatKuveti 2026-07-04 01:20:53 +04:00
cedric 24f4f65853 fix: request() lève une erreur claire sur réponse HTTP non-ok 2026-07-04 01:17:06 +04:00
cedric 3f30f699f7 test: mise en place de Vitest + React Testing Library 2026-07-04 01:15:24 +04:00
cedric 399796bb73 perf: préférer le format small pour les avatars de la sidebar
Déploiement FRONT BETA / check (push) Successful in 2m4s
Déploiement FRONT PROD / check (push) Successful in 2m5s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-04 00:33:04 +04:00
cedric b67bdf0632 perf: désactiver le prefetch next/link sur la sidebar des paroles
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m8s
Déploiement FRONT BETA / deploy (push) Successful in 22s
Déploiement FRONT PROD / deploy (push) Successful in 18s
2026-07-04 00:21:23 +04:00
cedric 9f9b408f1f perf: streamer la liste des morceaux sans bloquer le rendu de la page
Déploiement FRONT BETA / check (push) Successful in 2m2s
Déploiement FRONT PROD / check (push) Successful in 2m5s
Déploiement FRONT BETA / deploy (push) Successful in 22s
Déploiement FRONT PROD / deploy (push) Successful in 19s
2026-07-04 00:03:22 +04:00
cedric 15367f388e perf: ne plus précharger le fichier audio entier avant lecture 2026-07-04 00:03:18 +04:00
cedric 7234c1408f chore: relever pageSize de 100 à 200 sur les listes paginées
Déploiement FRONT BETA / check (push) Successful in 2m3s
Déploiement FRONT PROD / check (push) Successful in 2m5s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-03 22:22:22 +04:00
cedric e4d31b0243 fix: icônes partage/streaming via contexte au lieu du layout 2026-07-03 22:22:11 +04:00
cedric 96b7a601c8 fix: icônes partage/streaming ne dépendent plus du plafond de 100
Déploiement FRONT BETA / check (push) Successful in 2m13s
Déploiement FRONT PROD / check (push) Successful in 2m13s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-03 21:48:25 +04:00
cedric a56b355493 fix: lien vers le titre phare sur la page artiste 2026-07-03 19:59:49 +04:00
cedric 55967bfc3c refactor: generaliser stems en kits (type Stems/Acapella+Instru) 2026-07-03 16:57:44 +04:00
cedric daa3510d73 style: flèches de scroll des tabs plus visibles
Déploiement FRONT BETA / check (push) Successful in 2m5s
Déploiement FRONT PROD / check (push) Successful in 2m5s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-03 16:27:19 +04:00
cedric cad6e39f53 fix: toujours afficher les flèches de scroll des tabs
Déploiement FRONT BETA / check (push) Successful in 2m3s
Déploiement FRONT PROD / check (push) Successful in 2m12s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-03 16:18:37 +04:00
cedric 25066e97ce feat: display stems download links for CC tracks
Déploiement FRONT BETA / check (push) Successful in 2m15s
Déploiement FRONT PROD / check (push) Successful in 2m2s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-03 14:16:26 +04:00
87 changed files with 2438 additions and 5939 deletions
+8 -8
View File
@@ -3,8 +3,7 @@ API_URL=http://localhost:1337/api
NEXT_PUBLIC_API_URL=$API_URL
NEXT_PUBLIC_API_URL_ROOT=http://localhost:1337
SITE_URL=http://localhost:3001
NEXT_PUBLIC_READ_TOKEN=
NEXT_PUBLIC_ADMIN_JWT_SECRET=
API_READ_TOKEN=
# IDENTITÉ DU SITE (branding)
NEXT_PUBLIC_SITE_NAME=PAWÒL-NU. Paroles et traductions.
@@ -18,8 +17,8 @@ NEXT_PUBLIC_EXCLUSIVE_ARTIST_LABEL=OKI Exclusif
# FUNKWHALE VARIABLE
NEXT_PUBLIC_OKI_MIZIK_URL=https://funkwhale-server.com
NEXT_PUBLIC_MIZIK_API_USER=user
NEXT_PUBLIC_MIZIK_API_PASSWORD=password
MIZIK_API_USER=user
MIZIK_API_PASSWORD=password
NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ=6
NEXT_PUBLIC_SITE_URL=$SITE_URL
@@ -77,18 +76,19 @@ NEXT_PUBLIC_LIBERAPAY_DONATE=
NEXT_PUBLIC_PALE_USERNAME=
NEXT_PUBLIC_GADE_USERNAME=
NEXT_PUBLIC_YOUTUBE_USERNAME=
NEXT_PUBLIC_TELEGRAM_GROUP=
NEXT_PUBLIC_XMPP=
NEXT_PUBLIC_GIT=
NEXT_PUBLIC_CODEBERG=
NEXT_PUBLIC_BLUESKY_URL=
# Commentaires fédérés (bokante.o-k-i.net)
NEXT_PUBLIC_BOKANTE_URL=https://bokante.o-k-i.net
NEXT_PUBLIC_BOKANTE_ACCOUNT=pawol_nu
# DOMAIN IMAGE
NEXT_PUBLIC_DOMAINS_IMAGE="localhost:1337 strapi.mondomaine.com"
# JWT SECRET
NEXT_PUBLIC_JWT_SECRET=
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
+7
View File
@@ -22,6 +22,12 @@ jobs:
- name: Vérifier les dépendances
run: yarn install --frozen-lockfile
- name: Lancer le lint
run: yarn lint
- name: Lancer les tests
run: yarn test
deploy-beta:
needs: check
runs-on: ubuntu-latest
@@ -33,6 +39,7 @@ jobs:
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
set -e
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ secrets.FRONT_DEPLOY_PATH }}
+7
View File
@@ -20,6 +20,12 @@ jobs:
- name: Vérifier les dépendances
run: yarn install --frozen-lockfile
- name: Lancer le lint
run: yarn lint
- name: Lancer les tests
run: yarn test
deploy:
needs: check
runs-on: ubuntu-latest
@@ -31,6 +37,7 @@ jobs:
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
set -e
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ secrets.FRONT_DEPLOY_PATH }}
+7
View File
@@ -22,6 +22,12 @@ jobs:
- name: Vérifier les dépendances
run: yarn install --frozen-lockfile
- name: Lancer le lint
run: yarn lint
- name: Lancer les tests
run: yarn test
deploy:
needs: check
runs-on: ubuntu-latest
@@ -33,6 +39,7 @@ jobs:
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
set -e
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ secrets.PROD_DEPLOY_PATH }}
+18
View File
@@ -24,6 +24,24 @@ cp .env.sample .env
yarn && yarn dev
```
## Commentaires fédérés (Mastodon / bokante.o-k-i.net)
Les commentaires sous une parole peuvent provenir d'un compte pawol.nu ou du Fediverse
(réponses reçues sur le statut miroir publié sur bokante.o-k-i.net par le backend). Un
bouton permet de répondre directement depuis son propre compte Mastodon, quelle que soit
son instance, via `authorize_interaction` — aucun compte pawol.nu requis.
**Variables d'environnement :**
| Variable | Description |
|---|---|
| `NEXT_PUBLIC_BOKANTE_URL` | Instance Mastodon de l'organisation (défaut : `https://bokante.o-k-i.net`) |
| `NEXT_PUBLIC_BOKANTE_ACCOUNT` | Compte bot dont les statuts servent de miroir (défaut : `pawol_nu`) |
Voir le RFC dédié pour le détail de la conception
(`RFC-commentaires-activitypub-2026-07-04.md`, à la racine du dossier `PAWOL.NU`) et le
`README.md` de `api.pawol.nu` pour la configuration côté backend.
## License
Copyright (C) 2020 - 2026 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
+22
View File
@@ -0,0 +1,22 @@
import {describe, it, expect, afterEach} from 'vitest'
import robots from '../robots'
describe('robots', () => {
const originalNodeEnv = process.env.NODE_ENV
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
it('autorise l\'indexation en production', () => {
process.env.NODE_ENV = 'production'
expect(robots().rules).toEqual({userAgent: '*', allow: '/'})
})
it('bloque l\'indexation hors production', () => {
process.env.NODE_ENV = 'development'
expect(robots().rules).toEqual({userAgent: '*', disallow: '/'})
})
})
+23
View File
@@ -0,0 +1,23 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {jwennToutAwtis} from '../../../../lib/oki-api'
import {GET} from '../route'
vi.mock('../../../../lib/oki-api', () => ({
jwennToutAwtis: vi.fn()
}))
describe('GET /api/awtis', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renvoie les données de jwennToutAwtis en JSON, sans exposer de jeton', async () => {
jwennToutAwtis.mockResolvedValue({data: [{id: 1, alias: 'Foo'}], meta: {}})
const response = await GET()
const body = await response.json()
expect(body).toEqual({data: [{id: 1, alias: 'Foo'}], meta: {}})
expect(JSON.stringify(body)).not.toMatch(/bearer|token/i)
})
})
+6
View File
@@ -0,0 +1,6 @@
import {jwennToutAwtis} from '../../../lib/oki-api'
export async function GET() {
const data = await jwennToutAwtis()
return Response.json(data)
}
@@ -0,0 +1,68 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {GET} from '../route'
function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) {
return {
status,
ok: status >= 200 && status < 300,
body,
headers: {
get: key => headers[key.toLowerCase()] ?? null
}
}
}
describe('GET /api/mizik-stream/[id]', () => {
const originalFetch = global.fetch
afterEach(() => {
global.fetch = originalFetch
vi.restoreAllMocks()
})
it('n\'expose pas les identifiants au client, uniquement l\'id dans l\'URL amont', async () => {
global.fetch = vi.fn(async () => fakeUpstreamResponse())
const request = new Request('http://localhost/api/mizik-stream/42')
await GET(request, {params: Promise.resolve({id: '42'})})
const [calledUrl] = global.fetch.mock.calls[0]
expect(calledUrl).toContain('id=42')
expect(calledUrl).toMatch(/[?&]u=/)
expect(calledUrl).toMatch(/[?&]p=/)
})
it('transmet l\'en-tête Range pour permettre le seek audio', async () => {
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 206, headers: {'content-range': 'bytes 0-99/200'}}))
const request = new Request('http://localhost/api/mizik-stream/42', {headers: {range: 'bytes=0-99'}})
const response = await GET(request, {params: Promise.resolve({id: '42'})})
const [, options] = global.fetch.mock.calls[0]
expect(options.headers.range).toBe('bytes=0-99')
expect(response.status).toBe(206)
expect(response.headers.get('content-range')).toBe('bytes 0-99/200')
})
it('renvoie le flux et le statut renvoyés par le serveur Funkwhale', async () => {
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 200, headers: {'content-type': 'audio/mpeg'}}))
const request = new Request('http://localhost/api/mizik-stream/7')
const response = await GET(request, {params: Promise.resolve({id: '7'})})
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('audio/mpeg')
})
it('renvoie une erreur JSON si le serveur audio répond en erreur', async () => {
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 401, headers: {'content-type': 'application/json'}, body: '{"error": "unauthorized"}'}))
const request = new Request('http://localhost/api/mizik-stream/7')
const response = await GET(request, {params: Promise.resolve({id: '7'})})
expect(response.status).toBe(401)
expect(response.headers.get('content-type')).toContain('application/json')
const json = await response.json()
expect(json.error).toContain('401')
})
})
+37
View File
@@ -0,0 +1,37 @@
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
// Les variables publiques sont conservées en fallback pendant la migration
// des déploiements qui utiliseraient encore lancien nommage.
const MIZIK_API_USER = process.env.MIZIK_API_USER || process.env.NEXT_PUBLIC_MIZIK_API_USER || 'user'
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || process.env.NEXT_PUBLIC_MIZIK_API_PASSWORD || 'password'
const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges']
export async function GET(request, props) {
const {id} = await props.params
const upstreamUrl = `${MIZIK_URL}/rest/stream?u=${encodeURIComponent(MIZIK_API_USER)}&p=${encodeURIComponent(MIZIK_API_PASSWORD)}&id=${encodeURIComponent(id)}`
const range = request.headers.get('range')
const upstreamResponse = await fetch(upstreamUrl, {
headers: range ? {range} : {}
})
if (!upstreamResponse.ok) {
const message = `Le serveur audio a répondu ${upstreamResponse.status}`
console.error(message, upstreamUrl.replace(/p=[^&]+/, 'p=***'))
return Response.json({error: message}, {status: upstreamResponse.status})
}
const headers = new Headers()
for (const key of FORWARDED_HEADERS) {
const value = upstreamResponse.headers.get(key)
if (value) {
headers.set(key, value)
}
}
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
headers
})
}
-17
View File
@@ -1,17 +0,0 @@
'use client'
import Proptypes from 'prop-types'
import {SessionProvider} from 'next-auth/react'
export default function AuthProvider({children, session}) {
return (
<SessionProvider session={session}>
{children}
</SessionProvider>
)
}
AuthProvider.propTypes = {
children: Proptypes.node,
session: Proptypes.object
}
+6 -2
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation'
import Box from '@mui/material/Box'
@@ -20,7 +21,7 @@ async function jwennAwtis(slug) {
}
export async function generateMetadata(props) {
const params = await props.params;
const params = await props.params
const {slug} = params
const anAwtis = await jwennAwtis(slug)
@@ -64,9 +65,11 @@ export async function generateMetadata(props) {
}
export default async function AwtisPajSlug(props) {
const params = await props.params;
const params = await props.params
const {slug} = params
const anAwtis = await jwennAwtis(slug)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {photo} = anAwtis
const kuvetiFormat = formatKuveti(photo)
@@ -91,6 +94,7 @@ export default async function AwtisPajSlug(props) {
<section>
<script
type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/>
</section>
+1 -1
View File
@@ -63,7 +63,7 @@ async function jwennDone(paj) {
}
export default async function AwitsPaj(props) {
const searchParams = await props.searchParams;
const searchParams = await props.searchParams
const {paj} = searchParams
const {pajTotal, awtisPouChakPaj, pajParsed} = await jwennDone(paj || 1)
+6 -3
View File
@@ -7,10 +7,10 @@ import {useServerInsertedHTML} from 'next/navigation'
import {CacheProvider as DefaultCacheProvider} from '@emotion/react'
export default function NextAppDirEmotionCacheProvider(props) {
const {options, CacheProvider = DefaultCacheProvider, children} = props
const {options, CacheProvider = DefaultCacheProvider, children, nonce} = props
const [registry] = React.useState(() => {
const cache = createCache(options)
const cache = createCache({...options, nonce})
cache.compat = true
const prevInsert = cache.insert
let inserted = []
@@ -65,12 +65,14 @@ export default function NextAppDirEmotionCacheProvider(props) {
<style
key={name}
dangerouslySetInnerHTML={{__html: style}}
nonce={nonce}
data-emotion={`${registry.cache.key}-global ${name}`}
/>
))}
{styles !== '' && (
<style
dangerouslySetInnerHTML={{__html: styles}}
nonce={nonce}
data-emotion={dataEmotionAttribute}
/>
)}
@@ -84,5 +86,6 @@ export default function NextAppDirEmotionCacheProvider(props) {
NextAppDirEmotionCacheProvider.propTypes = {
options: PropTypes.object.isRequired,
CacheProvider: PropTypes.func,
children: PropTypes.node.isRequired
children: PropTypes.node.isRequired,
nonce: PropTypes.string
}
+9 -4
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import PlausibleProvider from 'next-plausible'
import TopLoader from '../components/top-loader'
import Navigasyon from '../components/navigasyon'
@@ -61,16 +62,20 @@ const jsonLd = {
}
export default async function RootLayout({children}) {
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const inner = (
<>
<TopLoader color='#ffeb3b' />
<ThemeRegistry>
<TopLoader color='#ffeb3b' nonce={nonce} />
<ThemeRegistry nonce={nonce}>
<Navigasyon />
{children}
</ThemeRegistry>
<section>
<script
type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/>
</section>
@@ -78,10 +83,10 @@ export default async function RootLayout({children}) {
)
return (
<html lang='fr' suppressHydrationWarning>
<html suppressHydrationWarning lang='fr'>
<body>
{plausibleUrl
? <PlausibleProvider src={plausibleUrl}>{inner}</PlausibleProvider>
? <PlausibleProvider src={plausibleUrl} scriptProps={{nonce}}>{inner}</PlausibleProvider>
: inner}
</body>
</html>
+3 -4
View File
@@ -1,5 +1,3 @@
export const dynamic = 'force-dynamic'
import Box from '@mui/material/Box'
import Container from '@mui/material/Container'
import {notFound} from 'next/navigation'
@@ -9,10 +7,11 @@ import Statistik from '../components/akey/statistik'
import Akey from '../components/akey'
import AnVedette from '../components/akey/an-vedette'
import okiLogo from '../public/logo-512x512.png'
import Footer from '../components/footer'
import Aso from '../components/akey/aso'
export const dynamic = 'force-dynamic'
async function jwennDone() {
const [statistik, denyeTeks, anVedette] = await Promise.all([jwennStats(), jwennDenyeTeks(), jwennAnVedette()])
@@ -28,7 +27,7 @@ export default async function Page() {
return (
<Box sx={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}>
<Akey logo={okiLogo} />
<Akey />
<Container sx={{flexGrow: 100}}>
{dernierTeks && <AnVedette teks={dernierTeks} />}
<Statistik statistik={statistik} />
+8 -4
View File
@@ -1,9 +1,10 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation'
import Box from '@mui/material/Box'
import {jwennTeksEpiSlug} from '../../../lib/oki-api'
import AnTeks from '../../../components/teks/an-teks'
import {getAlias} from '../../../lib/utils/format'
import {getAlias} from '../../../lib/utils/get-alias'
import {formatKuveti} from '../../../lib/kuveti'
import Footer from '../../../components/footer'
@@ -21,14 +22,14 @@ async function jwennAnTeks(slug) {
}
export async function generateMetadata(props) {
const params = await props.params;
const params = await props.params
const {slug} = params
const anTeks = await jwennAnTeks(slug)
const awtis = anTeks?.artistes?.length === 1 ? anTeks?.artistes[0].alias : getAlias(anTeks.artistes, anTeks.prioriteArtistes)
const title = `PAWÒL-NU | ${awtis} - ${anTeks.titre}`
const description = `Paroles de « ${anTeks?.titre} » : ${anTeks?.transcription.slice(0, 100)}...`
const description = `Paroles de « ${anTeks?.titre} » : ${anTeks?.transcription?.slice(0, 100) ?? ''}...`
const url = `${siteUrl}/paroles/${slug}`
const {couverture} = anTeks
@@ -67,10 +68,12 @@ export async function generateMetadata(props) {
}
export default async function AnPawolPaj(props) {
const params = await props.params;
const params = await props.params
const {slug} = params
const anTeks = await jwennAnTeks(slug)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {couverture} = anTeks
const teksKuvetiFormat = formatKuveti(couverture)
@@ -126,6 +129,7 @@ export default async function AnPawolPaj(props) {
<section>
<script
type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/>
</section>
+11 -19
View File
@@ -1,7 +1,7 @@
import {Suspense} from 'react'
import {notFound} from 'next/navigation'
import {jwennTeks} from '../../lib/oki-api'
import TeksDrawer from '../../components/teks/teks-drawer'
import PropTypes from 'prop-types'
import TeksDrawerLoader from '../../components/teks/teks-drawer-loader'
import {CurrentTrackProvider} from '../../components/teks/current-track-context'
import Loading from './loading'
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'PAWÒL-NU. Paroles et traductions.'
@@ -39,27 +39,19 @@ export const metadata = {
}
}
async function jwennDotDone() {
const teks = await jwennTeks()
if (!teks) {
notFound()
}
return teks
}
export default async function PawolLayout({children}) {
const teks = await jwennDotDone()
export default function PawolLayout({children}) {
return (
<>
<CurrentTrackProvider>
<Suspense fallback={<Loading />}>
<TeksDrawer paroles={teks} />
<TeksDrawerLoader />
</Suspense>
<section>
{children}
</section>
</>
</CurrentTrackProvider>
)
}
PawolLayout.propTypes = {
children: PropTypes.node.isRequired
}
+1 -1
View File
@@ -1,5 +1,5 @@
export default function robots() {
const isProduction = process.env.NEXT_PUBLIC_ENV === 'production';
const isProduction = process.env.NODE_ENV === 'production'
if (!isProduction) {
return {
+1 -1
View File
@@ -14,7 +14,7 @@ export default function Sipote() {
<Box sx={{display: 'flex', marginTop: 10, flexDirection: 'column', minHeight: '100vh'}}>
<Box sx={{flexGrow: 1, marginBottom: 10}}>
<Container>
<Typography gutterBottom sx={{fontWeight: 'bold', textAlign:'center' }} variant='h4' component='h1'>
<Typography gutterBottom sx={{fontWeight: 'bold', textAlign: 'center'}} variant='h4' component='h1'>
Soutenir ORGANISATION KA INTERNATIONALE !
</Typography>
<Paper sx={{padding: 2, marginTop: 3}}>
-4
View File
@@ -31,10 +31,6 @@ export default async function sitemap() {
{
url: `${url}/awtis`,
priority: 0.6
},
{
url: `${url}/pwopose`,
priority: 0.5
}
]
+5 -4
View File
@@ -64,12 +64,12 @@ const theme = createTheme({
})
export default function ThemeRegistry(props) {
const {children} = props
const {children, nonce} = props
return (
<>
<InitColorSchemeScript attribute='class' />
<NextAppDirEmotionCacheProvider options={{key: 'mui'}}>
<InitColorSchemeScript attribute='class' nonce={nonce} />
<NextAppDirEmotionCacheProvider options={{key: 'mui'}} nonce={nonce}>
<ThemeProvider theme={theme}>
<CssBaseline enableColorScheme />
<ChanjeTem />
@@ -81,5 +81,6 @@ export default function ThemeRegistry(props) {
}
ThemeRegistry.propTypes = {
children: PropTypes.node.isRequired
children: PropTypes.node.isRequired,
nonce: PropTypes.string
}
+3 -3
View File
@@ -10,7 +10,7 @@ import Chip from '@mui/material/Chip'
import Image from 'next/image'
import Link from 'next/link'
import {getAlias} from '../../lib/utils/format'
import {getAlias} from '../../lib/utils/get-alias'
import {formatKuveti} from '../../lib/kuveti'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -36,10 +36,10 @@ export default function AnVedette({teks}) {
<Box sx={{position: 'relative', width: '100%', aspectRatio: {xs: '1 / 1', sm: '16 / 9'}, maxHeight: {sm: 420}}}>
{fmt?.url ? (
<Image
src={`${IMAGE_URL}${fmt.url}`}
alt={titre}
fill
priority
src={`${IMAGE_URL}${fmt.url}`}
alt={titre}
placeholder='blur'
blurDataURL={BLUR_DATA_URL}
sizes='(max-width: 600px) 100vw, 800px'
+1 -10
View File
@@ -1,13 +1,8 @@
import PropTypes from 'prop-types'
import Container from '@mui/material/Container'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid'
import Button from '@mui/material/Button'
import Image from 'next/image'
import Link from 'next/link'
export default function Akey({logo}) {
export default function Akey() {
return (
<Box sx={{flexGrow: 1, marginBottom: 3, marginTop: 7}}>
<Container align='center'>
@@ -25,7 +20,3 @@ export default function Akey({logo}) {
</Box>
)
}
Akey.propTypes = {
logo: PropTypes.object.isRequired
}
+18 -13
View File
@@ -45,11 +45,11 @@ export default function AwtisDetay({anAwtis}) {
const hasStreaming = isExclusiveArtist && titrePhare?.streamAudio?.length > 0
const coverUrl = titrePhare?.couverture
? `${IMAGE_URL}${titrePhare.couverture.formats?.small?.url || titrePhare.couverture.formats?.thumbnail?.url || titrePhare.couverture.url}`
? `${IMAGE_URL}${formatKuveti(titrePhare.couverture, 'small')?.url}`
: null
const photoUrl = photo?.url
? `${IMAGE_URL}${photo.formats?.small?.url || photo.formats?.thumbnail?.url || photo.url}`
? `${IMAGE_URL}${formatKuveti(photo, 'small')?.url}`
: null
return (
@@ -72,7 +72,8 @@ export default function AwtisDetay({anAwtis}) {
position: 'relative',
flexShrink: 0,
mb: 2,
}}>
}}
>
{photoUrl ? (
<Image
src={photoUrl}
@@ -94,8 +95,8 @@ export default function AwtisDetay({anAwtis}) {
{rezoSosyal?.length > 0 && (
<Box sx={{display: 'flex', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center', mb: 1.5}}>
{rezoSosyal.map((rezo, i) => (
<SocialButton key={i} rezo={rezo} />
{rezoSosyal.map(rezo => (
<SocialButton key={rezo.url} rezo={rezo} />
))}
</Box>
)}
@@ -146,30 +147,34 @@ export default function AwtisDetay({anAwtis}) {
<Grid container>
{coverUrl && (
<Grid size={{xs: 12, sm: 4}}>
<Link href={`/paroles/${titrePhare.slug}`} style={{display: 'block', height: '100%'}}>
<Box sx={{position: 'relative', minHeight: 130, height: '100%'}}>
<Image
fill
src={coverUrl}
alt={titrePhare.titre}
fill
placeholder='blur'
blurDataURL={BLUR_DATA_URL}
sizes='200px'
style={{objectFit: 'cover'}}
/>
</Box>
</Link>
</Grid>
)}
<Grid size={{xs: 12, sm: coverUrl ? 8 : 12}}>
<CardContent>
<Typography variant='subtitle1' sx={{fontWeight: 700}} gutterBottom>
<Typography gutterBottom variant='subtitle1' sx={{fontWeight: 700}}>
<Link href={`/paroles/${titrePhare.slug}`} style={{color: 'inherit', textDecoration: 'none'}}>
{titrePhare.titre}
</Link>
</Typography>
<Typography variant='caption' sx={{color: 'text.secondary', display: 'block', mb: 1}}>
Écouter sur
</Typography>
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1}}>
{titrePhare.streamAudio.map((lyen, i) => (
<StreamButton key={i} lyen={lyen} />
{titrePhare.streamAudio.map(lyen => (
<StreamButton key={lyen.url} lyen={lyen} />
))}
</Box>
</CardContent>
@@ -189,7 +194,7 @@ export default function AwtisDetay({anAwtis}) {
<AccordionDetails sx={{paddingInline: 0}}>
{sortedTeks.map(anPawol => {
const {couverture} = anPawol
const kuvetiFormat = couverture?.formats?.thumbnail || formatKuveti(couverture)
const kuvetiFormat = formatKuveti(couverture, 'thumbnail')
return (
<Box key={anPawol.id} sx={{paddingBlock: 0.5}}>
<MizikLyen anPawol={anPawol} kuveti={kuvetiFormat} />
@@ -198,7 +203,7 @@ export default function AwtisDetay({anAwtis}) {
})}
</AccordionDetails>
</Accordion>
) : paroles.length === 0 ? (
) : (paroles.length === 0 ? (
<Typography textAlign='center' variant='body1' color='text.secondary'>
Aucune parole pour le moment
</Typography>
@@ -206,10 +211,10 @@ export default function AwtisDetay({anAwtis}) {
<Box>
<Typography gutterBottom variant='body1'><strong>Parole</strong></Typography>
<Paper sx={{paddingBlock: 2}}>
<MizikLyen anPawol={paroles[0]} kuveti={paroles[0].couverture?.formats?.thumbnail || formatKuveti(paroles[0].couverture)} />
<MizikLyen anPawol={paroles[0]} kuveti={formatKuveti(paroles[0].couverture, 'thumbnail')} />
</Paper>
</Box>
)}
))}
</Grid>
</Grid>
+2 -2
View File
@@ -15,10 +15,10 @@ import Chip from '@mui/material/Chip'
import {styled} from '@mui/material/styles'
import VerifiedIcon from '@mui/icons-material/Verified'
import {formatKuveti} from '../../lib/kuveti'
import AwtisBiyografi from './awtis-biyografi'
const PREFIX = 'awtis-kat'
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3001'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
const EXCLUSIVE_LABEL = process.env.NEXT_PUBLIC_EXCLUSIVE_ARTIST_LABEL || 'OKI Exclusif'
@@ -82,7 +82,7 @@ export default function AwtisKat({artiste}) {
className={classes.media}
component='img'
alt={alias}
image={`${photo?.url ? `${IMAGE_URL}${photo?.formats?.thumbnail?.url || photo?.url}` : noImageUrl}`}
image={`${photo?.url ? `${IMAGE_URL}${formatKuveti(photo, 'thumbnail')?.url}` : noImageUrl}`}
loading='lazy'
title={alias}
/>
+16 -19
View File
@@ -5,10 +5,9 @@ import {useRouter} from 'next/navigation'
import TextField from '@mui/material/TextField'
import Autocomplete from '@mui/material/Autocomplete'
import Avatar from '@mui/material/Avatar'
import CircularProgress from '@mui/material/CircularProgress'
import Container from '@mui/material/Container'
import {jwennToutAwtis} from '../../lib/oki-api'
import {formatKuveti} from '../../lib/kuveti'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -27,7 +26,7 @@ export default function ChecheAwtis() {
(async () => {
try {
const {data} = await jwennToutAwtis()
const {data} = await fetch('/api/awtis').then(response => response.json())
const filteredData = data.map(artiste => {
const firstLetter = artiste.alias[0].toUpperCase()
@@ -49,15 +48,13 @@ export default function ChecheAwtis() {
}
}, [loading])
const sortedOptions = useMemo(() => {
return [...options].sort((a, b) =>
const sortedOptions = useMemo(() => [...options].sort((a, b) =>
-b.firstLetter.localeCompare(a.firstLetter)
);
}, [options]);
), [options])
return (
<Container
sx={{ display: 'flex', justifyContent: 'center', marginBottom: 3 }}
sx={{display: 'flex', justifyContent: 'center', marginBottom: 3}}
>
<Autocomplete
autoHighlight
@@ -69,35 +66,35 @@ export default function ChecheAwtis() {
noOptionsText='Aucun résultat'
id='cheche-awtis'
options={sortedOptions}
groupBy={(option) => option?.firstLetter}
getOptionLabel={(option) => option?.alias}
renderOption={(props, option) => {
const {key, ...rest} = props;
groupBy={option => option?.firstLetter}
getOptionLabel={option => option?.alias}
renderOption={(optionProps, option) => {
const {key, ...rest} = optionProps
return (
<li key={key} {...rest}>
<Avatar
style={{ marginRight: 8 }}
style={{marginRight: 8}}
alt={option?.alias}
src={`${IMAGE_URL}${option?.photo?.formats?.thumbnail?.url || option?.photo?.url || ''}`}
src={`${IMAGE_URL}${formatKuveti(option?.photo, 'thumbnail')?.url || ''}`}
/>
{option?.alias}
</li>
);
)
}}
sx={{ width: 300 }}
renderInput={(params) => (
sx={{width: 300}}
renderInput={params => (
<TextField
{...params}
label='Rechercher un artiste'
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps.htmlInput },
htmlInput: {...params.slotProps.htmlInput},
}}
/>
)}
onChange={(event, newValue) => {
router.push(`/awtis/${newValue.slug}`);
router.push(`/awtis/${newValue.slug}`)
}}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function MizikLis({niAwtis, paroles, meteEsMobilOuve}) {
itemContent={index => {
const anPawol = pawol[index]
const {couverture} = anPawol
const kuvetiFormat = couverture?.formats?.thumbnail || formatKuveti(couverture)
const kuvetiFormat = formatKuveti(couverture, 'thumbnail')
return (
<MizikLyen niAwtis={niAwtis} anPawol={anPawol} kuveti={kuvetiFormat} slug={params.slug} meteEsMobilOuve={meteEsMobilOuve} />
+2 -1
View File
@@ -12,7 +12,7 @@ import LibraryMusicIcon from '@mui/icons-material/LibraryMusic'
import ExplicitIcon from '@mui/icons-material/Explicit'
import {esBrandNew} from '../../lib/date'
import {getAlias} from '../../lib/utils/format'
import {getAlias} from '../../lib/utils/get-alias'
const apiUrl = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -20,6 +20,7 @@ export default function MizikLyen({niAwtis, anPawol, kuveti, slug, meteEsMobilOu
return (
<Link
passHref
prefetch={false}
href={`/paroles/${anPawol.slug}#${anPawol.slug}`}
style={{textDecoration: 'none', width: '100%', display: 'flex', alignItems: 'center'}}
onClick={() => meteEsMobilOuve(false)}
+9 -1
View File
@@ -1,5 +1,6 @@
'use client'
import PropTypes from 'prop-types'
import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
@@ -59,15 +60,22 @@ export function SocialButton({rezo}) {
return (
<Tooltip title={rezo.url}>
<Chip
clickable
label={config.label}
component='a'
href={rezo.url}
target='_blank'
rel='noopener noreferrer'
clickable
size='small'
sx={{bgcolor: config.bg, color: config.color, fontWeight: 600}}
/>
</Tooltip>
)
}
SocialButton.propTypes = {
rezo: PropTypes.shape({
plateforme: PropTypes.string.isRequired,
url: PropTypes.string.isRequired
}).isRequired
}
+2 -2
View File
@@ -57,7 +57,7 @@ export default function LicenseModal({license, sourceOriginale, remixes}) {
<DialogContent dividers>
{sourceOriginale && (
<Box sx={{mb: 3}}>
<Typography variant='overline' color='text.secondary' display='block' gutterBottom>
<Typography gutterBottom variant='overline' color='text.secondary' display='block'>
Basé sur
</Typography>
<Link href={`/paroles/${sourceOriginale.slug}`} underline='hover' color='inherit'>
@@ -76,7 +76,7 @@ export default function LicenseModal({license, sourceOriginale, remixes}) {
)}
{remixes && (
<Box sx={{mb: 3}}>
<Typography variant='overline' color='text.secondary' display='block' gutterBottom>
<Typography gutterBottom variant='overline' color='text.secondary' display='block'>
Déclinaisons
</Typography>
{remixes.map(remix => (
+1 -1
View File
@@ -530,7 +530,7 @@ export default function Cgu() {
10. Propriété intellectuelle et licence
</Typography>
<Typography paragraph='true' gutterBottom variant='body1'>
<Typography gutterBottom paragraph='true' variant='body1'>
Le site internet <Link href='https://pawol.nu'><strong>pawol.nu</strong></Link> et les éléments qui y sont accessibles
(textes, images, graphismes, logos, vidéos, icônes, sons, etc.) sont, sauf mention contraire,
mis à disposition sous la licence <Link href='https://www.gnu.org/licenses/agpl-3.0.html' target='_blank' rel='noopener noreferrer'> <strong>GNU Affero General Public License Version 3 (AGPL-3.0)</strong></Link>. Cette licence garantit aux utilisateurs les libertés suivantes :
+25 -10
View File
@@ -70,12 +70,14 @@ export default function FilesList({files}) {
useEffect(() => {
const audioFiles = files.filter(f => f.mime.startsWith('audio'))
if (audioFiles.length === 0) return
if (audioFiles.length === 0) {
return
}
let cancelled = false
async function fetchAllMeta() {
const mm = await import('music-metadata-browser')
const mm = await import('music-metadata-browser') // eslint-disable-line node/no-unsupported-features/es-syntax
const results = {}
await Promise.all(
audioFiles.map(async file => {
@@ -98,7 +100,9 @@ export default function FilesList({files}) {
}
})
)
if (!cancelled) setAudioMeta(results)
if (!cancelled) {
setAudioMeta(results)
}
}
fetchAllMeta()
@@ -121,7 +125,8 @@ export default function FilesList({files}) {
fontWeight: 'bold',
letterSpacing: '0.05rem',
textTransform: 'uppercase',
}}>{isHaute ? 'Haute' : 'Faible'}</Typography>
}}
>{isHaute ? 'Haute' : 'Faible'}</Typography>
)
}
@@ -150,12 +155,16 @@ export default function FilesList({files}) {
}
useEffect(() => () => {
Object.values(controllersRef.current).forEach(c => c.abort())
for (const c of Object.values(controllersRef.current)) {
c.abort()
}
}, [])
const handleClick = async (e, url, fileName, fileId) => {
e.stopPropagation()
if (fileId in downloading) return
if (fileId in downloading) {
return
}
const controller = new AbortController()
controllersRef.current[fileId] = controller
@@ -163,14 +172,18 @@ export default function FilesList({files}) {
setDownloading(prev => ({...prev, [fileId]: 0}))
try {
const response = await fetch(url, {signal: controller.signal})
const contentLength = +response.headers.get('content-length')
const contentLength = Number(response.headers.get('content-length'))
const reader = response.body.getReader()
const chunks = []
let received = 0
while (true) {
for (;;) {
// eslint-disable-next-line no-await-in-loop -- chaque chunk dépend du précédent, la lecture est intrinsèquement séquentielle
const {done, value} = await reader.read()
if (done) break
if (done) {
break
}
chunks.push(value)
received += value.length
if (contentLength) {
@@ -186,7 +199,9 @@ export default function FilesList({files}) {
a.click()
URL.revokeObjectURL(blobUrl)
} catch (error) {
if (error.name !== 'AbortError') throw error
if (error.name !== 'AbortError') {
throw error
}
} finally {
delete controllersRef.current[fileId]
setDownloading(prev => {
+78
View File
@@ -0,0 +1,78 @@
import {useState} from 'react'
import PropTypes from 'prop-types'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import useMediaQuery from '@mui/material/useMediaQuery'
import {useTheme} from '@mui/material/styles'
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import {Nextcloud} from '@icons-pack/react-simple-icons'
const PLATFORM_ICON = {
NIYAJ: Nextcloud,
kDrive: CloudDownloadIcon,
}
export default function KitsDialog({kits}) {
const [open, setOpen] = useState(false)
const theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
const handleClickOpen = () => {
setOpen(true)
}
const handleClose = () => {
setOpen(false)
}
return (
<>
<Button endIcon={<CloudDownloadIcon />} sx={{marginBottom: 2}} variant='outlined' onClick={handleClickOpen}>
Kits
</Button>
<Dialog
fullScreen={fullScreen}
open={open}
aria-labelledby='KitsDialog'
onClose={handleClose}
>
<DialogTitle>Télécharger un kit</DialogTitle>
<DialogContent dividers>
<List>
{kits.map(kit => {
const PlatformIcon = PLATFORM_ICON[kit.plateforme] ?? CloudDownloadIcon
return (
<ListItem key={kit.url} disablePadding>
<ListItemButton component='a' href={kit.url} target='_blank' rel='noopener noreferrer'>
<ListItemIcon>
<PlatformIcon />
</ListItemIcon>
<ListItemText primary={kit.type} secondary={kit.plateforme} />
</ListItemButton>
</ListItem>
)
})}
</List>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleClose}>
Fermer
</Button>
</DialogActions>
</Dialog>
</>
)
}
KitsDialog.propTypes = {
kits: PropTypes.array.isRequired
}
-156
View File
@@ -1,156 +0,0 @@
import {useCallback, useEffect, useState, forwardRef} from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import {
TextField,
Container,
Button,
Snackbar,
LinearProgress,
Typography
} from '@mui/material'
import MuiAlert from '@mui/material/Alert'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337/api'
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert ref={ref} elevation={6} variant='filled' {...props} />
})
function EkriKomante({session, paroleId, meteEsKomanteOuve}) {
const {jwt, user} = session
const [komante, meteKomante] = useState({kontni: ''})
const [ere, meteEre] = useState('')
const [sikse, meteSikse] = useState('')
const [chaje, meteChaje] = useState(false)
const [esOuve, meteEsOuve] = useState(false)
const handleClick = async () => {
meteChaje(true)
const {kontni} = komante
if (kontni === '') {
meteEre({error: {message: 'Champ obligatoire'}})
meteChaje(false)
return
}
const headers = {
'content-type': 'application/json',
Authorization: `Bearer ${jwt}`
}
try {
await axios.post(`${API_URL}/commentaires`, {
data: {
contenu: kontni,
parole: paroleId,
user: {
id: user.id,
username: user.username,
email: user.email
},
datePublication: new Date()
}
}, {
headers
})
meteSikse('Commentaire envoyé avec succès. Il apparaîtra sur le site après validation.')
meteChaje(false)
} catch (error) {
meteEre(error?.response?.data)
meteChaje(false)
}
}
const handleUpdate = useCallback(update => {
meteKomante({...komante, ...update})
}, [komante])
const handleClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
meteEsOuve(false)
meteSikse('')
meteEre('')
meteEsKomanteOuve(false)
}
const handleReset = () => {
meteKomante({kontni: ''})
}
useEffect(() => {
if (sikse) {
meteEsOuve(true)
handleReset()
}
}, [sikse])
useEffect(() => {
if (ere) {
meteEsOuve(true)
}
}, [ere])
return (
<Container maxWidth='sm'>
<form noValidate autoComplete='off'>
<TextField
fullWidth
multiline
required
helperText='*Obligatoire. 500 caractères maximum'
style={{marginTop: '1em'}}
id='komante'
label='Commentaire'
value={komante.kontni}
rows={8}
variant='outlined'
onChange={event => handleUpdate({kontni: event.target.value})}
/>
</form>
<div>
<Button
fullWidth
disabled={chaje || komante.komante === ''}
style={{marginBlock: 20}}
variant='contained'
color='primary'
onClick={handleClick}
>
<Typography style={{fontWeight: 'bold'}}>
Valider
</Typography>
</Button>
{chaje && <LinearProgress size={24} style={{width: '100%', marginBlock: '1em'}} />}
</div>
{sikse && (
<Snackbar open={esOuve} autoHideDuration={10_000} onClose={handleClose}>
<Alert severity='success' onClose={handleClose}>
<strong>{sikse}</strong>
</Alert>
</Snackbar>
)}
{ere && (
<Snackbar open={esOuve} autoHideDuration={10_000} onClose={handleClose}>
<Alert severity='error' onClose={handleClose}>
<strong>Une erreur sest produite</strong> : <i>{ere?.error?.message}</i>
</Alert>
</Snackbar>
)}
</Container>
)
}
EkriKomante.propTypes = {
session: PropTypes.object.isRequired,
paroleId: PropTypes.number.isRequired,
meteEsKomanteOuve: PropTypes.func.isRequired
}
export default EkriKomante
-67
View File
@@ -1,67 +0,0 @@
import PropTypes from 'prop-types'
import {format} from 'date-fns'
import {fr} from 'date-fns/locale'
import {styled} from '@mui/material/styles'
import {
Typography,
Divider,
List,
ListItemText
} from '@mui/material'
import {formatJsonString} from '../../lib/utils/format'
const PREFIX = 'komante-list'
const classes = {
root: `${PREFIX}-root`,
inline: `${PREFIX}-inline`
}
const StyledList = styled(List)((
{
theme
}
) => ({
[`&.${classes.root}`]: {
width: '100%',
maxWidth: '36ch',
backgroundColor: theme.palette.background.paper
},
[`& .${classes.inline}`]: {
display: 'inline'
}
}))
export default function KomanteList({commentaires}) {
return (
<StyledList className={classes.root}>
{commentaires.map(({id, user, contenu, datePublication}) => (
<div key={id}>
<ListItemText
primary={
<Typography gutterBottom style={{textDecoration: 'underline'}} variant='body1'>
<small>{user.username}</small>
</Typography>
}
/>
<ListItemText
primary={formatJsonString(contenu)}
secondary={
<Typography gutterBottom style={{marginBlock: 5, fontStyle: 'italic'}} variant='caption' display='block'>
{format(new Date(datePublication), 'Pp', {locale: fr})}
</Typography>
}
/>
<Divider style={{marginBottom: '1em'}} />
</div>
))}
</StyledList>
)
}
KomanteList.propTypes = {
commentaires: PropTypes.array.isRequired
}
-136
View File
@@ -1,136 +0,0 @@
import {useState, useEffect, useRef} from 'react'
import {styled} from '@mui/material/styles'
import PropTypes from 'prop-types'
import {useSession} from 'next-auth/react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Badge
} from '@mui/material'
import {useRouter} from 'next/navigation'
import Koneksyon from '../sesyon/koneksyon'
import KomanteList from './komante-list'
import EkriKomante from './ekri-komante'
const PREFIX = 'vwe-komante'
const classes = {
tooltip: `${PREFIX}-tooltip`,
margin: `${PREFIX}-margin`,
extendedIcon: `${PREFIX}-extendedIcon`
}
const Root = styled('div')((
{
theme
}
) => ({
[`& .${classes.margin}`]: {
margin: theme.spacing(1)
},
[`& .${classes.extendedIcon}`]: {
marginRight: theme.spacing(1)
}
}))
export default function VweKomante({commentaires, parole, paroleId}) {
const [esOuve, meteEsOuve] = useState(false)
const [esKoneksyonOuve, meteEsKoneksyonOuve] = useState(false)
const [esKomenteOuve, meteEsKomanteOuve] = useState(false)
const {data: session} = useSession()
const router = useRouter()
const handleClick = () => {
meteEsOuve(true)
}
const handleClose = () => {
meteEsOuve(false)
}
const descriptionElementRef = useRef(null)
useEffect(() => {
if (esOuve) {
const {current: descriptionElement} = descriptionElementRef
if (descriptionElement !== null) {
descriptionElement.focus()
}
}
}, [esOuve])
return (
<Root>
<Badge badgeContent={commentaires.length} color='primary'>
<Button sx={{marginBlock: 3}} variant='outlined' onClick={handleClick}>
Voir les commentaires
</Button>
</Badge>
<Dialog
open={esOuve}
scroll='paper'
aria-labelledby='scroll-dialog-title'
aria-describedby='scroll-dialog-description'
onClose={handleClose}
>
<DialogTitle align='center' id='scroll-dialog-title'>Commentaires</DialogTitle>
<DialogContent dividers>
<Typography
ref={descriptionElementRef}
variant='inherit'
id='scroll-dialog-description'
tabIndex={-1}
component='div'
>
{commentaires.length > 0 ? (
<KomanteList commentaires={commentaires} />
) : (
<Typography component='div' align='center'>
Aucun commentaire
</Typography>
)}
</Typography>
</DialogContent>
<DialogActions align='center' >
{session && session.user && !esKomenteOuve && (
<Button fullWidth color='primary' onClick={() => meteEsKomanteOuve(true)}>
Ajouter un commentaire
</Button>
)}
{!session && !esKoneksyonOuve && (
<Button fullWidth color='primary' onClick={() => meteEsKoneksyonOuve(true)}>
Se connecter pour commenter
</Button>
)}
{!session && esKoneksyonOuve && (
<Koneksyon chimen={router.asPath} />
)}
</DialogActions>
{!session && esKoneksyonOuve && (
<Button style={{marginBlock: 10}} color='secondary' onClick={() => meteEsKoneksyonOuve(false)}>
<strong>Annuler</strong>
</Button>
)}
{session && session.user && esKomenteOuve && (
<>
<EkriKomante session={session} parole={parole} paroleId={paroleId} meteEsKomanteOuve={meteEsKomanteOuve} />
<Button fullWidth style={{marginBottom: 10}} color='primary' onClick={() => meteEsKomanteOuve(false)}>
Fermer
</Button>
</>
)}
</Dialog>
</Root>
)
}
VweKomante.propTypes = {
commentaires: PropTypes.array,
parole: PropTypes.object.isRequired,
paroleId: PropTypes.number.isRequired
}
+3 -39
View File
@@ -1,15 +1,11 @@
'use client'
import {useState} from 'react'
import PropTypes from 'prop-types'
import {useRouter, usePathname} from 'next/navigation'
import {styled} from '@mui/material/styles'
import AppBar from '@mui/material/AppBar'
import Tabs, {tabsClasses} from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import {useMediaQuery} from '@mui/material'
import MusicNoteIcon from '@mui/icons-material/MusicNote'
@@ -49,32 +45,6 @@ const tabPath = {
awtis: 3,
}
function TabPanel(props) {
const {children, value, index, ...other} = props
return (
<div
role='tabpanel'
hidden={value !== index}
id={`scrollable-force-tabpanel-${index}`}
aria-labelledby={`scrollable-force-tab-${index}`}
{...other}
>
{value === index && (
<Box p={3}>
<Typography component='div'>{children}</Typography>
</Box>
)}
</div>
)
}
TabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.any.isRequired,
value: PropTypes.any.isRequired
}
function a11yProps(index) {
return {
id: `scrollable-force-tab-${index}`,
@@ -89,11 +59,8 @@ export default function Navigasyon() {
const selectedTab = tabPath[selectedPath]
const isMobile = useMediaQuery('(max-width:422px)')
const [value, setValue] = useState(0)
const handleChange = (event, newValue) => {
setValue(newValue)
if (newValue !== selectedTab) {
router.push(tabRouteHref[newValue])
}
@@ -103,7 +70,8 @@ export default function Navigasyon() {
<Appdiv>
<div className={classes.root}>
<AppBar style={{zIndex: 1, boxShadow: 'none'}} position='fixed' color='default'>
{selectedTab !== undefined && <Tabs
{selectedTab !== undefined && (
<Tabs
allowScrollButtonsMobile
scrollButtons
centered={Boolean(!isMobile)}
@@ -124,12 +92,8 @@ export default function Navigasyon() {
<Tab icon={<MusicNoteIcon />} aria-label='Paroles' {...a11yProps(2)} />
<Tab icon={<RecordVoiceOverIcon />} aria-label='Artistes' {...a11yProps(3)} />
</Tabs>
}
)}
</AppBar>
<TabPanel value={value} index={0} />
<TabPanel value={value} index={1} />
<TabPanel value={value} index={2} />
<TabPanel value={value} index={3} />
</div>
</Appdiv>
)
-202
View File
@@ -1,202 +0,0 @@
import {useState, forwardRef} from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import {useRouter} from 'next/navigation'
import {FormControl, Snackbar, IconButton, Button, Input, InputAdornment, InputLabel, Box, Container, Typography, LinearProgress} from '@mui/material'
import MuiAlert from '@mui/material/Alert'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import Link from '@mui/material/Link'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert ref={ref} elevation={6} variant='filled' {...props} />
})
export default function NewPassword({code}) {
const router = useRouter()
const [showPassword, setShowPassword] = useState('')
const [showPasswordConfirmation, setShowPasswordConfirmation] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirmation, setPasswordConfirmation] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [open, setOpen] = useState(true)
const [passwordSuccess, setPasswordSuccess] = useState(false)
const resetForm = () => {
setPassword('')
setPasswordConfirmation('')
}
const changePassword = async () => {
setLoading(true)
try {
await axios.post(`${API_URL}/auth/reset-password`, {
code,
password,
passwordConfirmation
})
setLoading(false)
setPasswordSuccess(true)
setTimeout(() => {
router.push('/pwopose')
}, 10_000)
} catch {
setOpen(true)
setError('Une erreur sest produite. Veuillez réessayer ultérieurement')
}
}
const handleMouseDownPassword = event => {
event.preventDefault()
}
const handleMouseDownPasswordConfirmation = event => {
event.preventDefault()
}
const handleKeyUpPassword = event => {
if (event.keyCode === 13) {
handleClick()
}
}
const handleKeyUpPasswordConfirmation = event => {
if (event.keyCode === 13) {
handleClick()
}
}
const handleClick = async () => {
if (password !== passwordConfirmation) {
setOpen(true)
setError('Les 2 mots de passe de correspondent pas')
return
}
if (password.length < 6) {
setOpen(true)
setError('Le mot de passe est trop court, 6 caratères minimum')
return
}
await changePassword()
setLoading(false)
resetForm()
}
const handleClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
setOpen(false)
setError('')
}
return (
<Container sx={{marginTop: 2}} maxWidth='sm'>
<Box sx={{width: '100%'}}>
<Typography sx={{textAlign: 'center'}} variant='h5' component='div'>Nouveau mot de passe</Typography>
<FormControl fullWidth style={{marginTop: '1em'}}>
<InputLabel htmlFor='password'>Mot de passe</InputLabel>
<Input
value={password}
name='register-password'
type={showPassword ? 'text' : 'password'}
id='register-password'
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='password visibility'
size='large'
onClick={() => setShowPassword(!showPassword)}
onMouseDown={handleMouseDownPassword}
>
{showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
}
onChange={event => setPassword(event.target.value)}
onKeyUp={handleKeyUpPassword}
/>
</FormControl>
<FormControl fullWidth style={{marginTop: '1em'}}>
<InputLabel htmlFor='password'>Vérification du mot de passe</InputLabel>
<Input
value={passwordConfirmation}
name='verification-password'
type={showPasswordConfirmation ? 'text' : 'password'}
id='verification-password'
error={password !== passwordConfirmation}
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='password visibility'
size='large'
onClick={() => setShowPasswordConfirmation(!showPassword)}
onMouseDown={handleMouseDownPasswordConfirmation}
>
{showPasswordConfirmation ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
}
onChange={event => setPasswordConfirmation(event.target.value)}
onKeyUp={handleKeyUpPasswordConfirmation}
/>
</FormControl>
<Button
fullWidth
style={{marginTop: 20}}
variant='contained'
color='primary'
disabled={loading || password !== passwordConfirmation || password === ''}
onClick={handleClick}
>
<Typography style={{fontWeight: 'bold'}}>
Changer de mot de passe
</Typography>
</Button>
<Button
fullWidth
style={{marginTop: 20}}
variant='outlined'
color='primary'
onClick={() => router.push('/pwopose')}
>
<Typography style={{fontWeight: 'bold'}}>
Retour à la page de connexion
</Typography>
</Button>
</Box>
{loading && <LinearProgress size={24} style={{width: '100%', marginTop: '1em'}} />}
{error && (
<Snackbar
open={open}
autoHideDuration={6000}
onClose={handleClose}
>
<Alert severity='error' onClose={handleClose}><strong>{error}</strong></Alert>
</Snackbar>
)}
{passwordSuccess && (
<Snackbar
open={passwordSuccess}
onClose={handleClose}
>
<Alert severity='info' onClose={handleClose}>Votre changement de mot de passe a été pris en compte. Vous serez redirigé vers la page de connexion. <Link underline='hover' color='inherit' href='/pwopose'><strong>Cliquez ici si vous nêtes pas redirigé vers la page de connexion</strong></Link></Alert>
</Snackbar>
)}
</Container>
)
}
NewPassword.propTypes = {
code: PropTypes.string.isRequired
}
-88
View File
@@ -1,88 +0,0 @@
import {useState} from 'react'
import PropTypes from 'prop-types'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import {validateEmail} from '../../lib/utils/emails'
import {jwennUserEpiEmail, passwordRequest} from '../../lib/oki-api'
export default function ResetDialog({lyen, title, activation, content, open, setOpen, setLoading, setError, setSuccess}) {
const [email, setEmail] = useState('')
const forgotPasswordRequest = async () => {
setLoading(true)
try {
await passwordRequest(lyen, email)
if (activation) {
const user = await jwennUserEpiEmail(email)
localStorage.setItem('user-id', user?.id)
}
setLoading(false)
setSuccess(true)
} catch {
setError('Une erreur sest produite. Veuillez réessayer ultérieurement')
setLoading(false)
}
}
const resetFrom = () => {
setEmail('')
}
const handleClick = async () => {
forgotPasswordRequest()
setOpen(false)
resetFrom()
}
const handleClose = () => {
setOpen(false)
}
return (
<div>
<Dialog open={open} onClose={handleClose}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<DialogContentText>
{content}
</DialogContentText>
<TextField
autoFocus
fullWidth
value={email}
margin='dense'
id='email'
label='Adresse e-mail'
type='email'
variant='standard'
onChange={event => setEmail(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button variant='contained' color='secondary' onClick={handleClose}>Annuler</Button>
<Button disabled={!validateEmail(email)} variant='contained' color='primary' onClick={handleClick}>Valider</Button>
</DialogActions>
</Dialog>
</div>
)
}
ResetDialog.propTypes = {
lyen: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
activation: PropTypes.bool,
content: PropTypes.string.isRequired,
open: PropTypes.bool.isRequired,
setOpen: PropTypes.func.isRequired,
setLoading: PropTypes.func.isRequired,
setError: PropTypes.func.isRequired,
setSuccess: PropTypes.func.isRequired,
}
-75
View File
@@ -1,75 +0,0 @@
import {useState, forwardRef} from 'react'
import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Snackbar from '@mui/material/Snackbar'
import {LinearProgress} from '@mui/material'
import MuiAlert from '@mui/material/Alert'
import ResetDialog from './reset-dialog'
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert ref={ref} elevation={6} variant='filled' {...props} />
})
export default function ResetPassword() {
const [open, setOpen] = useState(false)
const [error, setError] = useState(false)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const handleClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
setError('')
setSuccess(false)
}
return (
<>
<Box style={{marginTop: 20}}>
<Button
fullWidth
disabled={loading}
variant='outlined'
color='warning'
onClick={() => setOpen(true)}
>
<Typography style={{fontWeight: 'bold'}}>
Mot de passe oublié
</Typography>
</Button>
</Box>
{loading && <LinearProgress size={24} style={{width: '100%', marginTop: '1em'}} />}
{error && (
<Snackbar
open={Boolean(error)}
autoHideDuration={6000}
onClose={handleClose}
>
<Alert severity='error' onClose={handleClose}><strong>{error}</strong></Alert>
</Snackbar>
)}
{success && (
<Snackbar
open={success}
autoHideDuration={15_000}
onClose={handleClose}
>
<Alert severity='success' onClose={handleClose}><strong>Email envoyé avec succès. Vous pouvez changer votre mot de passe via le lien qui vous a été envoyé.</strong></Alert>
</Snackbar>
)}
<ResetDialog
lyen='forgot-password'
title='Mot de passe oublié'
content='Indiquez ladresse e-mail utilisée lors de votre inscription. Vous receverez des instructions par e-mail.'
open={open}
setOpen={setOpen}
setLoading={setLoading}
setError={setError}
setSuccess={setSuccess}
/>
</>
)
}
-113
View File
@@ -1,113 +0,0 @@
'use client'
import {useState, useEffect, forwardRef} from 'react'
import PropTypes from 'prop-types'
import {useSession} from 'next-auth/react'
import MuiAlert from '@mui/material/Alert'
import Snackbar from '@mui/material/Snackbar'
import Box from '@mui/material/Box'
import Koneksyon from '../../components/sesyon/koneksyon'
import Dekoneksyon from '../../components/sesyon/dekoneksyon'
import EkriTeks from '../../components/soumet/ekri-teks'
import Footer from '../../components/footer'
import {jwennUserEpiToken, jwennUserEpiUsername} from '../../lib/oki-api'
import NewPassword from '../../components/password/new-password'
import ChwaTeks from '../../components/soumet/chwa-teks'
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert ref={ref} elevation={6} variant='filled' {...props} />
})
export default function Pwopose({code}) {
const {data: session} = useSession()
const [localUsername, setLocalUsername] = useState(null)
const [username, setUsername] = useState(null)
const [open, setOpen] = useState(true)
const [selectedTeks, setSelectedTeks] = useState(null)
const [canAutoTranslate, setCanAutoTranslate] = useState(false)
const handleClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
setOpen(false)
}
useEffect(() => {
if (session?.jwt) {
const getUser = async token => {
const user = await jwennUserEpiToken(token)
setCanAutoTranslate(user.canAutoTranslate)
}
getUser(session.jwt)
}
})
useEffect(() => {
if (localStorage.getItem('username')) {
const username = localStorage.getItem('username')
setLocalUsername(username)
}
}, [])
useEffect(() => {
if (localUsername) {
const getUser = async username => {
const user = await jwennUserEpiUsername(username)
setUsername(user?.username)
}
getUser(localUsername)
}
}, [localUsername])
useEffect(() => {
if (username && localStorage.getItem('username')) {
localStorage.removeItem('username')
}
}, [username])
return (
<Box sx={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}>
<Box sx={{flexGrow: 1, marginTop: 1, marginBottom: 10}}>
{!session && !code && (
<Koneksyon
chimen='/pwopose'
/>
)}
{!session && code && (
<NewPassword code={code} />
)}
{session && session.user && (
<>
<Dekoneksyon position='absolute' top={95} left={5} chimen='/pwopose' />
<ChwaTeks selectedTeks={selectedTeks} setSelectedTeks={setSelectedTeks} />
<EkriTeks canAutoTranslate={canAutoTranslate} selectedTeks={selectedTeks} setSelectedTeks={setSelectedTeks} />
</>
)}
{session && !session.user && (
<Dekoneksyon position='absolute' top={95} left={5} chimen='/pwopose' />
)}
{username && (
<Snackbar
open={open}
autoHideDuration={10_000}
onClose={handleClose}
>
<Alert severity='success' onClose={handleClose}><strong>Bonjour {username}, votre compte a été activé avec succès. Vous pouvez vous connecter.</strong></Alert>
</Snackbar>
)}
</Box>
<Footer />
</Box>
)
}
Pwopose.propTypes = {
code: PropTypes.string
}
-62
View File
@@ -1,62 +0,0 @@
import {useState} from 'react'
import {styled} from '@mui/material/styles'
import PropTypes from 'prop-types'
import {signOut} from 'next-auth/react'
import {Tooltip, Fab, Zoom} from '@mui/material'
import ExitToAppIcon from '@mui/icons-material/ExitToApp'
import Alet from '../alet'
const PREFIX = 'Dekoneksyon'
const classes = {
tooltip: `${PREFIX}-tooltip`,
dekoneksyon: `${PREFIX}-dekoneksyon`
}
const Root = styled('div')(() => ({
[`& .${classes.dekoneksyon}`]: {
position: props => props.position,
top: props => props.top,
left: props => props.left,
zIndex: 0
}
}))
const DekoneksonTooltip = Tooltip
function Dekoneksyon({tooltipPlacement}) {
const [esOuve, meteEsOuve] = useState(false)
return (
(
<Root>
<DekoneksonTooltip
title='Se déconnecter'
placement={tooltipPlacement}
TransitionComponent={Zoom}
classes={{
tooltip: classes.tooltip
}}
>
<Fab
className={classes.dekoneksyon}
color='error'
aria-label='logout'
size='small'
onClick={() => meteEsOuve(true)}
>
<ExitToAppIcon />
</Fab>
</DekoneksonTooltip>
<Alet esOuve={esOuve} meteEsOuve={meteEsOuve} handleKonfime={() => signOut()} />
</Root>
)
)
}
Dekoneksyon.propTypes = {
tooltipPlacement: PropTypes.string
}
export default Dekoneksyon
-459
View File
@@ -1,459 +0,0 @@
import {useEffect, useState, forwardRef} from 'react'
import {signIn} from 'next-auth/react'
import {useRouter} from 'next/navigation'
import PropTypes from 'prop-types'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Container from '@mui/material/Container'
import FormControl from '@mui/material/FormControl'
import IconButton from '@mui/material/IconButton'
import Input from '@mui/material/Input'
import InputAdornment from '@mui/material/InputAdornment'
import InputLabel from '@mui/material/InputLabel'
import LinearProgress from '@mui/material/LinearProgress'
import Snackbar from '@mui/material/Snackbar'
import Tab from '@mui/material/Tab'
import Tabs from '@mui/material/Tabs'
import Typography from '@mui/material/Typography'
import Grid from '@mui/material/Grid'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import MuiAlert from '@mui/material/Alert'
import LoginIcon from '@mui/icons-material/Login'
import AppRegistrationRoundedIcon from '@mui/icons-material/AppRegistrationRounded'
import axios from 'axios'
import {validateEmail} from '../../lib/utils/emails'
import ResetPassword from '../password/reset-password'
import ResetDialog from '../password/reset-dialog'
import LoginProvider from './login-provider'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3001'
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
const PROVIDERS = [
{
id: 'google',
title: 'Google',
width: 16,
height: 16
},
{
id: 'twitter',
title: 'Twitter',
width: 16,
height: 13
}
]
function TabPanel(props) {
const {children, value, index, ...other} = props
return (
<div
role='tabpanel'
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{p: 3}}>
{children}
</Box>
)}
</div>
)
}
TabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.number.isRequired,
value: PropTypes.number.isRequired,
}
function a11yProps(index) {
return {
id: `simple-tab-${index}`,
'aria-controls': `simple-tabpanel-${index}`,
}
}
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert ref={ref} elevation={6} variant='filled' {...props} />
})
function Koneksyon({chimen}) {
const [error, setError] = useState('')
const [loginCredentials, setLoginCredentials] = useState({username: '', password: ''})
const [registerCredentials, setRegisterCredentials] = useState({username: '', email: '', password: ''})
const [passwordVerification, setPasswordVerification] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showRegisterPassword, setShowRegisterPassword] = useState(false)
const [showVerificationPassword, setShowVerificationPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(true)
const [success, setSuccess] = useState(false)
const [openDialog, setOpenDialog] = useState(false)
const [value, setValue] = useState(0)
const router = useRouter()
const handleUpdate = update => {
setLoginCredentials({...loginCredentials, ...update})
}
const handleRegisterUpdate = update => {
setRegisterCredentials({...registerCredentials, ...update})
}
const handleChange = (event, newValue) => {
setValue(newValue)
}
const register = async () => {
try {
const response = await axios.post(`${apiUrl}/auth/local/register`, {
...registerCredentials
}, {
headers: {
'content-type': 'application/json'
}
})
localStorage.setItem('username', `${response?.data?.user?.username}`)
setSuccess(true)
resetRegisterForm()
} catch (error_) {
if (error_.message.endsWith(400)) {
setError('E-mail ou utilisateur déjà enregistré')
} else {
setError('Une erreur sest produite')
}
}
}
const resetRegisterForm = () => {
setRegisterCredentials({username: '', email: '', password: ''})
setPasswordVerification('')
}
const handleClickLogin = async () => {
if (!validateEmail(loginCredentials.username) || loginCredentials.password === '') {
return
}
setLoading(true)
const response = await signIn('credentials', {
callbackUrl: `${siteUrl}${chimen}`,
redirect: false,
...loginCredentials
})
if (response.error) {
setError(response.error)
setLoading(false)
} else if (response.ok) {
setLoading(false)
router.push(chimen)
}
}
const handleClickRegister = async () => {
setLoading(true)
if (!validateEmail(registerCredentials.email) || registerCredentials.password === '') {
return
}
if (registerCredentials.password !== passwordVerification) {
setError('Les 2 mots de passe de correspondent pas')
setLoading(false)
return
}
if (registerCredentials.username.length < 3) {
setError('Le nom dutilisateur est trop court, 3 caratères minimum')
setLoading(false)
return
}
if (registerCredentials.password.length < 6) {
setError('Le mot de passe est trop court, 6 caratères minimum')
setLoading(false)
return
}
await register()
setLoading(false)
}
const handleClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
setOpen(false)
setSuccess(false)
setError('')
}
useEffect(() => {
if (error) {
setOpen(true)
}
return () => {
setLoading(false)
}
}, [error])
const handleClickShowPassword = () => {
setShowPassword(!showPassword)
}
const handleClickShowRegisterPassword = () => {
setShowRegisterPassword(!showRegisterPassword)
}
const handleClickShowVerificationPassword = () => {
setShowVerificationPassword(!showVerificationPassword)
}
const handleMouseDownPassword = event => {
event.preventDefault()
}
const handleMouseDownRegisterPassword = event => {
event.preventDefault()
}
const handleMouseDownVerificationPassword = event => {
event.preventDefault()
}
const handleKeyUpLogin = event => {
if (event.keyCode === 13) {
handleClickLogin()
}
}
const handleKeyUpRegister = event => {
if (event.keyCode === 13) {
handleClickRegister()
}
}
return (
<Container sx={{marginTop: 6}} maxWidth='sm'>
<Box sx={{width: '100%'}}>
<Tabs centered value={value} aria-label='basic tabs example' onChange={handleChange}>
<Tab icon={<LoginIcon />} label='Se connecter' {...a11yProps(0)} />
<Tab icon={<AppRegistrationRoundedIcon />} label='Sinscrire' {...a11yProps(1)} />
</Tabs>
<TabPanel value={value} index={0}>
<Box sx={{textAlign: 'center', marginBottom: 3}}>
<Typography gutterBottom textalign='center'>Connectez-vous avec</Typography>
<Grid sx={{justifyContent: 'center'}} container columnSpacing={{xs: 1, sm: 2, md: 3}}>
{PROVIDERS.map(({id, title, width, height}) => (
<Grid sx={{marginTop: 1}} key={id}>
<LoginProvider id={id} title={title} width={width} height={height} callbackUrl={`${siteUrl}${chimen}`} />
</Grid>
))}
</Grid>
</Box>
<Box sx={{textAlign: 'center', marginBottom: 3}}>
<Typography>ou utilisez votre e-mail pour vous identifier</Typography>
</Box>
<FormControl fullWidth autoComplete='off'>
<InputLabel htmlFor='username'>E-mail</InputLabel>
<Input
autoComplete='new-password'
value={loginCredentials.username}
name='username'
type='email'
id='email'
onChange={event => handleUpdate({username: event.target.value})}
onKeyUp={handleKeyUpLogin}
/>
</FormControl>
<FormControl fullWidth style={{marginTop: '1em'}}>
<InputLabel htmlFor='password'>Mot de passe</InputLabel>
<Input
autoComplete='new-password'
value={loginCredentials.password}
name='password'
type={showPassword ? 'text' : 'password'}
id='password'
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='password visibility'
size='large'
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
>
{showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
}
onChange={event => handleUpdate({password: event.target.value})}
onKeyUp={handleKeyUpLogin}
/>
</FormControl>
<Button
fullWidth
disabled={loading || !validateEmail(loginCredentials.username) || loginCredentials.password === ''}
style={{marginTop: 20}}
variant='contained'
color='primary'
onClick={handleClickLogin}
>
<Typography style={{fontWeight: 'bold'}}>
Connexion
</Typography>
</Button>
<ResetPassword />
</TabPanel>
<TabPanel value={value} index={1}>
<FormControl fullWidth>
<InputLabel htmlFor='register-email'>E-mail</InputLabel>
<Input
autoComplete='email'
value={registerCredentials.email}
name='register-email'
type='email'
id='register-email'
onChange={event => handleRegisterUpdate({email: event.target.value})}
onKeyUp={handleKeyUpRegister}
/>
</FormControl>
<FormControl fullWidth style={{marginTop: '1em'}}>
<InputLabel htmlFor='register-username'>Nom dutilisateur</InputLabel>
<Input
autoComplete='username'
value={registerCredentials.username}
name='register-username'
type='text'
id='register-username'
onChange={event => handleRegisterUpdate({username: event.target.value})}
onKeyUp={handleKeyUpRegister}
/>
</FormControl>
<FormControl fullWidth style={{marginTop: '1em'}}>
<InputLabel htmlFor='password'>Mot de passe</InputLabel>
<Input
autoComplete='new-password' // Disable auto complete
value={registerCredentials.password}
name='register-password'
type={showRegisterPassword ? 'text' : 'password'}
id='register-password'
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='password visibility'
size='large'
onClick={handleClickShowRegisterPassword}
onMouseDown={handleMouseDownRegisterPassword}
>
{showRegisterPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
}
onChange={event => handleRegisterUpdate({password: event.target.value})}
onKeyUp={handleKeyUpRegister}
/>
</FormControl>
<FormControl fullWidth style={{marginTop: '1em'}}>
<InputLabel htmlFor='password'>Vérification du mot de passe</InputLabel>
<Input
value={passwordVerification}
name='verification-password'
type={showVerificationPassword ? 'text' : 'password'}
id='verification-password'
error={registerCredentials.password !== passwordVerification}
endAdornment={
<InputAdornment position='end'>
<IconButton
aria-label='password visibility'
size='large'
onClick={handleClickShowVerificationPassword}
onMouseDown={handleMouseDownVerificationPassword}
>
{showVerificationPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
}
onChange={event => setPasswordVerification(event.target.value)}
onKeyUp={handleKeyUpRegister}
/>
</FormControl>
<Button
fullWidth
disabled={loading || !validateEmail(registerCredentials.email) || registerCredentials.username === '' || registerCredentials.password === ''}
style={{marginTop: 20}}
variant='contained'
color='primary'
onClick={handleClickRegister}
>
<Typography style={{fontWeight: 'bold'}}>
Inscription
</Typography>
</Button>
<Button
fullWidth
style={{marginTop: 20}}
variant='outlined'
color='warning'
onClick={() => setOpenDialog(true)}
>
<Typography style={{fontWeight: 'bold'}}>
Renvoyer le-mail de confirmation
</Typography>
</Button>
</TabPanel>
</Box>
<ResetDialog
activation
lyen='send-email-confirmation'
title='Envoi de le-mail de confirmation'
content='Si vous navez pas reçu le-mail de confirmation, renseignez le champ et validez le formulaire.'
open={openDialog}
setOpen={setOpenDialog}
setLoading={setLoading}
setError={setError}
setSuccess={setSuccess}
/>
{loading && <LinearProgress size={24} style={{width: '100%'}} />}
{error && (
<Snackbar
open={open}
autoHideDuration={6000}
onClose={handleClose}
>
<Alert severity='error' onClose={handleClose}><strong>{error}</strong></Alert>
</Snackbar>
)}
{success && (
<Snackbar
open={success}
autoHideDuration={15_000}
onClose={handleClose}
>
<Alert severity='success' onClose={handleClose}><strong>Veuillez confirmer votre adresse e-mail via le lien qui vous a été envoyé.</strong></Alert>
</Snackbar>
)}
</Container>
)
}
Koneksyon.propTypes = {
chimen: PropTypes.string.isRequired
}
export default Koneksyon
-38
View File
@@ -1,38 +0,0 @@
import PropTypes from 'prop-types'
import Link from 'next/link'
import Image from 'next/image'
import {signIn} from 'next-auth/react'
import Button from '@mui/material/Button'
export default function LoginProvider({id, title, width, height, callbackUrl}) {
const hanleClick = event => {
event.preventDefault()
signIn(id, {
callbackUrl
})
}
return (
<Link passHref href='/api/auth/signin'>
<Button variant='outlined' color='info' startIcon={
<Image
width={width}
height={height}
alt={title}
src={`/images/${id === 'twitter' ? 'x' : id}.svg`}
/>
} onClick={hanleClick}
>
{title}
</Button>
</Link>
)
}
LoginProvider.propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
callbackUrl: PropTypes.string.isRequired
}
-162
View File
@@ -1,162 +0,0 @@
import {useState} from 'react'
import {styled} from '@mui/material/styles'
import PropTypes from 'prop-types'
import {MenuItem, Menu, Button} from '@mui/material'
import {uniq} from 'lodash'
import OtomatikSwitch from './otomatik-switch'
const PREFIX = 'AjouteTradiksyon'
const classes = {
paper: `${PREFIX}-paper`,
root: `${PREFIX}-root`
}
const Root = styled('div')((
{
theme
}
) => ({
[`& .${classes.paper}`]: {
border: '1px solid #d3d4d5'
},
[`& .${classes.root}`]: {
'&:focus': {
backgroundColor: theme.palette.primary.main,
'& .MuiListItemIcon-root, & .MuiListItemText-primary': {
color: theme.palette.common.white
}
}
}
}))
function StyledMenu(props) {
return (
<Menu
elevation={0}
anchorOrigin={{
vertical: 'top',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
{...props}
/>
)
}
const StyledMenuItem = MenuItem
function AjouteTradiksyon({showSwitch, disableSwitch, tradiksyonOtomatik, setTradiksyonOtomatik, chwaLang, setChwaLang}) {
const [anchorElement, setAnchorElement] = useState(null)
const handleClick = event => {
setAnchorElement(event.currentTarget)
}
const handleClose = event => {
setAnchorElement(null)
const chwaInik = uniq([...chwaLang, event.currentTarget.id])
setChwaLang(chwaInik.filter(c => c !== ''))
}
return (
<>
{showSwitch && (
<OtomatikSwitch tradiksyonOtomatik={tradiksyonOtomatik} setTradiksyonOtomatik={setTradiksyonOtomatik} disabled={disableSwitch} />
)}
<Root style={{textAlign: 'center', marginTop: 20}}>
<Button
aria-controls='lang-menu'
variant='outlined'
aria-haspopup='true'
onClick={handleClick}
>
Ajouter une traduction <br /> 🇺🇸 🇫🇷 🇪🇸 🇩🇪 🇮🇹 🇧🇷 🇯🇵 🇰🇷
</Button>
<StyledMenu
keepMounted
id='lang-menu'
anchorEl={anchorElement}
open={Boolean(anchorElement)}
classes={{
paper: classes.paper
}}
onClose={handleClose}
>
<StyledMenuItem
id='en'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇺🇸 Anglais (US)</StyledMenuItem>
<StyledMenuItem
id='fr'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇫🇷 Français</StyledMenuItem>
<StyledMenuItem
id='es'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇪🇸 Espagnol</StyledMenuItem>
<StyledMenuItem
id='de'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇩🇪 Allemand</StyledMenuItem>
<StyledMenuItem
id='it'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇮🇹 Italien</StyledMenuItem>
<StyledMenuItem
id='pt'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇧🇷 Portugais</StyledMenuItem>
<StyledMenuItem
id='ja'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇯🇵 Japonais</StyledMenuItem>
<StyledMenuItem
id='ko'
classes={{
root: classes.root
}}
onClick={handleClose}
>🇰🇷 Coréen</StyledMenuItem>
</StyledMenu>
</Root>
</>
)
}
AjouteTradiksyon.propTypes = {
showSwitch: PropTypes.bool.isRequired,
disableSwitch: PropTypes.bool.isRequired,
tradiksyonOtomatik: PropTypes.bool.isRequired,
setTradiksyonOtomatik: PropTypes.func.isRequired,
chwaLang: PropTypes.array.isRequired,
setChwaLang: PropTypes.func.isRequired
}
export default AjouteTradiksyon
-109
View File
@@ -1,109 +0,0 @@
import {useState} from 'react'
import {useSession} from 'next-auth/react'
import PropTypes from 'prop-types'
import Button from '@mui/material/Button'
import Avatar from '@mui/material/Avatar'
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import ListItemAvatar from '@mui/material/ListItemAvatar'
import ListItemText from '@mui/material/ListItemText'
import DialogTitle from '@mui/material/DialogTitle'
import Dialog from '@mui/material/Dialog'
import {green} from '@mui/material/colors'
import Container from '@mui/material/Container'
import CircularProgress from '@mui/material/CircularProgress'
import Typography from '@mui/material/Typography'
import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import {jwennUserEpiToken} from '../../lib/oki-api'
function Chwa(props) {
const {onClose, teksLis, selectedTeks, loading, open} = props
const handleClose = () => {
onClose(selectedTeks)
}
const handleListItemClick = value => {
onClose(value)
}
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Choisir la parole à modifier</DialogTitle>
{loading ? (
<Container sx={{textAlign: 'center', marginBottom: 1}}>
<CircularProgress size={20} sx={{color: '#29d'}} />
</Container>
) : (
<List sx={{pt: 0}}>
{teksLis.length > 0 ? teksLis.map(({id, titre, artistes}) => (
<ListItem key={id} button onClick={() => handleListItemClick(id)}>
<ListItemAvatar>
<Avatar sx={{bgcolor: green[100], color: green[600]}}>
<AudiotrackIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={`${new Intl.ListFormat('fr').format(artistes.map(({alias}) => alias))} - ${titre}`} />
</ListItem>
)) : (
<Typography sx={{textAlign: 'center'}} variant='button' component='div'>Aucun texte</Typography>
)}
</List>
)}
</Dialog>
)
}
Chwa.propTypes = {
onClose: PropTypes.func.isRequired,
teksLis: PropTypes.array.isRequired,
loading: PropTypes.bool.isRequired,
open: PropTypes.bool.isRequired,
selectedTeks: PropTypes.object,
}
export default function ChwaTeks({selectedTeks, setSelectedTeks}) {
const {data: session} = useSession()
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [teksLis, setTeksLis] = useState([])
const handleClickOpen = async () => {
setOpen(true)
setLoading(true)
const user = await jwennUserEpiToken(session?.jwt)
const {paroles} = user
const parolesList = paroles && paroles.length > 0 ? paroles : []
setTeksLis(parolesList)
setLoading(false)
}
const handleClose = value => {
const teks = teksLis.find(({id}) => id === value)
setSelectedTeks(teks)
setOpen(false)
}
return (
<Container sx={{textAlign: 'center', marginBottom: 1}} maxWidth='sm'>
<Button variant='outlined' onClick={handleClickOpen}>
Modifier mes paroles
</Button>
<Chwa
selectedTeks={selectedTeks}
teksLis={teksLis}
open={open}
loading={loading}
onClose={handleClose}
/>
</Container>
)
}
ChwaTeks.propTypes = {
selectedTeks: PropTypes.object,
setSelectedTeks: PropTypes.func.isRequired,
}
-469
View File
@@ -1,469 +0,0 @@
import {useCallback, useEffect, forwardRef, useState} from 'react'
import {styled} from '@mui/material/styles'
import PropTypes from 'prop-types'
import axios from 'axios'
import {useSession} from 'next-auth/react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Container from '@mui/material/Container'
import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import Grid from '@mui/material/Grid'
import IconButton from '@mui/material/IconButton'
import InputAdornment from '@mui/material/InputAdornment'
import InputLabel from '@mui/material/InputLabel'
import LinearProgress from '@mui/material/LinearProgress'
import OutlinedInput from '@mui/material/OutlinedInput'
import Snackbar from '@mui/material/Snackbar'
import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import MuiAlert from '@mui/material/Alert'
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'
import {jwennAnTeks} from '../../lib/oki-api'
import AjouteTradiksyon from './ajoute-tradiksyon'
const PREFIX = 'EkriTeks'
const classes = {
tooltip: `${PREFIX}-tooltip`
}
const StyledContainer = styled(Container)(() => ({
[`& .${classes.tooltip}`]: {
fontSize: 18
}
}))
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
const textLabels = {
fr: {
title: 'Traduction',
help: 'Traduction des paroles',
emoji: '&#127467;&#127479;'
},
en: {
title: 'Translation',
help: 'Translation of the lyrics',
emoji: '&#127468;&#127463;'
},
es: {
title: 'Traducción',
help: 'Traducción de la letra',
emoji: '&#127466;&#127480;'
},
de: {
title: 'Übersetzung',
help: 'Übersetzung von Liedtexten',
emoji: '&#127465;&#127466;'
},
it: {
title: 'Traduzione',
help: 'Traduzione del testo',
emoji: '&#127470;&#127481;'
},
pt: {
title: 'Tradução',
help: 'Tradução da letra',
emoji: '&#127463;&#127479;'
},
ja: {
title: '翻訳',
help: '歌詞の翻訳',
emoji: '&#127471;&#127477;'
},
ko: {
title: '번역',
help: '가사 번역',
emoji: '&#127472;&#127479;'
}
}
const Alert = forwardRef(function Alert(props, ref) {
return <MuiAlert ref={ref} elevation={6} variant='filled' {...props} />
})
const RemoveTooltip = Tooltip
function EkriTeks({canAutoTranslate, selectedTeks, setSelectedTeks}) {
const {data: session} = useSession()
const {jwt, user} = session
const [teksEkri, setTeksEkri] = useState({awtis: '', tit: '', transkripsyon: ''})
const [tradiksyon, setTradiksyon] = useState({fr: '', en: '', es: '', de: '', it: '', pt: '', ja: '', ko: ''})
const [kiChawLang, setKiChwalang] = useState(['fr'])
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const [currentTeksId, setCurrentTeksId] = useState(null)
const [open, setOpen] = useState(false)
const [tradiksyonOtomatik, setTradiksyonOtomatik] = useState(false)
const handleUpdate = useCallback(update => {
setTeksEkri({...teksEkri, ...update})
}, [teksEkri])
const handleUpdateTradiksyon = useCallback(update => {
setTradiksyon({...tradiksyon, ...update})
}, [tradiksyon])
const handleRemoveTradiksyon = useCallback(idTradiksyon => {
setKiChwalang(kiChawLang.filter(t => t !== idTradiksyon))
}, [kiChawLang, setKiChwalang])
const handleClose = (event, reason) => {
if (reason === 'clickaway') {
return
}
setOpen(false)
setSuccess('')
setError('')
}
const handleClick = async () => {
setLoading(true)
const {awtis, tit, transkripsyon} = teksEkri
const {fr, en, es, de, it, pt, ja, ko} = tradiksyon
if (teksEkri.awtis === '' || teksEkri.tit === '' || teksEkri.transkripsyon === '') {
setError({message: 'Certains champs sont obligatoires'})
setLoading(false)
return
}
const headers = {
'content-type': 'application/json',
Authorization: `Bearer ${jwt}`
}
if (currentTeksId) {
try {
const teks = await jwennAnTeks(currentTeksId, jwt)
const teksResponse = await axios.put(`${API_URL}/paroles/${currentTeksId}`, {
data: {
id: teks.id,
titre: tit,
transcription: transkripsyon,
traductions: {
francais: fr === '' ? null : fr,
anglais: en === '' ? null : en,
espagnol: es === '' ? null : es,
allemand: de === '' ? null : de,
italien: it === '' ? null : it,
portugais: pt === '' ? null : pt,
japonais: ja === '' ? null : ja,
coreen: ko === '' ? null : ko
},
user: {
id: user.id,
username: user.username,
email: user.email
},
artistes: teks.artistes.map(({id}) => id),
traductionAuto: tradiksyonOtomatik
}
}, {
headers
})
const {data} = teksResponse
setSuccess(`Le morceau "${data.tit}" a été modifié avec succès. Il apparaîtra sur le site après validation.`)
setLoading(false)
} catch (error) {
setError(error?.response?.data)
setLoading(false)
}
} else {
try {
const artiste = await axios.post(`${API_URL}/artistes`, {
data: {
alias: awtis,
user: {
id: user.id,
username: user.username,
email: user.email
}
}
}, {
headers
})
const parole = await axios.post(`${API_URL}/paroles`, {
data: {
titre: tit,
transcription: transkripsyon,
traductions: {
francais: fr === '' ? null : fr,
anglais: en === '' ? null : en,
espagnol: es === '' ? null : es,
allemand: de === '' ? null : de,
italien: it === '' ? null : it,
portugais: pt === '' ? null : pt,
japonais: ja === '' ? null : ja,
coreen: ko === '' ? null : ko
},
user: {
id: user.id,
username: user.username,
email: user.email
},
artistes: [artiste.id],
traductionAuto: tradiksyonOtomatik
}
}, {
headers
})
const {data} = parole
setSuccess(`Le texte "${data.titre}" a été soumis avec succès. Il apparaîtra sur le site après validation.`)
setLoading(false)
} catch (error) {
setError(error?.response?.data)
setLoading(false)
}
}
}
const handleReset = () => {
setTeksEkri({awtis: '', tit: '', transkripsyon: ''})
setTradiksyon({fr: '', en: '', es: '', de: '', it: '', pt: '', ja: '', ko: ''})
setKiChwalang(['fr'])
setCurrentTeksId(null)
setTradiksyonOtomatik(false)
}
useEffect(() => {
if (success) {
setOpen(true)
handleReset()
}
}, [success])
useEffect(() => {
if (error) {
setOpen(true)
}
}, [error])
useEffect(() => {
if (selectedTeks) {
setTradiksyonOtomatik(false)
setCurrentTeksId(selectedTeks.id)
setTeksEkri({
awtis: new Intl.ListFormat('fr').format(selectedTeks.artistes.map(({alias}) => alias)),
tit: selectedTeks.titre,
transkripsyon: selectedTeks.transcription
})
setTradiksyon({
fr: selectedTeks?.traductions?.francais || '',
en: selectedTeks?.traductions?.anglais || '',
es: selectedTeks?.traductions?.espagnol || '',
de: selectedTeks?.traductions?.allemand || '',
it: selectedTeks?.traductions?.italien || '',
pt: selectedTeks?.traductions?.portugais || '',
ja: selectedTeks?.traductions?.japonais || '',
ko: selectedTeks?.traductions?.coreen || ''
})
const jennLangId = () => {
const ids = []
if (selectedTeks?.traductions?.francais) {
ids.push('fr')
}
if (selectedTeks?.traductions?.anglais) {
ids.push('en')
}
if (selectedTeks?.traductions?.espagnol) {
ids.push('es')
}
if (selectedTeks?.traductions?.allemand) {
ids.push('de')
}
if (selectedTeks?.traductions?.italien) {
ids.push('it')
}
if (selectedTeks?.traductions?.portugais) {
ids.push('pt')
}
if (selectedTeks?.traductions?.japonais) {
ids.push('ja')
}
if (selectedTeks?.traductions?.coreen) {
ids.push('ko')
}
return ids
}
const langIds = jennLangId()
setKiChwalang(langIds)
}
return () => {
setSelectedTeks(null)
}
}, [teksEkri, selectedTeks, setSelectedTeks, kiChawLang])
return (
<StyledContainer maxWidth='sm'>
<Box sx={{textAlign: 'center', marginBottom: 2}}>
<Typography display='inline' variant='h6' component='h1'>
Proposer une parole
</Typography>
</Box>
<form noValidate autoComplete='off'>
<Grid container style={{textAlign: 'center'}} spacing={1}>
<Grid xs='true'>
<TextField
required
id='awtis'
name='awtis'
label='Artiste'
value={teksEkri.awtis}
disabled={Boolean(currentTeksId)}
variant='outlined'
helperText='Nom de lartiste (obligatoire)'
onChange={event => handleUpdate({awtis: event.target.value})}
/>
</Grid>
<Grid xs='true'>
<TextField
required
id='tit'
name='tit'
label='Titre'
value={selectedTeks?.tit || teksEkri.tit}
variant='outlined'
helperText='Titre de la musique (obligatoire)'
onChange={event => handleUpdate({tit: event.target.value})}
/>
</Grid>
</Grid>
<TextField
fullWidth
multiline
required
helperText='Transcription des paroles (obligatoire)'
style={{marginTop: '1em'}}
id='transkripsyon'
label='Transcription'
value={teksEkri.transkripsyon}
rows={8}
variant='outlined'
onChange={event => handleUpdate({transkripsyon: event.target.value})}
/>
{kiChawLang.length > 0 && kiChawLang.map(k => (
<FormControl key={k} fullWidth style={{marginBlock: 10}}>
<InputLabel htmlFor={`tradiksyon-${k}`}>
<span dangerouslySetInnerHTML={{__html: textLabels[k].emoji}} /> {textLabels[k].title}
</InputLabel>
<OutlinedInput
multiline
style={{marginTop: '1em'}}
id={`tradiksyon-${k}`}
name={`tradiksyon-${k}`}
label={`${textLabels[k].title}`}
value={tradiksyon[k]}
rows={8}
endAdornment={
<InputAdornment position='end'>
<RemoveTooltip
title='Cacher la fenêtre'
placement='left'
classes={{
tooltip: classes.tooltip
}}
>
<IconButton
style={{bottom: 75}}
aria-label='remove-tradiksyon'
edge='end'
size='large'
onClick={() => handleRemoveTradiksyon(k)}
>
<VisibilityOffIcon />
</IconButton>
</RemoveTooltip>
</InputAdornment>
}
onChange={event => handleUpdateTradiksyon({[k]: event.target.value})}
/>
<FormHelperText id={`tradiksyon-${k}`}>{textLabels[k].help}</FormHelperText>
</FormControl>
))}
</form>
<AjouteTradiksyon
showSwitch={Boolean(canAutoTranslate) && Boolean(!currentTeksId)}
disableSwitch={tradiksyon.fr === '' || tradiksyon.en !== '' || tradiksyon.es !== '' || tradiksyon.de !== '' || tradiksyon.it !== '' || tradiksyon.pt !== '' || tradiksyon.ja !== '' || tradiksyon.ko !== ''}
tradiksyonOtomatik={tradiksyonOtomatik}
setTradiksyonOtomatik={setTradiksyonOtomatik}
chwaLang={kiChawLang}
setChwaLang={setKiChwalang}
/>
<div>
<Button
fullWidth
disabled={loading || teksEkri.awtis === '' || teksEkri.tit === '' || teksEkri.transkripsyon === ''}
style={{marginTop: 20}}
variant='contained'
color='primary'
onClick={handleClick}
>
<Typography style={{fontWeight: 'bold'}}>
Valider
</Typography>
</Button>
<Button
fullWidth
disabled={loading || (teksEkri.awtis === '' && teksEkri.tit === '' && teksEkri.transkripsyon === '')}
style={{marginTop: 20}}
variant='contained'
color='error'
onClick={handleReset}
>
<Typography style={{fontWeight: 'bold'}}>
Tout effacer
</Typography>
</Button>
{loading && <LinearProgress size={24} style={{width: '100%', marginBlock: '1em'}} />}
</div>
{success && (
<Snackbar open={open} autoHideDuration={10_000} onClose={handleClose}>
<Alert severity='success' onClose={handleClose}>
<strong>{success}</strong>
</Alert>
</Snackbar>
)}
{error && (
<Snackbar open={open} autoHideDuration={10_000} onClose={handleClose}>
<Alert severity='error' onClose={handleClose}>
<strong>Une erreur sest produite</strong> : <i>{error?.error?.message}</i>
</Alert>
</Snackbar>
)}
</StyledContainer>
)
}
EkriTeks.propTypes = {
canAutoTranslate: PropTypes.bool,
selectedTeks: PropTypes.object,
setSelectedTeks: PropTypes.func.isRequired,
}
export default EkriTeks
-26
View File
@@ -1,26 +0,0 @@
import PropTypes from 'prop-types'
import FormGroup from '@mui/material/FormGroup'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
export default function OtomatikSwitch({tradiksyonOtomatik, setTradiksyonOtomatik, disabled}) {
const handleChange = event => {
setTradiksyonOtomatik(event.target.checked)
}
return (
<FormGroup sx={{marginTop: 1}}>
<FormControlLabel
control={<Switch checked={tradiksyonOtomatik && !disabled} onChange={handleChange} />}
disabled={disabled}
label='Traduction du français vers les autres langues (remplir uniquement la traduction française)'
/>
</FormGroup>
)
}
OtomatikSwitch.propTypes = {
tradiksyonOtomatik: PropTypes.bool.isRequired,
setTradiksyonOtomatik: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired
}
-2
View File
@@ -4,8 +4,6 @@ import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt'
import CGUDialog from '../cgu/cgu-dialog'
const CGU_PDF_LINK = process.env.NEXT_PUBLIC_CGU_DOWNLOAD_LINK
export default function Presantasyon() {
const [open, setOpen] = useState(false)
-42
View File
@@ -1,42 +0,0 @@
import PropTypes from 'prop-types'
import {styled} from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import LinearProgress, {linearProgressClasses} from '@mui/material/LinearProgress'
const BorderLinearProgress = styled(LinearProgress)(({theme}) => ({
height: 10,
borderRadius: 5,
[`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: 200,
...theme.applyStyles('dark', {
backgroundColor: 800
})
},
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: '#1a90ff',
...theme.applyStyles('dark', {
backgroundColor: '#308fe8'
})
},
}))
export default function BarStats({percent}) {
return (
<Box sx={{display: 'flex', alignItems: 'center'}}>
<Box sx={{width: '100%'}}>
<BorderLinearProgress variant='determinate' value={percent} />
</Box>
<Box sx={{minWidth: 35}}>
<Typography textAlign='right' sx={{fontWeight: 'bold'}} variant='body2' color='text.secondary'>{`${Math.round(
percent,
)}%`}</Typography>
</Box>
</Box>
)
}
BarStats.propTypes = {
percent: PropTypes.number.isRequired,
}
-28
View File
@@ -1,28 +0,0 @@
import PropTypes from 'prop-types'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import Typography from '@mui/material/Typography'
import Grid from '@mui/material/Grid'
import TraductionsStats from './traductions-stats'
export default function KatStats({emoji, value, total}) {
return (
<Grid size={{xs: 12, md: 2}}>
<Card variant='outlined'>
<CardContent>
<Typography align='center' variant='h6'>
<span dangerouslySetInnerHTML={{__html: emoji}} />
</Typography>
<TraductionsStats value={value} total={total} />
</CardContent>
</Card>
</Grid>
)
}
KatStats.propTypes = {
emoji: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
total: PropTypes.number.isRequired
}
-29
View File
@@ -1,29 +0,0 @@
import PropTypes from 'prop-types'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid'
import BarStats from './bar-stats'
export default function TraductionsStats({value, total}) {
const translated = total - value
const percent = (translated / total) * 100
return (
<Box sx={{width: '100%'}}>
<Grid container rowSpacing={3} columnSpacing={6} justifyContent='space-around'>
<Grid size={{xs: 12, md: 6}}>
<Typography style={{fontWeight: 'bold'}} align='center' variant='h6'>
{translated}
</Typography>
</Grid>
</Grid>
<BarStats value={value} percent={percent} />
</Box>
)
}
TraductionsStats.propTypes = {
value: PropTypes.number.isRequired,
total: PropTypes.number.isRequired
}
+8
View File
@@ -1,5 +1,6 @@
'use client'
import PropTypes from 'prop-types'
import Button from '@mui/material/Button'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import {Spotify, Applemusic, Deezer, Tidal, Youtubemusic, Amazon, Soundcloud} from '@icons-pack/react-simple-icons'
@@ -39,3 +40,10 @@ export function StreamButton({lyen}) {
</Button>
)
}
StreamButton.propTypes = {
lyen: PropTypes.shape({
plateforme: PropTypes.string.isRequired,
url: PropTypes.string.isRequired
}).isRequired
}
@@ -0,0 +1,51 @@
import {describe, it, expect} from 'vitest'
import {render, screen} from '@testing-library/react'
import Komante from '../komante'
describe('Komante', () => {
it('ne rend rien quand il n\'y a aucun commentaire', () => {
const {container} = render(<Komante commentaires={[]} />)
expect(container).toBeEmptyDOMElement()
})
it('affiche un commentaire local avec le pseudo pawol.nu', () => {
render(<Komante commentaires={[
{id: 1, contenu: 'Bèl tèks', origine: 'local', user: {username: 'foo'}}
]} />)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('Bèl tèks')).toBeInTheDocument()
expect(screen.queryByText('Mastodon')).not.toBeInTheDocument()
})
it('affiche un commentaire fédéré avec le badge et le lien Mastodon', () => {
render(<Komante commentaires={[{
id: 2,
contenu: 'Trè bèl',
origine: 'activitypub',
auteurNom: 'Quelqu\'un',
auteurHandle: '@quelqun@mastodon.social',
auteurProfilUrl: 'https://mastodon.social/@quelqun',
remoteUrl: 'https://bokante.o-k-i.net/@quelqun/1'
}]} />)
expect(screen.getByText('Quelqu\'un')).toBeInTheDocument()
expect(screen.getByText('Mastodon')).toBeInTheDocument()
expect(screen.getByRole('link', {name: 'Quelqu\'un'})).toHaveAttribute('href', 'https://mastodon.social/@quelqun')
expect(screen.getByRole('link', {name: /voir sur mastodon/i})).toHaveAttribute('href', 'https://bokante.o-k-i.net/@quelqun/1')
})
it('met en valeur les mentions @ dans le contenu', () => {
render(<Komante commentaires={[{
id: 3,
contenu: '@pawol_nu oh oh ye ye, avec @cybermawonaj@bokante.o-k-i.net aussi',
origine: 'activitypub',
auteurNom: 'Quelqu\'un'
}]} />)
const mansyon = screen.getByText('@pawol_nu')
expect(mansyon).toBeInTheDocument()
expect(screen.getByText('@cybermawonaj@bokante.o-k-i.net')).toBeInTheDocument()
expect(screen.getByText(/oh oh ye ye/)).toBeInTheDocument()
})
})
@@ -0,0 +1,74 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ReponsMastodon from '../repons-mastodon'
describe('ReponsMastodon', () => {
const originalOpen = window.open
afterEach(() => {
window.open = originalOpen
})
it('ne rend rien sans bokanteStatusId', () => {
const {container} = render(<ReponsMastodon bokanteStatusId={null} />)
expect(container).toBeEmptyDOMElement()
})
it('affiche un lien direct vers le fil BOKANTE', () => {
render(<ReponsMastodon bokanteStatusId='112233' />)
const lien = screen.getByRole('link', {name: 'BOKANTE (Mastodon)'})
expect(lien).toHaveAttribute('href', 'https://bokante.o-k-i.net/@pawol_nu/112233')
})
it('redirige vers BOKANTE par défaut quand on continue', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith(
'https://bokante.o-k-i.net/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank',
'noopener,noreferrer'
)
})
it('permet de choisir une autre instance dans le select', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.click(screen.getByLabelText(/instance mastodon/i))
await user.click(screen.getByRole('option', {name: /autre instance/i}))
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social')
await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith(
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank',
'noopener,noreferrer'
)
})
it('accepte un identifiant complet pour une autre instance', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.click(screen.getByLabelText(/instance mastodon/i))
await user.click(screen.getByRole('option', {name: /autre instance/i}))
await user.type(screen.getByLabelText(/@vous@votre-instance/i), '@quelqun@mastodon.social')
await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith(
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank',
'noopener,noreferrer'
)
})
})
-1
View File
@@ -8,7 +8,6 @@ export default function AnTeks({parole, paroleId}) {
<Teks
parole={parole}
paroleId={paroleId}
commentaires={parole?.commentaires}
/>
)
}
+24
View File
@@ -0,0 +1,24 @@
'use client'
import {createContext, useContext, useState} from 'react'
import PropTypes from 'prop-types'
const CurrentTrackContext = createContext([null, () => {}])
export function CurrentTrackProvider({children}) {
const state = useState(null)
return (
<CurrentTrackContext.Provider value={state}>
{children}
</CurrentTrackContext.Provider>
)
}
export function useCurrentTrack() {
return useContext(CurrentTrackContext)
}
CurrentTrackProvider.propTypes = {
children: PropTypes.node.isRequired
}
+1 -4
View File
@@ -1,9 +1,6 @@
import React, {forwardRef, useState} from 'react'
import PropTypes from 'prop-types'
import {format} from 'date-fns'
import {fr} from 'date-fns/locale'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
@@ -50,7 +47,7 @@ export default function DiferansDialog({difference}) {
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
{difference.map(({id, admin_user, date, jsonDiff}) => {
const {firstname} = admin_user
const diferansDate = format(new Date(date), 'PPPppp', {locale: fr})
const diferansDate = new Intl.DateTimeFormat(undefined, {dateStyle: 'full', timeStyle: 'medium'}).format(new Date(date))
return (
<React.Fragment key={id}>
+14 -6
View File
@@ -2,7 +2,7 @@
import {Box, IconButton} from '@mui/material'
import PropTypes from 'prop-types'
import {useRouter} from 'next/navigation'
import {usePathname} from 'next/navigation'
import {
Tidal,
@@ -69,10 +69,12 @@ function RannIframe({plateforme, url, titre, isMobile}) {
{plateforme === 'Tidal' && (
<iframe
src={src}
title={titre}
allowFullScreen='allowfullscreen'
frameBorder='0'
width={`${isMobile ? '100%' : '50%'}`}
height='96px'
sandbox='allow-same-origin allow-scripts allow-popups'
/>
)}
@@ -90,38 +92,44 @@ function RannIframe({plateforme, url, titre, isMobile}) {
{plateforme === 'Deezer' && (
<iframe
title='deezer-widget'
title={titre || 'deezer-widget'}
frameBorder='0'
src={src}
allow='encrypted-media; clipboard-write'
allowtransparency='true'
sandbox='allow-same-origin allow-scripts allow-popups'
/>
)}
{plateforme === 'Spotify' && (
<iframe
src={src}
title={titre}
width={`${isMobile ? '100%' : '50%'}`}
height='80'
frameBorder='0'
allowtransparency='true'
allow='encrypted-media'
sandbox='allow-same-origin allow-scripts allow-popups'
/>
)}
{plateforme === 'Soundcloud' && (
<iframe
src={src}
title={titre}
width={`${isMobile ? '100%' : '50%'}`}
height='166'
scrolling='no'
frameBorder='no'
sandbox='allow-same-origin allow-scripts allow-popups'
/>
)}
{plateforme === 'Applemusic' && (
<iframe
src={src}
title={titre}
width={`${isMobile ? '100%' : '50%'}`}
height='175'
frameBorder='0'
@@ -136,7 +144,7 @@ function RannIframe({plateforme, url, titre, isMobile}) {
function EntegreMizik({parole, isMobile}) {
const [chwaMizik, meteChwaMizik] = useState(null)
const router = useRouter()
const pathname = usePathname()
const {streamAudio, slug, okiMizikID, gadeEmbed, titre} = parole
const filteredKouteyAchtey = streamAudio.filter(({plateforme}) => plateforme === 'Tidal' || plateforme === 'Spotify' || plateforme === 'Deezer' || plateforme === 'Soundcloud' || plateforme === 'Applemusic')
@@ -150,10 +158,10 @@ function EntegreMizik({parole, isMobile}) {
}
useEffect(() => {
if (router.asPath === `/paroles/${slug}` || router.asPath === `/paroles/${slug}#${slug}`) {
if (pathname === `/paroles/${slug}`) {
meteChwaMizik(null)
}
}, [router, slug])
}, [pathname, slug])
return (
<Box align='center'>
@@ -162,7 +170,7 @@ function EntegreMizik({parole, isMobile}) {
key={id}
aria-label='player'
size='large'
onClick={() => handleClick(plateforme, url)}
onClick={() => handleClick(plateforme, url, titre)}
>
{kouteyAchteyIcons[plateforme]}
</IconButton>
+20 -14
View File
@@ -21,7 +21,7 @@ const pulse = keyframes`
`
const SlideUp = forwardRef(function SlideUp(props, ref) {
return <Slide direction='up' ref={ref} {...props} />
return <Slide ref={ref} direction='up' {...props} />
})
function toEmbedUrl(url) {
@@ -31,6 +31,7 @@ function toEmbedUrl(url) {
if (match) {
return `${u.origin}/videos/embed/${match[1]}?title=0&warningTitle=0&peertubeLink=0&controlBar=1`
}
return url
} catch {
return url
@@ -45,7 +46,9 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
const activeUrl = (!isMobile && desktopUrl) ? desktopUrl : url
useEffect(() => {
if (!open) setLoaded(false)
if (!open) {
setLoaded(false)
}
}, [open])
return (
@@ -55,13 +58,13 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
color='primary'
size={isMobile ? 'medium' : 'large'}
aria-label='karaoké'
onClick={() => setOpen(true)}
sx={{
position: 'fixed',
bottom: {xs: 24, sm: 32},
right: {xs: 24, sm: 32},
zIndex: 1050,
}}
onClick={() => setOpen(true)}
>
<MicIcon />
</Fab>
@@ -69,7 +72,6 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
<Dialog
open={open}
onClose={() => setOpen(false)}
slots={{transition: SlideUp}}
maxWidth={false}
sx={{
@@ -102,6 +104,7 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
}
}
}}
onClose={() => setOpen(false)}
>
{/* Header superposé en gradient */}
<Box sx={{
@@ -118,7 +121,8 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
alignItems: 'flex-start',
justifyContent: 'space-between',
pointerEvents: 'none',
}}>
}}
>
<Box sx={{pointerEvents: 'none'}}>
{titre && (
<Typography variant='subtitle1' sx={{color: '#fff', fontWeight: 700, lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.5)'}}>
@@ -133,7 +137,6 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
</Box>
<IconButton
size='small'
onClick={() => setOpen(false)}
aria-label='fermer'
sx={{
pointerEvents: 'auto',
@@ -143,6 +146,7 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
border: '1px solid rgba(255,255,255,0.12)',
'&:hover': {bgcolor: 'rgba(255,255,255,0.25)'},
}}
onClick={() => setOpen(false)}
>
<CloseIcon fontSize='small' />
</IconButton>
@@ -166,7 +170,8 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
}}>
}}
>
<MicIcon sx={{fontSize: 44, color: 'rgba(255,255,255,0.55)', animation: `${pulse} 1.6s ease-in-out infinite`}} />
<Typography
variant='caption'
@@ -187,27 +192,28 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
transform: 'translateX(-50%)',
height: '100%',
aspectRatio: '16 / 9',
}}>
}}
>
<iframe
src={toEmbedUrl(activeUrl)}
onLoad={() => setLoaded(true)}
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
allowFullScreen
src={toEmbedUrl(activeUrl)}
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
allow='autoplay; fullscreen'
sandbox='allow-same-origin allow-scripts allow-popups allow-forms'
title='Karaoké'
onLoad={() => setLoaded(true)}
/>
</Box>
) : (
/* Vidéo 16:9 desktop : player remplit exactement le dialog paysage */
<iframe
src={toEmbedUrl(activeUrl)}
onLoad={() => setLoaded(true)}
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
allowFullScreen
src={toEmbedUrl(activeUrl)}
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
allow='autoplay; fullscreen'
sandbox='allow-same-origin allow-scripts allow-popups allow-forms'
title='Karaoké'
onLoad={() => setLoaded(true)}
/>
)}
</Box>
+112
View File
@@ -0,0 +1,112 @@
'use client'
import PropTypes from 'prop-types'
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import Avatar from '@mui/material/Avatar'
import Typography from '@mui/material/Typography'
import Chip from '@mui/material/Chip'
import Link from 'next/link'
import {Mastodon} from '@icons-pack/react-simple-icons'
function formatAuteur(commentaire) {
if (commentaire.origine === 'activitypub') {
return commentaire.auteurNom || commentaire.auteurHandle || 'Yon moun'
}
return commentaire.user?.username || 'Anonim'
}
function formatDat(commentaire) {
if (!commentaire.datePublication) {
return null
}
return new Intl.DateTimeFormat(undefined, {dateStyle: 'short', timeStyle: 'short'})
.format(new Date(commentaire.datePublication))
}
const MENTION_REGEX = /(?:@[\w.-]+){1,2}/g
function metanValèMansyon(contenu) {
const mansyon = contenu.match(MENTION_REGEX) || []
return contenu.split(MENTION_REGEX).flatMap((moso, index) => (
mansyon[index] ? [
moso,
<Box key={index} component='span' sx={{color: 'primary.main', fontWeight: 700}}>
{mansyon[index]}
</Box>
] : [moso]
))
}
export default function Komante({commentaires}) {
if (!commentaires || commentaires.length === 0) {
return null
}
return (
<Box sx={{maxWidth: 700, margin: '2em auto 0'}}>
<Typography gutterBottom variant='h6'>
Komantè ({commentaires.length})
</Typography>
<Stack spacing={1.5}>
{commentaires.map(commentaire => {
const auteur = formatAuteur(commentaire)
const estFedere = commentaire.origine === 'activitypub'
const dat = formatDat(commentaire)
return (
<Box
key={commentaire.id}
sx={{
display: 'flex',
gap: 1.5,
p: 2,
borderRadius: 2,
bgcolor: 'action.hover'
}}
>
<Avatar src={commentaire.auteurAvatarUrl} alt={auteur} />
<Box sx={{flex: 1, minWidth: 0}}>
<Stack direction='row' alignItems='center' flexWrap='wrap' rowGap={0.5}>
<Stack direction='row' alignItems='center' spacing={1}>
<Typography variant='subtitle2' component='div'>
{estFedere && commentaire.auteurProfilUrl ? (
<Link href={commentaire.auteurProfilUrl} target='_blank' rel='noopener noreferrer'>
{auteur}
</Link>
) : auteur}
</Typography>
{estFedere && (
<Chip
label='Mastodon'
size='small'
icon={<Mastodon size={14} color='#6364FF' title='' />}
/>
)}
</Stack>
{dat && (
<Typography variant='caption' color='text.secondary' sx={{ml: 'auto', pl: 2}}>
{dat}
</Typography>
)}
</Stack>
<Typography variant='body2' sx={{mt: 0.5}}>{metanValèMansyon(commentaire.contenu)}</Typography>
{estFedere && commentaire.remoteUrl && (
<Link href={commentaire.remoteUrl} target='_blank' rel='noopener noreferrer'>
<Typography variant='caption' color='text.secondary'>Voir sur Mastodon</Typography>
</Link>
)}
</Box>
</Box>
)
})}
</Stack>
</Box>
)
}
Komante.propTypes = {
commentaires: PropTypes.array
}
+33 -30
View File
@@ -16,16 +16,24 @@ import Image from 'next/image'
import {grey} from '@mui/material/colors'
import {Link} from '@mui/material'
import {getAlias} from '../../lib/utils/format'
import {getAlias} from '../../lib/utils/get-alias'
import {formatKuveti} from '../../lib/kuveti'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
function createAudio(src) {
const audio = new Audio(src)
audio.preload = 'metadata'
return audio
}
function parseLrc(text) {
const lines = []
for (const raw of text.split('\n')) {
const match = raw.match(/^\[(\d{2}):(\d{2})[.:]\d+\](.*)$/)
const match = raw.match(/^\[(\d{2}):(\d{2})[.:]\d+](.*)$/)
if (match) {
lines.push({time: parseInt(match[1]) * 60 + parseInt(match[2]), text: match[3].trim()})
const time = (Number.parseInt(match[1], 10) * 60) + Number.parseInt(match[2], 10)
lines.push({time, text: match[3].trim()})
}
}
@@ -68,9 +76,12 @@ const TinyText = styled(Typography)({
})
export default function Lekte({audio, url, parole}) {
const audioRef = useRef(new Audio(audio))
const audioRef = useRef(null)
if (audioRef.current === null) {
audioRef.current = createAudio(audio)
}
const intervalRef = useRef()
const isReady = useRef(false)
const [duration, setDuration] = useState(0)
const theme = useTheme()
const {mode} = useColorScheme()
@@ -97,25 +108,9 @@ export default function Lekte({audio, url, parole}) {
clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => {
setPosition(Math.round(audioRef.current.currentTime))
}, [1000])
}, 1000)
}
useEffect(() => {
if (isReady.current) {
audioRef.current.play()
setIsPlaying(true)
startTimer()
}
}, [])
useEffect(() => {
if (isPlaying) {
audioRef.current.play()
} else {
audioRef.current.pause()
}
}, [isPlaying])
useEffect(() =>
() => {
audioRef.current.pause()
@@ -125,7 +120,7 @@ export default function Lekte({audio, url, parole}) {
useEffect(() => {
if (isPlaying) {
audioRef.current.play()
audioRef.current.play().catch(() => {})
startTimer()
} else {
clearInterval(intervalRef.current)
@@ -135,7 +130,7 @@ export default function Lekte({audio, url, parole}) {
useEffect(() => {
audioRef.current.pause()
audioRef.current = new Audio(audio)
audioRef.current = createAudio(audio)
setIsPlaying(false)
setVolume(100)
setPosition(0)
@@ -151,6 +146,7 @@ export default function Lekte({audio, url, parole}) {
setPosition(0)
el.currentTime = 0
}
el.addEventListener('loadedmetadata', onLoaded)
el.addEventListener('ended', onEnded)
return () => {
@@ -160,7 +156,10 @@ export default function Lekte({audio, url, parole}) {
}, [audio])
useEffect(() => {
if (!parole?.pawol?.url) return
if (!parole?.pawol?.url) {
return
}
fetch(new URL(parole.pawol.url, IMAGE_URL).toString())
.then(r => r.text())
.then(text => setLrcLines(parseLrc(text)))
@@ -168,7 +167,7 @@ export default function Lekte({audio, url, parole}) {
}, [parole?.pawol?.url])
const activeIndex = lrcLines
? lrcLines.reduce((last, line, i) => line.time <= position ? i : last, -1)
? lrcLines.findLastIndex(line => line.time <= position)
: -1
const handleChangePosition = value => {
@@ -181,9 +180,10 @@ export default function Lekte({audio, url, parole}) {
audioRef.current.volume = value / 100
}
const imagePath = parole?.couverture?.formats?.thumbnail?.url
const width = parole?.couverture?.formats?.thumbnail?.width || 192
const height = parole?.couverture?.formats?.thumbnail?.height || 192
const couvertureFormat = formatKuveti(parole?.couverture, 'thumbnail')
const imagePath = couvertureFormat?.url
const width = couvertureFormat?.width || 192
const height = couvertureFormat?.height || 192
const imageSrc = imagePath
? new URL(imagePath, IMAGE_URL).toString()
@@ -322,7 +322,10 @@ export default function Lekte({audio, url, parole}) {
{[-1, 0, 1].map(offset => {
const idx = activeIndex + offset
const line = lrcLines[idx]
if (!line) return <Box key={offset} sx={{height: offset === 0 ? 28 : 20}} />
if (!line) {
return <Box key={offset} sx={{height: offset === 0 ? 28 : 20}} />
}
return (
<Typography
key={offset}
+1 -3
View File
@@ -3,8 +3,6 @@ import {Box, CircularProgress} from '@mui/material'
import dynamic from 'next/dynamic'
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
const MIZIK_API_USER = process.env.NEXT_PUBLIC_MIZIK_API_USER || 'user'
const MIZIK_API_PASSWORD = process.env.NEXT_PUBLIC_MIZIK_API_PASSWORD || 'password'
const DinamikLekte = dynamic(
() => import('./lekte'), // eslint-disable-line node/no-unsupported-features/es-syntax
@@ -12,7 +10,7 @@ const DinamikLekte = dynamic(
)
export default function OkiMizik({id, parole}) {
const mizikStreamUrl = `${MIZIK_URL}/rest/stream?u=${MIZIK_API_USER}&p=${MIZIK_API_PASSWORD}&id=${id}`
const mizikStreamUrl = `/api/mizik-stream/${id}`
const detailsUrl = `${MIZIK_URL}/library/tracks/${id}`
return (
+146
View File
@@ -0,0 +1,146 @@
'use client'
import {useState} from 'react'
import PropTypes from 'prop-types'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import Select from '@mui/material/Select'
import MenuItem from '@mui/material/MenuItem'
import Link from '@mui/material/Link'
import {Mastodon} from '@icons-pack/react-simple-icons'
const BOKANTE_URL = process.env.NEXT_PUBLIC_BOKANTE_URL || 'https://bokante.o-k-i.net'
const BOKANTE_ACCOUNT = process.env.NEXT_PUBLIC_BOKANTE_ACCOUNT || 'pawol_nu'
const INSTANCES_SUGGEREES = [
{domain: 'bokante.o-k-i.net', label: 'BOKANTE - notre instance'},
{domain: 'mastodon.social', label: 'mastodon.social'},
{domain: 'piaille.fr', label: 'piaille.fr'},
]
const AUTRE_INSTANCE = 'autre'
function extraireInstance(saisie) {
const valeur = saisie.trim().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/$/, '')
return valeur.includes('@') ? valeur.split('@').pop() : valeur
}
export default function ReponsMastodon({bokanteStatusId}) {
const [ouvert, setOuvert] = useState(false)
const [instance, setInstance] = useState(INSTANCES_SUGGEREES[0].domain)
const [autreInstance, setAutreInstance] = useState('')
if (!bokanteStatusId) {
return null
}
const tootUrl = `${BOKANTE_URL}/@${BOKANTE_ACCOUNT}/${bokanteStatusId}`
const repondre = () => {
const domaine = instance === AUTRE_INSTANCE ? extraireInstance(autreInstance) : instance
if (!domaine) {
return
}
window.open(`https://${domaine}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer')
setOuvert(false)
setAutreInstance('')
}
return (
<Box sx={{maxWidth: 700, margin: '1em auto 0', textAlign: 'center'}}>
<Typography variant='body2' sx={{mb: 1}}>
Rejoignez la discussion sur le Fediverse via{' '}
<Link
href={tootUrl}
target='_blank'
rel='noopener noreferrer'
underline='hover'
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.25,
borderRadius: 1,
bgcolor: 'action.hover',
color: 'primary.main',
fontWeight: 500,
textDecoration: 'none',
'&:hover': {
bgcolor: 'action.selected'
}
}}
>
BOKANTE (Mastodon)
</Link>
</Typography>
<Button
variant='outlined'
size='small'
startIcon={<Mastodon size={16} color='#6364FF' title='' />}
onClick={() => setOuvert(true)}
>
Répondre sur Mastodon
</Button>
<Dialog open={ouvert} onClose={() => setOuvert(false)}>
<DialogTitle>Répondre depuis Mastodon</DialogTitle>
<DialogContent>
<Typography variant='body2' sx={{mb: 2}}>
Choisissez linstance Mastodon sur laquelle vous avez un compte.
BOKANTE est linstance de OKI : vous pouvez aussi utiliser
nimporte quelle autre instance du Fediverse.
</Typography>
<FormControl fullWidth sx={{mb: 2}}>
<InputLabel id='instance-mastodon-label'>Instance Mastodon</InputLabel>
<Select
labelId='instance-mastodon-label'
id='instance-mastodon'
value={instance}
label='Instance Mastodon'
onChange={event => setInstance(event.target.value)}
>
{INSTANCES_SUGGEREES.map(({domain, label}) => (
<MenuItem key={domain} value={domain}>
{label}
</MenuItem>
))}
<MenuItem value={AUTRE_INSTANCE}>Autre instance</MenuItem>
</Select>
</FormControl>
{instance === AUTRE_INSTANCE && (
<TextField
autoFocus
fullWidth
label='@vous@votre-instance'
helperText='Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre instance, vous serez redirigé·e vers votre compte pour répondre.'
value={autreInstance}
onChange={event => setAutreInstance(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
repondre()
}
}}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOuvert(false)}>Annuler</Button>
<Button variant='contained' onClick={repondre}>Continuer</Button>
</DialogActions>
</Dialog>
</Box>
)
}
ReponsMastodon.propTypes = {
bokanteStatusId: PropTypes.string
}
+13
View File
@@ -0,0 +1,13 @@
import {notFound} from 'next/navigation'
import {jwennTeks} from '../../lib/oki-api'
import TeksDrawer from './teks-drawer'
export default async function TeksDrawerLoader() {
const teks = await jwennTeks()
if (!teks) {
notFound()
}
return <TeksDrawer paroles={teks} />
}
+3 -5
View File
@@ -2,7 +2,6 @@
import {useState, useEffect, forwardRef} from 'react'
import Link from 'next/link'
import {useParams} from 'next/navigation'
import PropTypes from 'prop-types'
import AppBar from '@mui/material/AppBar'
import Box from '@mui/material/Box'
@@ -19,6 +18,7 @@ import VweKouteAchte from './vwe-koute-achte'
import DrawerBar from './drawer-bar'
import Pataje from './pataje'
import KaraokeModal from './karaoke-modal'
import {useCurrentTrack} from './current-track-context'
const drawerWidth = 240
@@ -27,14 +27,12 @@ const Alert = forwardRef(function Alert(props, ref) {
})
export default function TeksDrawer({paroles}) {
const {slug} = useParams()
const [parole] = useCurrentTrack()
const [mobileOpen, setMobileOpen] = useState(false)
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const parole = paroles.find((parole) => parole.slug === slug)
useEffect(() => {
if (error || success) {
setOpen(true)
@@ -84,7 +82,7 @@ export default function TeksDrawer({paroles}) {
<MenuIcon />
</IconButton>
{parole && (
<Link passHref href='/paroles'>
<Link passHref prefetch={false} href='/paroles'>
<IconButton aria-label='return' size='medium'>
<KeyboardBackspaceIcon style={{fontSize: '1.5em'}} />
</IconButton>
+15 -8
View File
@@ -3,8 +3,6 @@
import PropTypes from 'prop-types'
import {useRouter} from 'next/navigation'
import Image from 'next/image'
import {format} from 'date-fns'
import {fr} from 'date-fns/locale'
import Card from '@mui/material/Card'
import CardActionArea from '@mui/material/CardActionArea'
@@ -13,9 +11,11 @@ import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid'
import ExplicitIcon from '@mui/icons-material/Explicit'
import EventIcon from '@mui/icons-material/Event'
import {styled} from '@mui/material/styles'
import {getAlias} from '../../lib/utils/format'
import {getAlias} from '../../lib/utils/get-alias'
import {formatKuveti} from '../../lib/kuveti'
const PREFIX = 'teks-kat'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -36,7 +36,7 @@ export default function TeksKat({parole}) {
const router = useRouter()
const {titre, artistes, annee, couverture, createdAt, slug} = parole
const datPiblikasyon = format(new Date(createdAt), 'P', {locale: fr})
const datPiblikasyon = new Intl.DateTimeFormat(undefined, {dateStyle: 'short'}).format(new Date(createdAt))
const aliases = getAlias(artistes, parole.prioriteArtistes)
const handleClick = slug => {
@@ -50,9 +50,9 @@ export default function TeksKat({parole}) {
<Box sx={{position: 'relative', height: 240}}>
{couverture?.url ? (
<Image
src={`${IMAGE_URL}${couverture.formats?.thumbnail?.url || couverture.url}`}
alt={titre}
fill
src={`${IMAGE_URL}${formatKuveti(couverture, 'thumbnail')?.url}`}
alt={titre}
placeholder='blur'
blurDataURL={BLUR_DATA_URL}
sizes='(max-width: 600px) 100vw, (max-width: 900px) 50vw, 33vw'
@@ -102,8 +102,15 @@ export default function TeksKat({parole}) {
</>
)}
</Typography>
<Typography align='center' style={{marginTop: '0.5em'}} variant='body1' color='textSecondary' component='p'>
Publiée le : {datPiblikasyon}
<Typography
align='center'
style={{marginTop: '0.5em'}}
variant='body1'
color='textSecondary'
component='p'
sx={{display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5}}
>
<EventIcon fontSize='inherit' /> {datPiblikasyon}
</Typography>
</Box>
</CardContent>
+114 -26
View File
@@ -3,8 +3,11 @@
import {useEffect, useState} from 'react'
import PropTypes from 'prop-types'
import Box from '@mui/material/Box'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Select from '@mui/material/Select'
import MenuItem from '@mui/material/MenuItem'
import ListSubheader from '@mui/material/ListSubheader'
import Divider from '@mui/material/Divider'
import FormControl from '@mui/material/FormControl'
import Typography from '@mui/material/Typography'
import Tooltip from '@mui/material/Tooltip'
import {useMediaQuery} from '@mui/material'
@@ -13,18 +16,26 @@ import slugify from 'slugify'
import {styled} from '@mui/material/styles'
import ExplicitIcon from '@mui/icons-material/Explicit'
import TranslateIcon from '@mui/icons-material/Translate'
import StarIcon from '@mui/icons-material/Star'
import ArticleIcon from '@mui/icons-material/Article'
import Image from 'next/image'
import {formatJsonString, getAlias} from '../../lib/utils/format'
import {formatJsonString} from '../../lib/utils/format'
import {getAlias} from '../../lib/utils/get-alias'
import {formatKuveti} from '../../lib/kuveti'
import LicenseModal from '../cc/license-modal'
import FilesDialog from '../files/files-dialog'
import KitsDialog from '../files/kits-dialog'
import {StreamButton} from '../streaming-buttons'
import EntegreMizik from './entegre-mizik'
import OkiMizik from './oki-mizik'
import DiferansDialog from './diferans-dialog'
import Komante from './komante'
import ReponsMastodon from './repons-mastodon'
import {useCurrentTrack} from './current-track-context'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -84,15 +95,35 @@ const LANG_NAMES = {
pt: 'Português', ja: '日本語', ko: '한국어',
}
const CONTINENTS = ['Afrique', 'Asie', 'Europe']
const TRAD_FIELDS = [
{field: 'anglais', title: 'English'},
{field: 'francais', title: 'Français'},
{field: 'espagnol', title: 'Español'},
{field: 'allemand', title: 'Deutsch'},
{field: 'italien', title: 'Italiano'},
{field: 'portugais', title: 'Português'},
{field: 'japonais', title: '日本語'},
{field: 'coreen', title: '한국어'},
// Afrique — langues vedettes en premier
{field: 'ayisyen', title: 'Ayisyen 🇭🇹', continent: 'Afrique', vedette: true},
{field: 'yoruba', title: 'Yorùbá', continent: 'Afrique', vedette: true},
{field: 'lingala', title: 'Lingála', continent: 'Afrique', vedette: true},
{field: 'wolof', title: 'Wolof', continent: 'Afrique', vedette: true},
{field: 'swahili', title: 'Kiswahili', continent: 'Afrique', vedette: true},
{field: 'hausa', title: 'Hausa', continent: 'Afrique', vedette: true},
{field: 'arabe', title: 'العربية', continent: 'Afrique'},
{field: 'oromo', title: 'Oromoo', continent: 'Afrique'},
{field: 'igbo', title: 'Igbo', continent: 'Afrique'},
{field: 'zoulou', title: 'isiZulu', continent: 'Afrique'},
{field: 'malgache', title: 'Malagasy', continent: 'Afrique'},
{field: 'xhosa', title: 'isiXhosa', continent: 'Afrique'},
{field: 'tswana', title: 'Setswana', continent: 'Afrique'},
{field: 'tsonga', title: 'Xitsonga', continent: 'Afrique'},
{field: 'sesotho', title: 'Sesotho', continent: 'Afrique'},
// Asie
{field: 'japonais', title: '日本語', continent: 'Asie'},
{field: 'coreen', title: '한국어', continent: 'Asie'},
// Europe
{field: 'anglais', title: 'English', continent: 'Europe'},
{field: 'francais', title: 'Français', continent: 'Europe'},
{field: 'espagnol', title: 'Español', continent: 'Europe'},
{field: 'allemand', title: 'Deutsch', continent: 'Europe'},
{field: 'italien', title: 'Italiano', continent: 'Europe'},
{field: 'portugais', title: 'Português', continent: 'Europe'},
]
const langToArray = parole => {
@@ -102,7 +133,7 @@ const langToArray = parole => {
return TRAD_FIELDS
.filter(({field}) => parole.traductions[field])
.map(({field, title}) => ({title, lang: parole.traductions[field]}))
.map(({field, title, continent, vedette}) => ({field, title, continent, vedette, lang: parole.traductions[field]}))
}
const ExplicitTooltip = Tooltip
@@ -113,6 +144,7 @@ export default function Teks({parole}) {
const enhancedAliases = getAlias(parole.artistes, parole.prioriteArtistes, true)
const coverFmt = formatKuveti(parole.couverture)
const [tab, setTab] = useState(0)
const [, setCurrentTrack] = useCurrentTrack()
useEffect(() => {
const isBrowser = () => typeof window !== 'undefined'
@@ -123,6 +155,11 @@ export default function Teks({parole}) {
window.scrollTo({top: 0, behavior: 'smooth'})
}, [])
useEffect(() => {
setCurrentTrack(parole)
return () => setCurrentTrack(null)
}, [parole, setCurrentTrack])
return (
<Root className={classes.container}>
<Box sx={{textAlign: 'center', marginTop: 12}}>
@@ -172,7 +209,7 @@ export default function Teks({parole}) {
<i>parole soumise par {parole.user.username}</i>
</Typography>
)}
{parole?.userAdmin&& !parole.user && (
{parole?.userAdmin && !parole.user && (
<Typography style={{marginBottom: '1.5em'}} display='block' variant='caption'>
<i>parole soumise par {parole.userAdmin.firstname}</i>
</Typography>
@@ -182,9 +219,16 @@ export default function Teks({parole}) {
<LicenseModal license={parole.creativeCommons.toLowerCase()} sourceOriginale={parole.sourceOriginale ?? null} remixes={parole.remixes?.length ? parole.remixes : null} />
</Box>
)}
{(parole?.files || (parole.creativeCommons && parole.kits?.length > 0)) && (
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}>
{parole?.files && (
<FilesDialog files={parole.files} />
)}
{parole.creativeCommons && parole.kits?.length > 0 && (
<KitsDialog kits={parole.kits} />
)}
</Box>
)}
{(parole.okiMizikID || parole.streamAudio.length > 0 || parole.gadeEmbed) && (
<Box sx={{textAlign: 'center'}}>
<EntegreMizik parole={parole} isMobile={isMobile} />
@@ -202,27 +246,69 @@ export default function Teks({parole}) {
Écouter sur
</Typography>
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}>
{parole.streamAudio.map((lyen, i) => (
<StreamButton key={i} lyen={lyen} />
{parole.streamAudio.map(lyen => (
<StreamButton key={lyen.url} lyen={lyen} />
))}
</Box>
</Box>
)}
</Box>
<Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}>
<Tabs
<Box sx={{display: 'flex', justifyContent: 'center', borderBottom: 1, borderColor: 'divider', mb: 2, pb: 2}}>
<FormControl size='small' sx={{minWidth: 240, width: isMobile ? '100%' : 'auto'}}>
<Select
value={tab}
onChange={(event, value) => setTab(value)}
variant='scrollable'
scrollButtons='auto'
centered={langArray.length === 0}
sx={{borderBottom: 1, borderColor: 'divider', mb: 2}}
MenuProps={{PaperProps: {sx: {maxHeight: 420}}}}
renderValue={value => {
const label = value === 0 ? 'Transcription' : langArray[value - 1]?.title
const Icon = value === 0 ? ArticleIcon : TranslateIcon
return (
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, fontWeight: value === 0 ? 700 : 400}}>
<Icon fontSize='small' color='primary' />
{label}
</Box>
)
}}
onChange={event => setTab(event.target.value)}
>
<Tab label='Transcription' />
{langArray.map(({title}) => (
<Tab key={title} label={title} />
))}
</Tabs>
<MenuItem value={0} sx={{display: 'flex', gap: 0.75, fontWeight: 700}}>
<ArticleIcon fontSize='inherit' color='primary' />
Transcription
</MenuItem>
<Divider />
{CONTINENTS.flatMap(continent => {
const items = langArray
.map((item, index) => ({...item, index}))
.filter(item => item.continent === continent)
if (items.length === 0) {
return []
}
return [
<ListSubheader
key={`entet-${continent}`}
sx={{
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
color: 'primary.main',
lineHeight: '2.5em'
}}
>
{continent}
</ListSubheader>,
...items.map(({title, index, vedette}) => (
<MenuItem key={title} value={index + 1} sx={{display: 'flex', gap: 0.75}}>
{vedette && <StarIcon fontSize='inherit' color='primary' />}
{title}
</MenuItem>
))
]
})}
</Select>
</FormControl>
</Box>
{tab === 0 && (
<>
{parole.langueSource && parole.langueSource !== 'ka' && (
@@ -243,6 +329,8 @@ export default function Teks({parole}) {
)
))}
</Box>
<ReponsMastodon bokanteStatusId={parole.bokanteStatusId} />
<Komante commentaires={parole.commentaires} />
</Root>
)
}
+12 -2
View File
@@ -1,7 +1,17 @@
'use client'
import PropTypes from 'prop-types'
import NextTopLoader from 'nextjs-toploader'
export default function TopLoader({color}) {
return <NextTopLoader color={color} />
export default function TopLoader({color, nonce}) {
return <NextTopLoader color={color} nonce={nonce} />
}
TopLoader.propTypes = {
color: PropTypes.string.isRequired,
nonce: PropTypes.string
}
TopLoader.propTypes = {
color: PropTypes.string.isRequired
}
+14 -14
View File
@@ -1,25 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import {dirname} from 'node:path'
import {fileURLToPath} from 'node:url'
import {FlatCompat} from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
});
})
const eslintConfig = [
...compat.extends("next/core-web-vitals"),
...compat.extends('next/core-web-vitals'),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
'node_modules/**',
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
],
},
];
]
export default eslintConfig;
export default eslintConfig
+38
View File
@@ -0,0 +1,38 @@
import {describe, it, expect} from 'vitest'
import {formatKuveti} from '../kuveti'
const kuveti = {
url: '/uploads/original.jpg',
formats: {
thumbnail: {url: '/uploads/thumbnail.jpg', width: 156, height: 156},
small: {url: '/uploads/small.jpg', width: 500, height: 500},
medium: {url: '/uploads/medium.jpg', width: 750, height: 750},
large: {url: '/uploads/large.jpg', width: 1000, height: 1000},
}
}
describe('formatKuveti', () => {
it('returns null when there is no image', () => {
expect(formatKuveti(null)).toBeNull()
expect(formatKuveti(undefined)).toBeNull()
})
it('defaults to the large format', () => {
expect(formatKuveti(kuveti)).toBe(kuveti.formats.large)
})
it('prefers the thumbnail format when asked', () => {
expect(formatKuveti(kuveti, 'thumbnail')).toBe(kuveti.formats.thumbnail)
})
it('falls back to small then medium then large when thumbnail is missing', () => {
const {thumbnail, ...rest} = kuveti.formats
const withoutThumbnail = {...kuveti, formats: rest}
expect(formatKuveti(withoutThumbnail, 'thumbnail')).toBe(rest.small)
})
it('falls back to the original file when no format matches', () => {
const noFormats = {url: '/uploads/original.jpg', formats: {}}
expect(formatKuveti(noFormats, 'thumbnail')).toBe(noFormats)
})
})
+58
View File
@@ -0,0 +1,58 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {jwennTeksSlug} from '../oki-api'
function fakeEntry(i) {
return {slug: `slug-${i}`}
}
describe('jwennTeksSlug (full pagination)', () => {
const originalFetch = global.fetch
afterEach(() => {
global.fetch = originalFetch
vi.restoreAllMocks()
})
it('walks every page instead of stopping at the first pageSize chunk', async () => {
const pageSize = 200
const totalRecords = 250
let calls = 0
global.fetch = vi.fn(async url => {
calls++
if (calls > 5) {
throw new Error('too many pagination calls, likely an infinite loop')
}
const pageMatch = url.match(/pagination\[page]=(\d+)/)
const page = pageMatch ? Number(pageMatch[1]) : 1
const start = (page - 1) * pageSize
const end = Math.min(start + pageSize, totalRecords)
const data = Array.from({length: Math.max(end - start, 0)}, (_, i) => fakeEntry(start + i))
return {
ok: true,
json: async () => ({data})
}
})
const slugs = await jwennTeksSlug()
expect(slugs).toHaveLength(totalRecords)
expect(slugs[0]).toBe('slug-0')
expect(slugs.at(-1)).toBe('slug-249')
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('stops after a single page when there are fewer records than pageSize', async () => {
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({data: [fakeEntry(1), fakeEntry(2)]})
}))
const slugs = await jwennTeksSlug()
expect(slugs).toEqual(['slug-1', 'slug-2'])
expect(global.fetch).toHaveBeenCalledTimes(1)
})
})
+37
View File
@@ -0,0 +1,37 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {jwennAnTeks} from '../oki-api'
describe('request (via jwennAnTeks)', () => {
const originalFetch = global.fetch
afterEach(() => {
global.fetch = originalFetch
vi.restoreAllMocks()
})
it('returns the parsed body on a successful response', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({id: 1, titre: 'Test'})
})
const result = await jwennAnTeks(1, 'token')
expect(result).toEqual({id: 1, titre: 'Test'})
})
it('throws a clear error when Strapi returns an error response', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({data: null, error: {status: 400, name: 'ValidationError', message: 'Invalid key'}})
})
await expect(jwennAnTeks(1, 'token')).rejects.toThrow('Eshwe pu jwenn done-a')
})
it('throws when the network request itself fails', async () => {
global.fetch = vi.fn().mockRejectedValue(new TypeError('network down'))
await expect(jwennAnTeks(1, 'token')).rejects.toThrow('Eshwe pu jwenn done-a')
})
})
+9 -2
View File
@@ -2,13 +2,20 @@ const SIZE_ORDER = {
large: ['large', 'medium', 'small'],
medium: ['medium', 'small', 'large'],
small: ['small', 'medium', 'large'],
thumbnail: ['thumbnail', 'small', 'medium', 'large'],
}
export const formatKuveti = (kuveti, preferred = 'large') => {
if (!kuveti) return null
if (!kuveti) {
return null
}
const order = SIZE_ORDER[preferred] ?? SIZE_ORDER.large
for (const size of order) {
if (kuveti.formats?.[size]) return kuveti.formats[size]
if (kuveti.formats?.[size]) {
return kuveti.formats[size]
}
}
return kuveti
}
+48 -48
View File
@@ -2,7 +2,7 @@ import qs from 'qs'
const OKI_API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
const AWTIS_POU_CHAK_PAJ = process.env.NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ || 6
const readToken = process.env.NEXT_PUBLIC_READ_TOKEN || 'read-token'
const readToken = process.env.API_READ_TOKEN || 'read-token'
const headers = {
next: {revalidate: 60},
@@ -14,12 +14,18 @@ const headers = {
async function request(url, opts = {}) {
const {...options} = opts
let res
try {
const res = await fetch(`${OKI_API}${url}`, options)
return res.json()
res = await fetch(`${OKI_API}${url}`, options)
} catch {
throw new Error('Eshwe pu jwenn done-a')
}
if (!res.ok) {
throw new Error('Eshwe pu jwenn done-a')
}
return res.json()
}
export async function jwennTeksEpiSlug(slug) {
@@ -32,7 +38,16 @@ export async function jwennTeksEpiSlug(slug) {
fields: ['username', 'firstname']
},
commentaires: {
fields: ['contenu', 'datePublication'],
fields: [
'contenu',
'datePublication',
'origine',
'auteurNom',
'auteurHandle',
'auteurAvatarUrl',
'auteurProfilUrl',
'remoteUrl'
],
populate: {
user: {
fields: ['username']
@@ -54,6 +69,9 @@ export async function jwennTeksEpiSlug(slug) {
streamVideo: {
populate: '*'
},
kits: {
populate: '*'
},
traductions: {
populate: '*'
},
@@ -130,7 +148,7 @@ export async function jwennToutAwtis() {
populate: ['photo'],
sort: ['alias:asc'],
pagination: {
pageSize: 100
pageSize: 200
}
}, {
encodeValuesOnly: true
@@ -143,39 +161,16 @@ export async function jwennToutAwtis() {
export async function jwennTeks() {
const query = qs.stringify({
populate: {
user: {
fields: ['username']
},
userAdmin: {
fields: ['username', 'firstname']
},
commentaires: {
fields: ['contenu', 'datePublication'],
populate: {
user: {
fields: ['username']
}
}
},
couverture: {
populate: '*'
},
artistes: {
fields: ['alias', 'slug']
},
streamAudio: {
populate: '*'
},
streamVideo: {
populate: '*'
},
traductions: {
populate: '*'
}
},
sort: ['titre:asc'],
pagination: {
pageSize: 100
pageSize: 200
}
}, {
encodeValuesOnly: true
@@ -185,32 +180,37 @@ export async function jwennTeks() {
return data
}
export async function jwennAwtisSlug() {
async function jwennTouSlug(endpoint) {
const pageSize = 200
const slugs = []
let page = 1
let hasMore = true
while (hasMore) {
const query = qs.stringify({
sort: ['createdAt:desc'],
pagination: {
pageSize: 100
}
fields: ['slug'],
pagination: {page, pageSize}
}, {
encodeValuesOnly: true
})
const {data} = await request(`/artistes?${query}`, headers)
return data.map(({slug}) => slug)
// eslint-disable-next-line no-await-in-loop -- pagination séquentielle, chaque page dépend du nombre de résultats de la précédente
const {data} = await request(`/${endpoint}?${query}`, headers)
slugs.push(...data.map(({slug}) => slug))
hasMore = data.length === pageSize
page += 1
}
return slugs
}
export async function jwennAwtisSlug() {
return jwennTouSlug('artistes')
}
export async function jwennTeksSlug() {
const query = qs.stringify({
sort: ['createdAt:desc'],
pagination: {
pageSize: 100
}
}, {
encodeValuesOnly: true
})
const {data} = await request(`/paroles?${query}`, headers)
return data.map(({slug}) => slug)
return jwennTouSlug('paroles')
}
export async function jwennDenyeTeks() {
@@ -233,9 +233,9 @@ export async function jwennAnVedette() {
const query = qs.stringify({
populate: ['artistes', 'couverture'],
filters: {
isNewRelease: { $eq: true }
isNewRelease: {$eq: true}
},
pagination: { limit: 1 }
pagination: {limit: 1}
}, {
encodeValuesOnly: true
})
-4
View File
@@ -1,16 +1,12 @@
import YouTubeIcon from '@mui/icons-material/YouTube'
import TelegramIcon from '@mui/icons-material/Telegram'
import PeertubeIcon from './icons/peertube'
import XmppIcon from './icons/xmpp'
import XIcon from './icons/x'
import GiteaIcon from './icons/gitea'
import BlueskyIcon from './icons/bluesky'
const gitURL = process.env.NEXT_PUBLIC_GIT || 'https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/pawol.nu'
const blueskyUrl = process.env.NEXT_PUBLIC_BLUESKY_URL || 'https://bsky.app/profile/organisationka.bsky.social'
const xmppContact = process.env.NEXT_PUBLIC_XMPP || 'oki@xmpp.cz'
const gadeUsername = process.env.NEXT_PUBLIC_GADE_USERNAME || 'oki'
const youtubeUsername = process.env.NEXT_PUBLIC_YOUTUBE_USERNAME || 'OrganisationKInternationaleOKi'
const tiwtterUsername = process.env.NEXT_PUBLIC_TWITTER_USERNAME || 'OrganisationKA'
const telegramGroup = process.env.NEXT_PUBLIC_TELEGRAM_GROUP || 'OrganisationKA'
+26
View File
@@ -0,0 +1,26 @@
import {describe, it, expect} from 'vitest'
import {getAlias} from '../get-alias'
describe('getAlias', () => {
it('retourne une chaîne vide sans planter quand artistes est undefined', () => {
expect(getAlias(undefined)).toBe('')
})
it('retourne une chaîne vide sans planter quand artistes est un tableau vide', () => {
expect(getAlias([])).toBe('')
})
it('retourne l\'alias unique quand il n\'y a qu\'un artiste', () => {
expect(getAlias([{id: 1, alias: 'Foo'}])).toBe('Foo')
})
it('ignore un id de priorité qui ne correspond à aucun artiste (donnée obsolète)', () => {
const artistes = [{id: 1, alias: 'Foo'}, {id: 2, alias: 'Bar'}]
expect(getAlias(artistes, '1-99-2')).toBe('Foo et Bar')
})
it('formate plusieurs artistes selon la priorité fournie', () => {
const artistes = [{id: 1, alias: 'Foo'}, {id: 2, alias: 'Bar'}]
expect(getAlias(artistes, '2-1')).toBe('Bar et Foo')
})
})
-52
View File
@@ -1,54 +1,2 @@
import slugify from 'slugify'
export const formatJsonString = stringToFormat =>
stringToFormat.split('\n').map((string, index) => <div key={index}>{`${string}`}<br /></div>) // eslint-disable-line react/no-array-index-key
export const getAlias = (artistes, priorite = null, enhanced = false) => {
if (artistes.length === 1 && !enhanced) {
return artistes[0].alias
}
if (artistes.length === 1 && enhanced) {
return [{
type: 'element',
value: artistes[0].alias
}]
}
const alias = []
const splittedPriority = priorite ? priorite.split('-')
.map(p => Number.parseInt(p, 10)) : null
if (splittedPriority) {
for (const element of splittedPriority) {
const foundedArtistes = artistes.find(({id}) => id === element)
alias.push(foundedArtistes.alias)
}
} else {
for (const artiste of artistes) {
alias.push(artiste.alias)
}
}
if (enhanced) {
const list = new Intl.ListFormat('fr').formatToParts(alias)
return list
}
return new Intl.ListFormat('fr').format(alias)
}
function separateString(inputString) {
const regex = /\s*,\s*|\s+et\s+/
const strings = inputString.split(regex).map(element => element.trim())
return strings
}
export const getAliasObject = inputString => {
const awtis = separateString(inputString)
return awtis.map(alias => ({
alias,
slug: slugify(alias, {lower: true, remove: /[*#+~.()'"!:@]/g})
}))
}
+55
View File
@@ -0,0 +1,55 @@
import slugify from 'slugify'
export const getAlias = (artistes, priorite = null, enhanced = false) => {
const safeArtistes = artistes || []
if (safeArtistes.length === 1 && !enhanced) {
return safeArtistes[0].alias
}
if (safeArtistes.length === 1 && enhanced) {
return [{
type: 'element',
value: safeArtistes[0].alias
}]
}
const alias = []
const splittedPriority = priorite ? priorite.split('-')
.map(p => Number.parseInt(p, 10)) : null
if (splittedPriority) {
for (const element of splittedPriority) {
const foundedArtistes = safeArtistes.find(({id}) => id === element)
if (foundedArtistes) {
alias.push(foundedArtistes.alias)
}
}
} else {
for (const artiste of safeArtistes) {
alias.push(artiste.alias)
}
}
if (enhanced) {
const list = new Intl.ListFormat('fr').formatToParts(alias)
return list
}
return new Intl.ListFormat('fr').format(alias)
}
function separateString(inputString) {
const regex = /\s*,\s*|\s+et\s+/
const strings = inputString.split(regex).map(element => element.trim())
return strings
}
export const getAliasObject = inputString => {
const awtis = separateString(inputString)
return awtis.map(alias => ({
alias,
slug: slugify(alias, {lower: true, remove: /[*#+~.()'"!:@]/g})
}))
}
+51 -12
View File
@@ -11,27 +11,66 @@ function buildRemotePatterns() {
const [hostname, port] = entry.split(':')
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1'
const pattern = {protocol: isLocal ? 'http' : 'https', hostname, pathname: '/uploads/**'}
if (port) pattern.port = port
if (port) {
pattern.port = port
}
return pattern
})
if (!raw.includes('localhost'))
if (!raw.includes('localhost')) {
patterns.push({protocol: 'http', hostname: 'localhost', port: '1337', pathname: '/uploads/**'})
if (!raw.includes('127.0.0.1'))
}
if (!raw.includes('127.0.0.1')) {
patterns.push({protocol: 'http', hostname: '127.0.0.1', port: '1337', pathname: '/uploads/**'})
}
return patterns
}
async function headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Cross-Origin-Resource-Policy',
value: 'same-site'
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin'
},
{
key: 'Permissions-Policy',
value: 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
}
]
}
]
}
module.exports = (withPWA({
turbopack: {},
webpack: config => {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack', 'url-loader'],
})
return config
},
poweredByHeader: false,
images: {
remotePatterns: buildRemotePatterns()
}
},
headers
}))
+16 -14
View File
@@ -17,7 +17,9 @@
"lint": "xo",
"dev": "next dev",
"build": "next build",
"start": "next start"
"start": "next start",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
@@ -32,36 +34,36 @@
"@mui/material-pigment-css": "^9.0.0",
"@mui/styles": "^6.4.8",
"@pigment-css/react": "^0.0.30",
"@svgr/webpack": "^6.5.0",
"axios": "^0.21.0",
"compression": "^1.7.4",
"date-fns": "^2.16.1",
"dotenv": "^16.0.0",
"express": "^4.17.1",
"file-saver": "^2.0.5",
"lodash": "^4.17.21",
"music-metadata-browser": "^2.5.11",
"next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"next-plausible": "^4.0.0",
"next-pwa": "5.6.0",
"nextjs-toploader": "^3.9.17",
"nodemailer": "^6.7.2",
"nprogress": "^0.2.0",
"qs": "^6.15.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-swipeable-views": "^0.13.9",
"react-virtuoso": "^4.4.0",
"sharp": "^0.29.3",
"slugify": "^1.6.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^8.7.0",
"eslint-config-next": "16.2.4",
"eslint-config-xo-nextjs": "^4.0.3",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"jsdom": "^29.1.1",
"vitest": "^4.1.9",
"xo": "^0.47.0"
},
"xo": {
@@ -83,11 +85,11 @@
"error",
{
"capIsNewExceptions": [
"CredentialsProvider",
"TwitterProvider",
"GoogleProvider",
"GitHubProvider",
"NextAuth"
"GET",
"POST",
"PUT",
"DELETE",
"PATCH"
]
}
],
+121
View File
@@ -0,0 +1,121 @@
/* eslint-disable @next/next/no-server-import-in-page */
import {NextResponse} from 'next/server'
const nonceCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function generateNonce() {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
let nonce = ''
for (const byte of array) {
nonce += nonceCharset[byte % 64]
}
return nonce
}
function extractOrigin(url) {
if (!url) {
return ''
}
try {
return new URL(url).origin
} catch {
return ''
}
}
function extractHostSource(raw) {
if (!raw) {
return ''
}
const [hostname, port] = raw.split(':')
if (!hostname) {
return ''
}
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `http://${hostname}${port ? `:${port}` : ''}`
}
return `https://${hostname}${port ? `:${port}` : ''}`
}
function buildCsp(nonce) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_URL_ROOT || ''
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL || ''
const mizikUrl = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || ''
const imageDomainsRaw = process.env.NEXT_PUBLIC_DOMAINS_IMAGE || ''
const apiOrigin = extractOrigin(apiUrl)
const plausibleOrigin = extractOrigin(plausibleUrl)
const mizikOrigin = extractOrigin(mizikUrl)
const imageOrigins = imageDomainsRaw
.split(' ')
.filter(Boolean)
.map(entry => extractHostSource(entry))
.filter(Boolean)
const imageOriginsSet = new Set([apiOrigin, ...imageOrigins])
const imageOriginsUnique = [...imageOriginsSet].filter(Boolean)
const connectSrc = ['\'self\'', apiOrigin, plausibleOrigin, mizikOrigin].filter(Boolean)
const imgSrc = ['\'self\'', 'data:', 'blob:', ...imageOriginsUnique].filter(Boolean)
const mediaSrc = ['\'self\'', apiOrigin, mizikOrigin].filter(Boolean)
// Frame-src is intentionally broad: the application embeds user-supplied
// PeerTube instances in addition to known platforms (Tidal, Deezer, Spotify,
// Soundcloud, Apple Music). Restricting this to a fixed list would break
// user-generated content.
const frameSrc = ['\'self\'', 'https:']
const directives = [
'default-src \'self\'',
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src-elem 'self' 'nonce-${nonce}'`,
'style-src-attr \'unsafe-inline\'',
`img-src ${imgSrc.join(' ')}`,
'font-src \'self\'',
`connect-src ${connectSrc.join(' ')}`,
`frame-src ${frameSrc.join(' ')}`,
`media-src ${mediaSrc.join(' ')}`,
'manifest-src \'self\'',
'object-src \'none\'',
'base-uri \'self\'',
'form-action \'self\'',
'frame-ancestors \'self\'',
'upgrade-insecure-requests'
]
return directives.join('; ')
}
export function proxy(request) {
const nonce = generateNonce()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
const response = NextResponse.next({
request: {
headers: requestHeaders
}
})
response.headers.set('Content-Security-Policy', buildCsp(nonce))
return response
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{type: 'header', key: 'next-router-prefetch'},
{type: 'header', key: 'purpose', value: 'prefetch'}
]
}
]
}
+11
View File
@@ -0,0 +1,11 @@
import {defineConfig} from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.js'],
globals: true,
},
})
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest' // eslint-disable-line import/no-unassigned-import
+839 -3199
View File
File diff suppressed because it is too large Load Diff