50 Commits
Author SHA1 Message Date
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
85 changed files with 2086 additions and 5963 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=$API_URL
NEXT_PUBLIC_API_URL_ROOT=http://localhost:1337 NEXT_PUBLIC_API_URL_ROOT=http://localhost:1337
SITE_URL=http://localhost:3001 SITE_URL=http://localhost:3001
NEXT_PUBLIC_READ_TOKEN= API_READ_TOKEN=
NEXT_PUBLIC_ADMIN_JWT_SECRET=
# IDENTITÉ DU SITE (branding) # IDENTITÉ DU SITE (branding)
NEXT_PUBLIC_SITE_NAME=PAWÒL-NU. Paroles et traductions. NEXT_PUBLIC_SITE_NAME=PAWÒL-NU. Paroles et traductions.
@@ -18,8 +17,8 @@ NEXT_PUBLIC_EXCLUSIVE_ARTIST_LABEL=OKI Exclusif
# FUNKWHALE VARIABLE # FUNKWHALE VARIABLE
NEXT_PUBLIC_OKI_MIZIK_URL=https://funkwhale-server.com NEXT_PUBLIC_OKI_MIZIK_URL=https://funkwhale-server.com
NEXT_PUBLIC_MIZIK_API_USER=user MIZIK_API_USER=user
NEXT_PUBLIC_MIZIK_API_PASSWORD=password MIZIK_API_PASSWORD=password
NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ=6 NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ=6
NEXT_PUBLIC_SITE_URL=$SITE_URL NEXT_PUBLIC_SITE_URL=$SITE_URL
@@ -77,18 +76,19 @@ NEXT_PUBLIC_LIBERAPAY_DONATE=
NEXT_PUBLIC_PALE_USERNAME= NEXT_PUBLIC_PALE_USERNAME=
NEXT_PUBLIC_GADE_USERNAME= NEXT_PUBLIC_GADE_USERNAME=
NEXT_PUBLIC_YOUTUBE_USERNAME= NEXT_PUBLIC_YOUTUBE_USERNAME=
NEXT_PUBLIC_TELEGRAM_GROUP=
NEXT_PUBLIC_XMPP= NEXT_PUBLIC_XMPP=
NEXT_PUBLIC_GIT= NEXT_PUBLIC_GIT=
NEXT_PUBLIC_CODEBERG= NEXT_PUBLIC_CODEBERG=
NEXT_PUBLIC_BLUESKY_URL= 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 # DOMAIN IMAGE
NEXT_PUBLIC_DOMAINS_IMAGE="localhost:1337 strapi.mondomaine.com" NEXT_PUBLIC_DOMAINS_IMAGE="localhost:1337 strapi.mondomaine.com"
# JWT SECRET
NEXT_PUBLIC_JWT_SECRET=
# STRIPE # STRIPE
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
+7
View File
@@ -22,6 +22,12 @@ jobs:
- name: Vérifier les dépendances - name: Vérifier les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer le lint
run: yarn lint
- name: Lancer les tests
run: yarn test
deploy-beta: deploy-beta:
needs: check needs: check
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -33,6 +39,7 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
script: | script: |
set -e
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ secrets.FRONT_DEPLOY_PATH }} cd ${{ secrets.FRONT_DEPLOY_PATH }}
+7
View File
@@ -20,6 +20,12 @@ jobs:
- name: Vérifier les dépendances - name: Vérifier les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer le lint
run: yarn lint
- name: Lancer les tests
run: yarn test
deploy: deploy:
needs: check needs: check
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -31,6 +37,7 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
script: | script: |
set -e
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ secrets.FRONT_DEPLOY_PATH }} cd ${{ secrets.FRONT_DEPLOY_PATH }}
+7
View File
@@ -22,6 +22,12 @@ jobs:
- name: Vérifier les dépendances - name: Vérifier les dépendances
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Lancer le lint
run: yarn lint
- name: Lancer les tests
run: yarn test
deploy: deploy:
needs: check needs: check
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -33,6 +39,7 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
script: | script: |
set -e
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ secrets.PROD_DEPLOY_PATH }} cd ${{ secrets.PROD_DEPLOY_PATH }}
+18
View File
@@ -24,6 +24,24 @@ cp .env.sample .env
yarn && yarn dev 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 ## License
Copyright (C) 2020 - 2026 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI) 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,55 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {GET} from '../route'
function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) {
return {
status,
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')
})
})
+29
View File
@@ -0,0 +1,29 @@
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
const MIZIK_API_USER = process.env.MIZIK_API_USER || 'user'
const MIZIK_API_PASSWORD = process.env.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} : {}
})
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
}
+2 -2
View File
@@ -20,7 +20,7 @@ async function jwennAwtis(slug) {
} }
export async function generateMetadata(props) { export async function generateMetadata(props) {
const params = await props.params; const params = await props.params
const {slug} = params const {slug} = params
const anAwtis = await jwennAwtis(slug) const anAwtis = await jwennAwtis(slug)
@@ -64,7 +64,7 @@ export async function generateMetadata(props) {
} }
export default async function AwtisPajSlug(props) { export default async function AwtisPajSlug(props) {
const params = await props.params; const params = await props.params
const {slug} = params const {slug} = params
const anAwtis = await jwennAwtis(slug) const anAwtis = await jwennAwtis(slug)
+1 -1
View File
@@ -63,7 +63,7 @@ async function jwennDone(paj) {
} }
export default async function AwitsPaj(props) { export default async function AwitsPaj(props) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams
const {paj} = searchParams const {paj} = searchParams
const {pajTotal, awtisPouChakPaj, pajParsed} = await jwennDone(paj || 1) const {pajTotal, awtisPouChakPaj, pajParsed} = await jwennDone(paj || 1)
+1 -1
View File
@@ -78,7 +78,7 @@ export default async function RootLayout({children}) {
) )
return ( return (
<html lang='fr' suppressHydrationWarning> <html suppressHydrationWarning lang='fr'>
<body> <body>
{plausibleUrl {plausibleUrl
? <PlausibleProvider src={plausibleUrl}>{inner}</PlausibleProvider> ? <PlausibleProvider src={plausibleUrl}>{inner}</PlausibleProvider>
+3 -4
View File
@@ -1,5 +1,3 @@
export const dynamic = 'force-dynamic'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Container from '@mui/material/Container' import Container from '@mui/material/Container'
import {notFound} from 'next/navigation' import {notFound} from 'next/navigation'
@@ -9,10 +7,11 @@ import Statistik from '../components/akey/statistik'
import Akey from '../components/akey' import Akey from '../components/akey'
import AnVedette from '../components/akey/an-vedette' import AnVedette from '../components/akey/an-vedette'
import okiLogo from '../public/logo-512x512.png'
import Footer from '../components/footer' import Footer from '../components/footer'
import Aso from '../components/akey/aso' import Aso from '../components/akey/aso'
export const dynamic = 'force-dynamic'
async function jwennDone() { async function jwennDone() {
const [statistik, denyeTeks, anVedette] = await Promise.all([jwennStats(), jwennDenyeTeks(), jwennAnVedette()]) const [statistik, denyeTeks, anVedette] = await Promise.all([jwennStats(), jwennDenyeTeks(), jwennAnVedette()])
@@ -28,7 +27,7 @@ export default async function Page() {
return ( return (
<Box sx={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}> <Box sx={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}>
<Akey logo={okiLogo} /> <Akey />
<Container sx={{flexGrow: 100}}> <Container sx={{flexGrow: 100}}>
{dernierTeks && <AnVedette teks={dernierTeks} />} {dernierTeks && <AnVedette teks={dernierTeks} />}
<Statistik statistik={statistik} /> <Statistik statistik={statistik} />
+4 -4
View File
@@ -3,7 +3,7 @@ import Box from '@mui/material/Box'
import {jwennTeksEpiSlug} from '../../../lib/oki-api' import {jwennTeksEpiSlug} from '../../../lib/oki-api'
import AnTeks from '../../../components/teks/an-teks' 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 {formatKuveti} from '../../../lib/kuveti'
import Footer from '../../../components/footer' import Footer from '../../../components/footer'
@@ -21,14 +21,14 @@ async function jwennAnTeks(slug) {
} }
export async function generateMetadata(props) { export async function generateMetadata(props) {
const params = await props.params; const params = await props.params
const {slug} = params const {slug} = params
const anTeks = await jwennAnTeks(slug) const anTeks = await jwennAnTeks(slug)
const awtis = anTeks?.artistes?.length === 1 ? anTeks?.artistes[0].alias : getAlias(anTeks.artistes, anTeks.prioriteArtistes) const awtis = anTeks?.artistes?.length === 1 ? anTeks?.artistes[0].alias : getAlias(anTeks.artistes, anTeks.prioriteArtistes)
const title = `PAWÒL-NU | ${awtis} - ${anTeks.titre}` 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 url = `${siteUrl}/paroles/${slug}`
const {couverture} = anTeks const {couverture} = anTeks
@@ -67,7 +67,7 @@ export async function generateMetadata(props) {
} }
export default async function AnPawolPaj(props) { export default async function AnPawolPaj(props) {
const params = await props.params; const params = await props.params
const {slug} = params const {slug} = params
const anTeks = await jwennAnTeks(slug) const anTeks = await jwennAnTeks(slug)
+11 -19
View File
@@ -1,7 +1,7 @@
import {Suspense} from 'react' import {Suspense} from 'react'
import {notFound} from 'next/navigation' import PropTypes from 'prop-types'
import {jwennTeks} from '../../lib/oki-api' import TeksDrawerLoader from '../../components/teks/teks-drawer-loader'
import TeksDrawer from '../../components/teks/teks-drawer' import {CurrentTrackProvider} from '../../components/teks/current-track-context'
import Loading from './loading' import Loading from './loading'
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'PAWÒL-NU. Paroles et traductions.' const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'PAWÒL-NU. Paroles et traductions.'
@@ -39,27 +39,19 @@ export const metadata = {
} }
} }
async function jwennDotDone() { export default function PawolLayout({children}) {
const teks = await jwennTeks()
if (!teks) {
notFound()
}
return teks
}
export default async function PawolLayout({children}) {
const teks = await jwennDotDone()
return ( return (
<> <CurrentTrackProvider>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<TeksDrawer paroles={teks} /> <TeksDrawerLoader />
</Suspense> </Suspense>
<section> <section>
{children} {children}
</section> </section>
</> </CurrentTrackProvider>
) )
} }
PawolLayout.propTypes = {
children: PropTypes.node.isRequired
}
+1 -1
View File
@@ -1,5 +1,5 @@
export default function robots() { export default function robots() {
const isProduction = process.env.NEXT_PUBLIC_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production'
if (!isProduction) { if (!isProduction) {
return { return {
-4
View File
@@ -31,10 +31,6 @@ export default async function sitemap() {
{ {
url: `${url}/awtis`, url: `${url}/awtis`,
priority: 0.6 priority: 0.6
},
{
url: `${url}/pwopose`,
priority: 0.5
} }
] ]
+3 -3
View File
@@ -10,7 +10,7 @@ import Chip from '@mui/material/Chip'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import {getAlias} from '../../lib/utils/format' import {getAlias} from '../../lib/utils/get-alias'
import {formatKuveti} from '../../lib/kuveti' import {formatKuveti} from '../../lib/kuveti'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337' 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}}}> <Box sx={{position: 'relative', width: '100%', aspectRatio: {xs: '1 / 1', sm: '16 / 9'}, maxHeight: {sm: 420}}}>
{fmt?.url ? ( {fmt?.url ? (
<Image <Image
src={`${IMAGE_URL}${fmt.url}`}
alt={titre}
fill fill
priority priority
src={`${IMAGE_URL}${fmt.url}`}
alt={titre}
placeholder='blur' placeholder='blur'
blurDataURL={BLUR_DATA_URL} blurDataURL={BLUR_DATA_URL}
sizes='(max-width: 600px) 100vw, 800px' 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 Container from '@mui/material/Container'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box' 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 ( return (
<Box sx={{flexGrow: 1, marginBottom: 3, marginTop: 7}}> <Box sx={{flexGrow: 1, marginBottom: 3, marginTop: 7}}>
<Container align='center'> <Container align='center'>
@@ -25,7 +20,3 @@ export default function Akey({logo}) {
</Box> </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 hasStreaming = isExclusiveArtist && titrePhare?.streamAudio?.length > 0
const coverUrl = titrePhare?.couverture 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 : null
const photoUrl = photo?.url const photoUrl = photo?.url
? `${IMAGE_URL}${photo.formats?.small?.url || photo.formats?.thumbnail?.url || photo.url}` ? `${IMAGE_URL}${formatKuveti(photo, 'small')?.url}`
: null : null
return ( return (
@@ -72,7 +72,8 @@ export default function AwtisDetay({anAwtis}) {
position: 'relative', position: 'relative',
flexShrink: 0, flexShrink: 0,
mb: 2, mb: 2,
}}> }}
>
{photoUrl ? ( {photoUrl ? (
<Image <Image
src={photoUrl} src={photoUrl}
@@ -94,8 +95,8 @@ export default function AwtisDetay({anAwtis}) {
{rezoSosyal?.length > 0 && ( {rezoSosyal?.length > 0 && (
<Box sx={{display: 'flex', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center', mb: 1.5}}> <Box sx={{display: 'flex', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center', mb: 1.5}}>
{rezoSosyal.map((rezo, i) => ( {rezoSosyal.map(rezo => (
<SocialButton key={i} rezo={rezo} /> <SocialButton key={rezo.url} rezo={rezo} />
))} ))}
</Box> </Box>
)} )}
@@ -146,30 +147,34 @@ export default function AwtisDetay({anAwtis}) {
<Grid container> <Grid container>
{coverUrl && ( {coverUrl && (
<Grid size={{xs: 12, sm: 4}}> <Grid size={{xs: 12, sm: 4}}>
<Link href={`/paroles/${titrePhare.slug}`} style={{display: 'block', height: '100%'}}>
<Box sx={{position: 'relative', minHeight: 130, height: '100%'}}> <Box sx={{position: 'relative', minHeight: 130, height: '100%'}}>
<Image <Image
fill
src={coverUrl} src={coverUrl}
alt={titrePhare.titre} alt={titrePhare.titre}
fill
placeholder='blur' placeholder='blur'
blurDataURL={BLUR_DATA_URL} blurDataURL={BLUR_DATA_URL}
sizes='200px' sizes='200px'
style={{objectFit: 'cover'}} style={{objectFit: 'cover'}}
/> />
</Box> </Box>
</Link>
</Grid> </Grid>
)} )}
<Grid size={{xs: 12, sm: coverUrl ? 8 : 12}}> <Grid size={{xs: 12, sm: coverUrl ? 8 : 12}}>
<CardContent> <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} {titrePhare.titre}
</Link>
</Typography> </Typography>
<Typography variant='caption' sx={{color: 'text.secondary', display: 'block', mb: 1}}> <Typography variant='caption' sx={{color: 'text.secondary', display: 'block', mb: 1}}>
Écouter sur Écouter sur
</Typography> </Typography>
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1}}> <Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1}}>
{titrePhare.streamAudio.map((lyen, i) => ( {titrePhare.streamAudio.map(lyen => (
<StreamButton key={i} lyen={lyen} /> <StreamButton key={lyen.url} lyen={lyen} />
))} ))}
</Box> </Box>
</CardContent> </CardContent>
@@ -189,7 +194,7 @@ export default function AwtisDetay({anAwtis}) {
<AccordionDetails sx={{paddingInline: 0}}> <AccordionDetails sx={{paddingInline: 0}}>
{sortedTeks.map(anPawol => { {sortedTeks.map(anPawol => {
const {couverture} = anPawol const {couverture} = anPawol
const kuvetiFormat = couverture?.formats?.thumbnail || formatKuveti(couverture) const kuvetiFormat = formatKuveti(couverture, 'thumbnail')
return ( return (
<Box key={anPawol.id} sx={{paddingBlock: 0.5}}> <Box key={anPawol.id} sx={{paddingBlock: 0.5}}>
<MizikLyen anPawol={anPawol} kuveti={kuvetiFormat} /> <MizikLyen anPawol={anPawol} kuveti={kuvetiFormat} />
@@ -198,7 +203,7 @@ export default function AwtisDetay({anAwtis}) {
})} })}
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
) : paroles.length === 0 ? ( ) : (paroles.length === 0 ? (
<Typography textAlign='center' variant='body1' color='text.secondary'> <Typography textAlign='center' variant='body1' color='text.secondary'>
Aucune parole pour le moment Aucune parole pour le moment
</Typography> </Typography>
@@ -206,10 +211,10 @@ export default function AwtisDetay({anAwtis}) {
<Box> <Box>
<Typography gutterBottom variant='body1'><strong>Parole</strong></Typography> <Typography gutterBottom variant='body1'><strong>Parole</strong></Typography>
<Paper sx={{paddingBlock: 2}}> <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> </Paper>
</Box> </Box>
)} ))}
</Grid> </Grid>
</Grid> </Grid>
+2 -2
View File
@@ -15,10 +15,10 @@ import Chip from '@mui/material/Chip'
import {styled} from '@mui/material/styles' import {styled} from '@mui/material/styles'
import VerifiedIcon from '@mui/icons-material/Verified' import VerifiedIcon from '@mui/icons-material/Verified'
import {formatKuveti} from '../../lib/kuveti'
import AwtisBiyografi from './awtis-biyografi' import AwtisBiyografi from './awtis-biyografi'
const PREFIX = 'awtis-kat' 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 IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
const EXCLUSIVE_LABEL = process.env.NEXT_PUBLIC_EXCLUSIVE_ARTIST_LABEL || 'OKI Exclusif' const EXCLUSIVE_LABEL = process.env.NEXT_PUBLIC_EXCLUSIVE_ARTIST_LABEL || 'OKI Exclusif'
@@ -82,7 +82,7 @@ export default function AwtisKat({artiste}) {
className={classes.media} className={classes.media}
component='img' component='img'
alt={alias} 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' loading='lazy'
title={alias} title={alias}
/> />
+12 -15
View File
@@ -5,10 +5,9 @@ import {useRouter} from 'next/navigation'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Autocomplete from '@mui/material/Autocomplete' import Autocomplete from '@mui/material/Autocomplete'
import Avatar from '@mui/material/Avatar' import Avatar from '@mui/material/Avatar'
import CircularProgress from '@mui/material/CircularProgress'
import Container from '@mui/material/Container' 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' const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -27,7 +26,7 @@ export default function ChecheAwtis() {
(async () => { (async () => {
try { try {
const {data} = await jwennToutAwtis() const {data} = await fetch('/api/awtis').then(response => response.json())
const filteredData = data.map(artiste => { const filteredData = data.map(artiste => {
const firstLetter = artiste.alias[0].toUpperCase() const firstLetter = artiste.alias[0].toUpperCase()
@@ -49,11 +48,9 @@ export default function ChecheAwtis() {
} }
}, [loading]) }, [loading])
const sortedOptions = useMemo(() => { const sortedOptions = useMemo(() => [...options].sort((a, b) =>
return [...options].sort((a, b) =>
-b.firstLetter.localeCompare(a.firstLetter) -b.firstLetter.localeCompare(a.firstLetter)
); ), [options])
}, [options]);
return ( return (
<Container <Container
@@ -69,24 +66,24 @@ export default function ChecheAwtis() {
noOptionsText='Aucun résultat' noOptionsText='Aucun résultat'
id='cheche-awtis' id='cheche-awtis'
options={sortedOptions} options={sortedOptions}
groupBy={(option) => option?.firstLetter} groupBy={option => option?.firstLetter}
getOptionLabel={(option) => option?.alias} getOptionLabel={option => option?.alias}
renderOption={(props, option) => { renderOption={(optionProps, option) => {
const {key, ...rest} = props; const {key, ...rest} = optionProps
return ( return (
<li key={key} {...rest}> <li key={key} {...rest}>
<Avatar <Avatar
style={{marginRight: 8}} style={{marginRight: 8}}
alt={option?.alias} alt={option?.alias}
src={`${IMAGE_URL}${option?.photo?.formats?.thumbnail?.url || option?.photo?.url || ''}`} src={`${IMAGE_URL}${formatKuveti(option?.photo, 'thumbnail')?.url || ''}`}
/> />
{option?.alias} {option?.alias}
</li> </li>
); )
}} }}
sx={{width: 300}} sx={{width: 300}}
renderInput={(params) => ( renderInput={params => (
<TextField <TextField
{...params} {...params}
label='Rechercher un artiste' label='Rechercher un artiste'
@@ -97,7 +94,7 @@ export default function ChecheAwtis() {
/> />
)} )}
onChange={(event, newValue) => { onChange={(event, newValue) => {
router.push(`/awtis/${newValue.slug}`); router.push(`/awtis/${newValue.slug}`)
}} }}
onOpen={() => setOpen(true)} onOpen={() => setOpen(true)}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function MizikLis({niAwtis, paroles, meteEsMobilOuve}) {
itemContent={index => { itemContent={index => {
const anPawol = pawol[index] const anPawol = pawol[index]
const {couverture} = anPawol const {couverture} = anPawol
const kuvetiFormat = couverture?.formats?.thumbnail || formatKuveti(couverture) const kuvetiFormat = formatKuveti(couverture, 'thumbnail')
return ( return (
<MizikLyen niAwtis={niAwtis} anPawol={anPawol} kuveti={kuvetiFormat} slug={params.slug} meteEsMobilOuve={meteEsMobilOuve} /> <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 ExplicitIcon from '@mui/icons-material/Explicit'
import {esBrandNew} from '../../lib/date' 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' 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 ( return (
<Link <Link
passHref passHref
prefetch={false}
href={`/paroles/${anPawol.slug}#${anPawol.slug}`} href={`/paroles/${anPawol.slug}#${anPawol.slug}`}
style={{textDecoration: 'none', width: '100%', display: 'flex', alignItems: 'center'}} style={{textDecoration: 'none', width: '100%', display: 'flex', alignItems: 'center'}}
onClick={() => meteEsMobilOuve(false)} onClick={() => meteEsMobilOuve(false)}
+9 -1
View File
@@ -1,5 +1,6 @@
'use client' 'use client'
import PropTypes from 'prop-types'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
@@ -59,15 +60,22 @@ export function SocialButton({rezo}) {
return ( return (
<Tooltip title={rezo.url}> <Tooltip title={rezo.url}>
<Chip <Chip
clickable
label={config.label} label={config.label}
component='a' component='a'
href={rezo.url} href={rezo.url}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
clickable
size='small' size='small'
sx={{bgcolor: config.bg, color: config.color, fontWeight: 600}} sx={{bgcolor: config.bg, color: config.color, fontWeight: 600}}
/> />
</Tooltip> </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> <DialogContent dividers>
{sourceOriginale && ( {sourceOriginale && (
<Box sx={{mb: 3}}> <Box sx={{mb: 3}}>
<Typography variant='overline' color='text.secondary' display='block' gutterBottom> <Typography gutterBottom variant='overline' color='text.secondary' display='block'>
Basé sur Basé sur
</Typography> </Typography>
<Link href={`/paroles/${sourceOriginale.slug}`} underline='hover' color='inherit'> <Link href={`/paroles/${sourceOriginale.slug}`} underline='hover' color='inherit'>
@@ -76,7 +76,7 @@ export default function LicenseModal({license, sourceOriginale, remixes}) {
)} )}
{remixes && ( {remixes && (
<Box sx={{mb: 3}}> <Box sx={{mb: 3}}>
<Typography variant='overline' color='text.secondary' display='block' gutterBottom> <Typography gutterBottom variant='overline' color='text.secondary' display='block'>
Déclinaisons Déclinaisons
</Typography> </Typography>
{remixes.map(remix => ( {remixes.map(remix => (
+1 -1
View File
@@ -530,7 +530,7 @@ export default function Cgu() {
10. Propriété intellectuelle et licence 10. Propriété intellectuelle et licence
</Typography> </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 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, (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 : 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(() => { useEffect(() => {
const audioFiles = files.filter(f => f.mime.startsWith('audio')) const audioFiles = files.filter(f => f.mime.startsWith('audio'))
if (audioFiles.length === 0) return if (audioFiles.length === 0) {
return
}
let cancelled = false let cancelled = false
async function fetchAllMeta() { 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 = {} const results = {}
await Promise.all( await Promise.all(
audioFiles.map(async file => { audioFiles.map(async file => {
@@ -98,7 +100,9 @@ export default function FilesList({files}) {
} }
}) })
) )
if (!cancelled) setAudioMeta(results) if (!cancelled) {
setAudioMeta(results)
}
} }
fetchAllMeta() fetchAllMeta()
@@ -121,7 +125,8 @@ export default function FilesList({files}) {
fontWeight: 'bold', fontWeight: 'bold',
letterSpacing: '0.05rem', letterSpacing: '0.05rem',
textTransform: 'uppercase', textTransform: 'uppercase',
}}>{isHaute ? 'Haute' : 'Faible'}</Typography> }}
>{isHaute ? 'Haute' : 'Faible'}</Typography>
) )
} }
@@ -150,12 +155,16 @@ export default function FilesList({files}) {
} }
useEffect(() => () => { 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) => { const handleClick = async (e, url, fileName, fileId) => {
e.stopPropagation() e.stopPropagation()
if (fileId in downloading) return if (fileId in downloading) {
return
}
const controller = new AbortController() const controller = new AbortController()
controllersRef.current[fileId] = controller controllersRef.current[fileId] = controller
@@ -163,14 +172,18 @@ export default function FilesList({files}) {
setDownloading(prev => ({...prev, [fileId]: 0})) setDownloading(prev => ({...prev, [fileId]: 0}))
try { try {
const response = await fetch(url, {signal: controller.signal}) 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 reader = response.body.getReader()
const chunks = [] const chunks = []
let received = 0 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() const {done, value} = await reader.read()
if (done) break if (done) {
break
}
chunks.push(value) chunks.push(value)
received += value.length received += value.length
if (contentLength) { if (contentLength) {
@@ -186,7 +199,9 @@ export default function FilesList({files}) {
a.click() a.click()
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} catch (error) { } catch (error) {
if (error.name !== 'AbortError') throw error if (error.name !== 'AbortError') {
throw error
}
} finally { } finally {
delete controllersRef.current[fileId] delete controllersRef.current[fileId]
setDownloading(prev => { setDownloading(prev => {
@@ -21,7 +21,7 @@ const PLATFORM_ICON = {
kDrive: CloudDownloadIcon, kDrive: CloudDownloadIcon,
} }
export default function StemsDialog({stems}) { export default function KitsDialog({kits}) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const theme = useTheme() const theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md')) const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
@@ -37,26 +37,26 @@ export default function StemsDialog({stems}) {
return ( return (
<> <>
<Button endIcon={<CloudDownloadIcon />} sx={{marginBottom: 2}} variant='outlined' onClick={handleClickOpen}> <Button endIcon={<CloudDownloadIcon />} sx={{marginBottom: 2}} variant='outlined' onClick={handleClickOpen}>
Stems Kits
</Button> </Button>
<Dialog <Dialog
fullScreen={fullScreen} fullScreen={fullScreen}
open={open} open={open}
aria-labelledby='StemsDialog' aria-labelledby='KitsDialog'
onClose={handleClose} onClose={handleClose}
> >
<DialogTitle>Télécharger les stems</DialogTitle> <DialogTitle>Télécharger un kit</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<List> <List>
{stems.map((stem, index) => { {kits.map(kit => {
const PlatformIcon = PLATFORM_ICON[stem.plateforme] ?? CloudDownloadIcon const PlatformIcon = PLATFORM_ICON[kit.plateforme] ?? CloudDownloadIcon
return ( return (
<ListItem key={index} disablePadding> <ListItem key={kit.url} disablePadding>
<ListItemButton component='a' href={stem.url} target='_blank' rel='noopener noreferrer'> <ListItemButton component='a' href={kit.url} target='_blank' rel='noopener noreferrer'>
<ListItemIcon> <ListItemIcon>
<PlatformIcon /> <PlatformIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={stem.plateforme} /> <ListItemText primary={kit.type} secondary={kit.plateforme} />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
) )
@@ -73,6 +73,6 @@ export default function StemsDialog({stems}) {
) )
} }
StemsDialog.propTypes = { KitsDialog.propTypes = {
stems: PropTypes.array.isRequired 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' 'use client'
import {useState} from 'react'
import PropTypes from 'prop-types'
import {useRouter, usePathname} from 'next/navigation' import {useRouter, usePathname} from 'next/navigation'
import {styled} from '@mui/material/styles' import {styled} from '@mui/material/styles'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
import Tabs, {tabsClasses} from '@mui/material/Tabs' import Tabs, {tabsClasses} from '@mui/material/Tabs'
import Tab from '@mui/material/Tab' import Tab from '@mui/material/Tab'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import {useMediaQuery} from '@mui/material' import {useMediaQuery} from '@mui/material'
import MusicNoteIcon from '@mui/icons-material/MusicNote' import MusicNoteIcon from '@mui/icons-material/MusicNote'
@@ -49,32 +45,6 @@ const tabPath = {
awtis: 3, 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) { function a11yProps(index) {
return { return {
id: `scrollable-force-tab-${index}`, id: `scrollable-force-tab-${index}`,
@@ -89,11 +59,8 @@ export default function Navigasyon() {
const selectedTab = tabPath[selectedPath] const selectedTab = tabPath[selectedPath]
const isMobile = useMediaQuery('(max-width:422px)') const isMobile = useMediaQuery('(max-width:422px)')
const [value, setValue] = useState(0)
const handleChange = (event, newValue) => { const handleChange = (event, newValue) => {
setValue(newValue)
if (newValue !== selectedTab) { if (newValue !== selectedTab) {
router.push(tabRouteHref[newValue]) router.push(tabRouteHref[newValue])
} }
@@ -103,7 +70,8 @@ export default function Navigasyon() {
<Appdiv> <Appdiv>
<div className={classes.root}> <div className={classes.root}>
<AppBar style={{zIndex: 1, boxShadow: 'none'}} position='fixed' color='default'> <AppBar style={{zIndex: 1, boxShadow: 'none'}} position='fixed' color='default'>
{selectedTab !== undefined && <Tabs {selectedTab !== undefined && (
<Tabs
allowScrollButtonsMobile allowScrollButtonsMobile
scrollButtons scrollButtons
centered={Boolean(!isMobile)} centered={Boolean(!isMobile)}
@@ -124,12 +92,8 @@ export default function Navigasyon() {
<Tab icon={<MusicNoteIcon />} aria-label='Paroles' {...a11yProps(2)} /> <Tab icon={<MusicNoteIcon />} aria-label='Paroles' {...a11yProps(2)} />
<Tab icon={<RecordVoiceOverIcon />} aria-label='Artistes' {...a11yProps(3)} /> <Tab icon={<RecordVoiceOverIcon />} aria-label='Artistes' {...a11yProps(3)} />
</Tabs> </Tabs>
} )}
</AppBar> </AppBar>
<TabPanel value={value} index={0} />
<TabPanel value={value} index={1} />
<TabPanel value={value} index={2} />
<TabPanel value={value} index={3} />
</div> </div>
</Appdiv> </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' import CGUDialog from '../cgu/cgu-dialog'
const CGU_PDF_LINK = process.env.NEXT_PUBLIC_CGU_DOWNLOAD_LINK
export default function Presantasyon() { export default function Presantasyon() {
const [open, setOpen] = useState(false) 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' 'use client'
import PropTypes from 'prop-types'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import OpenInNewIcon from '@mui/icons-material/OpenInNew' import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import {Spotify, Applemusic, Deezer, Tidal, Youtubemusic, Amazon, Soundcloud} from '@icons-pack/react-simple-icons' import {Spotify, Applemusic, Deezer, Tidal, Youtubemusic, Amazon, Soundcloud} from '@icons-pack/react-simple-icons'
@@ -39,3 +40,10 @@ export function StreamButton({lyen}) {
</Button> </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,55 @@
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: 'Mastodon'})
expect(lien).toHaveAttribute('href', 'https://bokante.o-k-i.net/@pawol_nu/112233')
})
it('redirige vers authorize_interaction avec le domaine extrait d\'un identifiant complet', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre via mastodon/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'
)
})
it('accepte aussi juste un nom d\'instance sans @', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre via mastodon/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(
expect.stringContaining('https://mastodon.social/authorize_interaction'),
'_blank',
'noopener,noreferrer'
)
})
})
-1
View File
@@ -8,7 +8,6 @@ export default function AnTeks({parole, paroleId}) {
<Teks <Teks
parole={parole} parole={parole}
paroleId={paroleId} 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 React, {forwardRef, useState} from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import {format} from 'date-fns'
import {fr} from 'date-fns/locale'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
@@ -50,7 +47,7 @@ export default function DiferansDialog({difference}) {
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}> <List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
{difference.map(({id, admin_user, date, jsonDiff}) => { {difference.map(({id, admin_user, date, jsonDiff}) => {
const {firstname} = admin_user 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 ( return (
<React.Fragment key={id}> <React.Fragment key={id}>
+14 -6
View File
@@ -2,7 +2,7 @@
import {Box, IconButton} from '@mui/material' import {Box, IconButton} from '@mui/material'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import {useRouter} from 'next/navigation' import {usePathname} from 'next/navigation'
import { import {
Tidal, Tidal,
@@ -69,10 +69,12 @@ function RannIframe({plateforme, url, titre, isMobile}) {
{plateforme === 'Tidal' && ( {plateforme === 'Tidal' && (
<iframe <iframe
src={src} src={src}
title={titre}
allowFullScreen='allowfullscreen' allowFullScreen='allowfullscreen'
frameBorder='0' frameBorder='0'
width={`${isMobile ? '100%' : '50%'}`} width={`${isMobile ? '100%' : '50%'}`}
height='96px' height='96px'
sandbox='allow-same-origin allow-scripts allow-popups'
/> />
)} )}
@@ -90,38 +92,44 @@ function RannIframe({plateforme, url, titre, isMobile}) {
{plateforme === 'Deezer' && ( {plateforme === 'Deezer' && (
<iframe <iframe
title='deezer-widget' title={titre || 'deezer-widget'}
frameBorder='0' frameBorder='0'
src={src} src={src}
allow='encrypted-media; clipboard-write' allow='encrypted-media; clipboard-write'
allowtransparency='true' allowtransparency='true'
sandbox='allow-same-origin allow-scripts allow-popups'
/> />
)} )}
{plateforme === 'Spotify' && ( {plateforme === 'Spotify' && (
<iframe <iframe
src={src} src={src}
title={titre}
width={`${isMobile ? '100%' : '50%'}`} width={`${isMobile ? '100%' : '50%'}`}
height='80' height='80'
frameBorder='0' frameBorder='0'
allowtransparency='true' allowtransparency='true'
allow='encrypted-media' allow='encrypted-media'
sandbox='allow-same-origin allow-scripts allow-popups'
/> />
)} )}
{plateforme === 'Soundcloud' && ( {plateforme === 'Soundcloud' && (
<iframe <iframe
src={src} src={src}
title={titre}
width={`${isMobile ? '100%' : '50%'}`} width={`${isMobile ? '100%' : '50%'}`}
height='166' height='166'
scrolling='no' scrolling='no'
frameBorder='no' frameBorder='no'
sandbox='allow-same-origin allow-scripts allow-popups'
/> />
)} )}
{plateforme === 'Applemusic' && ( {plateforme === 'Applemusic' && (
<iframe <iframe
src={src} src={src}
title={titre}
width={`${isMobile ? '100%' : '50%'}`} width={`${isMobile ? '100%' : '50%'}`}
height='175' height='175'
frameBorder='0' frameBorder='0'
@@ -136,7 +144,7 @@ function RannIframe({plateforme, url, titre, isMobile}) {
function EntegreMizik({parole, isMobile}) { function EntegreMizik({parole, isMobile}) {
const [chwaMizik, meteChwaMizik] = useState(null) const [chwaMizik, meteChwaMizik] = useState(null)
const router = useRouter() const pathname = usePathname()
const {streamAudio, slug, okiMizikID, gadeEmbed, titre} = parole const {streamAudio, slug, okiMizikID, gadeEmbed, titre} = parole
const filteredKouteyAchtey = streamAudio.filter(({plateforme}) => plateforme === 'Tidal' || plateforme === 'Spotify' || plateforme === 'Deezer' || plateforme === 'Soundcloud' || plateforme === 'Applemusic') const filteredKouteyAchtey = streamAudio.filter(({plateforme}) => plateforme === 'Tidal' || plateforme === 'Spotify' || plateforme === 'Deezer' || plateforme === 'Soundcloud' || plateforme === 'Applemusic')
@@ -150,10 +158,10 @@ function EntegreMizik({parole, isMobile}) {
} }
useEffect(() => { useEffect(() => {
if (router.asPath === `/paroles/${slug}` || router.asPath === `/paroles/${slug}#${slug}`) { if (pathname === `/paroles/${slug}`) {
meteChwaMizik(null) meteChwaMizik(null)
} }
}, [router, slug]) }, [pathname, slug])
return ( return (
<Box align='center'> <Box align='center'>
@@ -162,7 +170,7 @@ function EntegreMizik({parole, isMobile}) {
key={id} key={id}
aria-label='player' aria-label='player'
size='large' size='large'
onClick={() => handleClick(plateforme, url)} onClick={() => handleClick(plateforme, url, titre)}
> >
{kouteyAchteyIcons[plateforme]} {kouteyAchteyIcons[plateforme]}
</IconButton> </IconButton>
+20 -14
View File
@@ -21,7 +21,7 @@ const pulse = keyframes`
` `
const SlideUp = forwardRef(function SlideUp(props, ref) { const SlideUp = forwardRef(function SlideUp(props, ref) {
return <Slide direction='up' ref={ref} {...props} /> return <Slide ref={ref} direction='up' {...props} />
}) })
function toEmbedUrl(url) { function toEmbedUrl(url) {
@@ -31,6 +31,7 @@ function toEmbedUrl(url) {
if (match) { if (match) {
return `${u.origin}/videos/embed/${match[1]}?title=0&warningTitle=0&peertubeLink=0&controlBar=1` return `${u.origin}/videos/embed/${match[1]}?title=0&warningTitle=0&peertubeLink=0&controlBar=1`
} }
return url return url
} catch { } catch {
return url return url
@@ -45,7 +46,9 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
const activeUrl = (!isMobile && desktopUrl) ? desktopUrl : url const activeUrl = (!isMobile && desktopUrl) ? desktopUrl : url
useEffect(() => { useEffect(() => {
if (!open) setLoaded(false) if (!open) {
setLoaded(false)
}
}, [open]) }, [open])
return ( return (
@@ -55,13 +58,13 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
color='primary' color='primary'
size={isMobile ? 'medium' : 'large'} size={isMobile ? 'medium' : 'large'}
aria-label='karaoké' aria-label='karaoké'
onClick={() => setOpen(true)}
sx={{ sx={{
position: 'fixed', position: 'fixed',
bottom: {xs: 24, sm: 32}, bottom: {xs: 24, sm: 32},
right: {xs: 24, sm: 32}, right: {xs: 24, sm: 32},
zIndex: 1050, zIndex: 1050,
}} }}
onClick={() => setOpen(true)}
> >
<MicIcon /> <MicIcon />
</Fab> </Fab>
@@ -69,7 +72,6 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
<Dialog <Dialog
open={open} open={open}
onClose={() => setOpen(false)}
slots={{transition: SlideUp}} slots={{transition: SlideUp}}
maxWidth={false} maxWidth={false}
sx={{ sx={{
@@ -102,6 +104,7 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
} }
} }
}} }}
onClose={() => setOpen(false)}
> >
{/* Header superposé en gradient */} {/* Header superposé en gradient */}
<Box sx={{ <Box sx={{
@@ -118,7 +121,8 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'space-between', justifyContent: 'space-between',
pointerEvents: 'none', pointerEvents: 'none',
}}> }}
>
<Box sx={{pointerEvents: 'none'}}> <Box sx={{pointerEvents: 'none'}}>
{titre && ( {titre && (
<Typography variant='subtitle1' sx={{color: '#fff', fontWeight: 700, lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.5)'}}> <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> </Box>
<IconButton <IconButton
size='small' size='small'
onClick={() => setOpen(false)}
aria-label='fermer' aria-label='fermer'
sx={{ sx={{
pointerEvents: 'auto', pointerEvents: 'auto',
@@ -143,6 +146,7 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
border: '1px solid rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.12)',
'&:hover': {bgcolor: 'rgba(255,255,255,0.25)'}, '&:hover': {bgcolor: 'rgba(255,255,255,0.25)'},
}} }}
onClick={() => setOpen(false)}
> >
<CloseIcon fontSize='small' /> <CloseIcon fontSize='small' />
</IconButton> </IconButton>
@@ -166,7 +170,8 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 1.5, gap: 1.5,
}}> }}
>
<MicIcon sx={{fontSize: 44, color: 'rgba(255,255,255,0.55)', animation: `${pulse} 1.6s ease-in-out infinite`}} /> <MicIcon sx={{fontSize: 44, color: 'rgba(255,255,255,0.55)', animation: `${pulse} 1.6s ease-in-out infinite`}} />
<Typography <Typography
variant='caption' variant='caption'
@@ -187,27 +192,28 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
height: '100%', height: '100%',
aspectRatio: '16 / 9', aspectRatio: '16 / 9',
}}> }}
>
<iframe <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 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' allow='autoplay; fullscreen'
sandbox='allow-same-origin allow-scripts allow-popups allow-forms' sandbox='allow-same-origin allow-scripts allow-popups allow-forms'
title='Karaoké' title='Karaoké'
onLoad={() => setLoaded(true)}
/> />
</Box> </Box>
) : ( ) : (
/* Vidéo 16:9 desktop : player remplit exactement le dialog paysage */ /* Vidéo 16:9 desktop : player remplit exactement le dialog paysage */
<iframe <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 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' allow='autoplay; fullscreen'
sandbox='allow-same-origin allow-scripts allow-popups allow-forms' sandbox='allow-same-origin allow-scripts allow-popups allow-forms'
title='Karaoké' title='Karaoké'
onLoad={() => setLoaded(true)}
/> />
)} )}
</Box> </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 {grey} from '@mui/material/colors'
import {Link} from '@mui/material' 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' 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) { function parseLrc(text) {
const lines = [] const lines = []
for (const raw of text.split('\n')) { 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) { 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}) { 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 intervalRef = useRef()
const isReady = useRef(false)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const theme = useTheme() const theme = useTheme()
const {mode} = useColorScheme() const {mode} = useColorScheme()
@@ -97,25 +108,9 @@ export default function Lekte({audio, url, parole}) {
clearInterval(intervalRef.current) clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
setPosition(Math.round(audioRef.current.currentTime)) 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(() => useEffect(() =>
() => { () => {
audioRef.current.pause() audioRef.current.pause()
@@ -125,7 +120,7 @@ export default function Lekte({audio, url, parole}) {
useEffect(() => { useEffect(() => {
if (isPlaying) { if (isPlaying) {
audioRef.current.play() audioRef.current.play().catch(() => {})
startTimer() startTimer()
} else { } else {
clearInterval(intervalRef.current) clearInterval(intervalRef.current)
@@ -135,7 +130,7 @@ export default function Lekte({audio, url, parole}) {
useEffect(() => { useEffect(() => {
audioRef.current.pause() audioRef.current.pause()
audioRef.current = new Audio(audio) audioRef.current = createAudio(audio)
setIsPlaying(false) setIsPlaying(false)
setVolume(100) setVolume(100)
setPosition(0) setPosition(0)
@@ -151,6 +146,7 @@ export default function Lekte({audio, url, parole}) {
setPosition(0) setPosition(0)
el.currentTime = 0 el.currentTime = 0
} }
el.addEventListener('loadedmetadata', onLoaded) el.addEventListener('loadedmetadata', onLoaded)
el.addEventListener('ended', onEnded) el.addEventListener('ended', onEnded)
return () => { return () => {
@@ -160,7 +156,10 @@ export default function Lekte({audio, url, parole}) {
}, [audio]) }, [audio])
useEffect(() => { useEffect(() => {
if (!parole?.pawol?.url) return if (!parole?.pawol?.url) {
return
}
fetch(new URL(parole.pawol.url, IMAGE_URL).toString()) fetch(new URL(parole.pawol.url, IMAGE_URL).toString())
.then(r => r.text()) .then(r => r.text())
.then(text => setLrcLines(parseLrc(text))) .then(text => setLrcLines(parseLrc(text)))
@@ -168,7 +167,7 @@ export default function Lekte({audio, url, parole}) {
}, [parole?.pawol?.url]) }, [parole?.pawol?.url])
const activeIndex = lrcLines const activeIndex = lrcLines
? lrcLines.reduce((last, line, i) => line.time <= position ? i : last, -1) ? lrcLines.findLastIndex(line => line.time <= position)
: -1 : -1
const handleChangePosition = value => { const handleChangePosition = value => {
@@ -181,9 +180,10 @@ export default function Lekte({audio, url, parole}) {
audioRef.current.volume = value / 100 audioRef.current.volume = value / 100
} }
const imagePath = parole?.couverture?.formats?.thumbnail?.url const couvertureFormat = formatKuveti(parole?.couverture, 'thumbnail')
const width = parole?.couverture?.formats?.thumbnail?.width || 192 const imagePath = couvertureFormat?.url
const height = parole?.couverture?.formats?.thumbnail?.height || 192 const width = couvertureFormat?.width || 192
const height = couvertureFormat?.height || 192
const imageSrc = imagePath const imageSrc = imagePath
? new URL(imagePath, IMAGE_URL).toString() ? new URL(imagePath, IMAGE_URL).toString()
@@ -322,7 +322,10 @@ export default function Lekte({audio, url, parole}) {
{[-1, 0, 1].map(offset => { {[-1, 0, 1].map(offset => {
const idx = activeIndex + offset const idx = activeIndex + offset
const line = lrcLines[idx] 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 ( return (
<Typography <Typography
key={offset} key={offset}
+1 -3
View File
@@ -3,8 +3,6 @@ import {Box, CircularProgress} from '@mui/material'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com' 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( const DinamikLekte = dynamic(
() => import('./lekte'), // eslint-disable-line node/no-unsupported-features/es-syntax () => import('./lekte'), // eslint-disable-line node/no-unsupported-features/es-syntax
@@ -12,7 +10,7 @@ const DinamikLekte = dynamic(
) )
export default function OkiMizik({id, parole}) { 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}` const detailsUrl = `${MIZIK_URL}/library/tracks/${id}`
return ( return (
+83
View File
@@ -0,0 +1,83 @@
'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 Link from 'next/link'
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'
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 [saisie, setSaisie] = useState('')
if (!bokanteStatusId) {
return null
}
const tootUrl = `${BOKANTE_URL}/@${BOKANTE_ACCOUNT}/${bokanteStatusId}`
const repondre = () => {
const instance = extraireInstance(saisie)
if (!instance) {
return
}
window.open(`https://${instance}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer')
setOuvert(false)
}
return (
<Box sx={{maxWidth: 700, margin: '1em auto 0', textAlign: 'center'}}>
<Typography variant='body2' sx={{mb: 1}}>
Rejoignez la discussion sur{' '}
<Link href={tootUrl} target='_blank' rel='noopener noreferrer'>Mastodon</Link>
</Typography>
<Button variant='outlined' size='small' onClick={() => setOuvert(true)}>
Répondre via Mastodon
</Button>
<Dialog open={ouvert} onClose={() => setOuvert(false)}>
<DialogTitle>Répondre depuis votre compte Mastodon</DialogTitle>
<DialogContent>
<Typography variant='body2' sx={{mb: 2}}>
Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre
instance, vous serez redirigé·e vers votre compte pour répondre.
</Typography>
<TextField
autoFocus
fullWidth
label='@vous@votre-instance'
value={saisie}
onChange={event => setSaisie(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 {useState, useEffect, forwardRef} from 'react'
import Link from 'next/link' import Link from 'next/link'
import {useParams} from 'next/navigation'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
@@ -19,6 +18,7 @@ import VweKouteAchte from './vwe-koute-achte'
import DrawerBar from './drawer-bar' import DrawerBar from './drawer-bar'
import Pataje from './pataje' import Pataje from './pataje'
import KaraokeModal from './karaoke-modal' import KaraokeModal from './karaoke-modal'
import {useCurrentTrack} from './current-track-context'
const drawerWidth = 240 const drawerWidth = 240
@@ -27,14 +27,12 @@ const Alert = forwardRef(function Alert(props, ref) {
}) })
export default function TeksDrawer({paroles}) { export default function TeksDrawer({paroles}) {
const {slug} = useParams() const [parole] = useCurrentTrack()
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const parole = paroles.find((parole) => parole.slug === slug)
useEffect(() => { useEffect(() => {
if (error || success) { if (error || success) {
setOpen(true) setOpen(true)
@@ -84,7 +82,7 @@ export default function TeksDrawer({paroles}) {
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
{parole && ( {parole && (
<Link passHref href='/paroles'> <Link passHref prefetch={false} href='/paroles'>
<IconButton aria-label='return' size='medium'> <IconButton aria-label='return' size='medium'>
<KeyboardBackspaceIcon style={{fontSize: '1.5em'}} /> <KeyboardBackspaceIcon style={{fontSize: '1.5em'}} />
</IconButton> </IconButton>
+15 -8
View File
@@ -3,8 +3,6 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import Image from 'next/image' import Image from 'next/image'
import {format} from 'date-fns'
import {fr} from 'date-fns/locale'
import Card from '@mui/material/Card' import Card from '@mui/material/Card'
import CardActionArea from '@mui/material/CardActionArea' import CardActionArea from '@mui/material/CardActionArea'
@@ -13,9 +11,11 @@ import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Grid from '@mui/material/Grid' import Grid from '@mui/material/Grid'
import ExplicitIcon from '@mui/icons-material/Explicit' import ExplicitIcon from '@mui/icons-material/Explicit'
import EventIcon from '@mui/icons-material/Event'
import {styled} from '@mui/material/styles' 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 PREFIX = 'teks-kat'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337' 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 router = useRouter()
const {titre, artistes, annee, couverture, createdAt, slug} = parole 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 aliases = getAlias(artistes, parole.prioriteArtistes)
const handleClick = slug => { const handleClick = slug => {
@@ -50,9 +50,9 @@ export default function TeksKat({parole}) {
<Box sx={{position: 'relative', height: 240}}> <Box sx={{position: 'relative', height: 240}}>
{couverture?.url ? ( {couverture?.url ? (
<Image <Image
src={`${IMAGE_URL}${couverture.formats?.thumbnail?.url || couverture.url}`}
alt={titre}
fill fill
src={`${IMAGE_URL}${formatKuveti(couverture, 'thumbnail')?.url}`}
alt={titre}
placeholder='blur' placeholder='blur'
blurDataURL={BLUR_DATA_URL} blurDataURL={BLUR_DATA_URL}
sizes='(max-width: 600px) 100vw, (max-width: 900px) 50vw, 33vw' sizes='(max-width: 600px) 100vw, (max-width: 900px) 50vw, 33vw'
@@ -102,8 +102,15 @@ export default function TeksKat({parole}) {
</> </>
)} )}
</Typography> </Typography>
<Typography align='center' style={{marginTop: '0.5em'}} variant='body1' color='textSecondary' component='p'> <Typography
Publiée le : {datPiblikasyon} 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> </Typography>
</Box> </Box>
</CardContent> </CardContent>
+126 -47
View File
@@ -3,8 +3,11 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Tabs from '@mui/material/Tabs' import Select from '@mui/material/Select'
import Tab from '@mui/material/Tab' 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 Typography from '@mui/material/Typography'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
import {useMediaQuery} from '@mui/material' import {useMediaQuery} from '@mui/material'
@@ -13,21 +16,26 @@ import slugify from 'slugify'
import {styled} from '@mui/material/styles' import {styled} from '@mui/material/styles'
import ExplicitIcon from '@mui/icons-material/Explicit' import ExplicitIcon from '@mui/icons-material/Explicit'
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew' import TranslateIcon from '@mui/icons-material/Translate'
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos' import StarIcon from '@mui/icons-material/Star'
import ArticleIcon from '@mui/icons-material/Article'
import Image from 'next/image' 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 {formatKuveti} from '../../lib/kuveti'
import LicenseModal from '../cc/license-modal' import LicenseModal from '../cc/license-modal'
import FilesDialog from '../files/files-dialog' import FilesDialog from '../files/files-dialog'
import StemsDialog from '../files/stems-dialog' import KitsDialog from '../files/kits-dialog'
import {StreamButton} from '../streaming-buttons' import {StreamButton} from '../streaming-buttons'
import EntegreMizik from './entegre-mizik' import EntegreMizik from './entegre-mizik'
import OkiMizik from './oki-mizik' import OkiMizik from './oki-mizik'
import DiferansDialog from './diferans-dialog' 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' const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -87,15 +95,34 @@ const LANG_NAMES = {
pt: 'Português', ja: '日本語', ko: '한국어', pt: 'Português', ja: '日本語', ko: '한국어',
} }
const CONTINENTS = ['Afrique', 'Asie', 'Europe']
const TRAD_FIELDS = [ const TRAD_FIELDS = [
{field: 'anglais', title: 'English'}, // Afrique — langues vedettes en premier
{field: 'francais', title: 'Français'}, {field: 'yoruba', title: 'Yorùbá', continent: 'Afrique', vedette: true},
{field: 'espagnol', title: 'Español'}, {field: 'lingala', title: 'Lingála', continent: 'Afrique', vedette: true},
{field: 'allemand', title: 'Deutsch'}, {field: 'wolof', title: 'Wolof', continent: 'Afrique', vedette: true},
{field: 'italien', title: 'Italiano'}, {field: 'swahili', title: 'Kiswahili', continent: 'Afrique', vedette: true},
{field: 'portugais', title: 'Português'}, {field: 'hausa', title: 'Hausa', continent: 'Afrique', vedette: true},
{field: 'japonais', title: '日本語'}, {field: 'arabe', title: 'العربية', continent: 'Afrique'},
{field: 'coreen', title: '한국어'}, {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 => { const langToArray = parole => {
@@ -105,7 +132,15 @@ const langToArray = parole => {
return TRAD_FIELDS return TRAD_FIELDS
.filter(({field}) => parole.traductions[field]) .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 BROWSER_LANG_TO_FIELD = {
yo: 'yoruba', ln: 'lingala', wo: 'wolof', sw: 'swahili', ha: 'hausa',
ar: 'arabe', om: 'oromo', ig: 'igbo', zu: 'zoulou', mg: 'malgache',
xh: 'xhosa', tn: 'tswana', ts: 'tsonga', st: 'sesotho',
ja: 'japonais', ko: 'coreen', en: 'anglais', fr: 'francais',
es: 'espagnol', de: 'allemand', it: 'italien', pt: 'portugais',
} }
const ExplicitTooltip = Tooltip const ExplicitTooltip = Tooltip
@@ -116,6 +151,7 @@ export default function Teks({parole}) {
const enhancedAliases = getAlias(parole.artistes, parole.prioriteArtistes, true) const enhancedAliases = getAlias(parole.artistes, parole.prioriteArtistes, true)
const coverFmt = formatKuveti(parole.couverture) const coverFmt = formatKuveti(parole.couverture)
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
const [, setCurrentTrack] = useCurrentTrack()
useEffect(() => { useEffect(() => {
const isBrowser = () => typeof window !== 'undefined' const isBrowser = () => typeof window !== 'undefined'
@@ -126,6 +162,25 @@ export default function Teks({parole}) {
window.scrollTo({top: 0, behavior: 'smooth'}) window.scrollTo({top: 0, behavior: 'smooth'})
}, []) }, [])
useEffect(() => {
setCurrentTrack(parole)
return () => setCurrentTrack(null)
}, [parole, setCurrentTrack])
useEffect(() => {
const browserLang = navigator.language?.split('-')[0]?.toLowerCase()
if (!browserLang || browserLang === parole.langueSource) {
return
}
const champ = BROWSER_LANG_TO_FIELD[browserLang]
const index = langArray.findIndex(lang => lang.field === champ)
if (index !== -1) {
setTab(index + 1)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- ne doit s'exécuter qu'au montage, pas à chaque changement d'onglet
}, [])
return ( return (
<Root className={classes.container}> <Root className={classes.container}>
<Box sx={{textAlign: 'center', marginTop: 12}}> <Box sx={{textAlign: 'center', marginTop: 12}}>
@@ -185,13 +240,13 @@ export default function Teks({parole}) {
<LicenseModal license={parole.creativeCommons.toLowerCase()} sourceOriginale={parole.sourceOriginale ?? null} remixes={parole.remixes?.length ? parole.remixes : null} /> <LicenseModal license={parole.creativeCommons.toLowerCase()} sourceOriginale={parole.sourceOriginale ?? null} remixes={parole.remixes?.length ? parole.remixes : null} />
</Box> </Box>
)} )}
{(parole?.files || (parole.creativeCommons && parole.stems?.length > 0)) && ( {(parole?.files || (parole.creativeCommons && parole.kits?.length > 0)) && (
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}> <Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}>
{parole?.files && ( {parole?.files && (
<FilesDialog files={parole.files} /> <FilesDialog files={parole.files} />
)} )}
{parole.creativeCommons && parole.stems?.length > 0 && ( {parole.creativeCommons && parole.kits?.length > 0 && (
<StemsDialog stems={parole.stems} /> <KitsDialog kits={parole.kits} />
)} )}
</Box> </Box>
)} )}
@@ -212,47 +267,69 @@ export default function Teks({parole}) {
Écouter sur Écouter sur
</Typography> </Typography>
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}> <Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}>
{parole.streamAudio.map((lyen, i) => ( {parole.streamAudio.map(lyen => (
<StreamButton key={i} lyen={lyen} /> <StreamButton key={lyen.url} lyen={lyen} />
))} ))}
</Box> </Box>
</Box> </Box>
)} )}
</Box> </Box>
<Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}> <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} value={tab}
onChange={(event, value) => setTab(value)} MenuProps={{PaperProps: {sx: {maxHeight: 420}}}}
variant='scrollable' renderValue={value => {
scrollButtons const label = value === 0 ? 'Transcription' : langArray[value - 1]?.title
allowScrollButtonsMobile const Icon = value === 0 ? ArticleIcon : TranslateIcon
centered={langArray.length === 0} return (
slots={{ <Box sx={{display: 'flex', alignItems: 'center', gap: 1, fontWeight: value === 0 ? 700 : 400}}>
StartScrollButtonIcon: ArrowBackIosNewIcon, <Icon fontSize='small' color='primary' />
EndScrollButtonIcon: ArrowForwardIosIcon {label}
</Box>
)
}} }}
onChange={event => setTab(event.target.value)}
>
<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={{ sx={{
borderBottom: 1, fontWeight: 700,
borderColor: 'divider', textTransform: 'uppercase',
mb: 2, letterSpacing: 1,
'& .MuiTabs-scrollButtons': {
width: 36,
height: 36,
borderRadius: '50%',
color: 'primary.main', color: 'primary.main',
backgroundColor: 'action.hover', lineHeight: '2.5em'
mx: 0.5,
'&.Mui-disabled': {
opacity: 0
}
}
}} }}
> >
<Tab label='Transcription' /> {continent}
{langArray.map(({title}) => ( </ListSubheader>,
<Tab key={title} label={title} /> ...items.map(({title, index, vedette}) => (
))} <MenuItem key={title} value={index + 1} sx={{display: 'flex', gap: 0.75}}>
</Tabs> {vedette && <StarIcon fontSize='inherit' color='primary' />}
{title}
</MenuItem>
))
]
})}
</Select>
</FormControl>
</Box>
{tab === 0 && ( {tab === 0 && (
<> <>
{parole.langueSource && parole.langueSource !== 'ka' && ( {parole.langueSource && parole.langueSource !== 'ka' && (
@@ -273,6 +350,8 @@ export default function Teks({parole}) {
) )
))} ))}
</Box> </Box>
<ReponsMastodon bokanteStatusId={parole.bokanteStatusId} />
<Komante commentaires={parole.commentaires} />
</Root> </Root>
) )
} }
+5
View File
@@ -1,7 +1,12 @@
'use client' 'use client'
import PropTypes from 'prop-types'
import NextTopLoader from 'nextjs-toploader' import NextTopLoader from 'nextjs-toploader'
export default function TopLoader({color}) { export default function TopLoader({color}) {
return <NextTopLoader color={color} /> return <NextTopLoader color={color} />
} }
TopLoader.propTypes = {
color: PropTypes.string.isRequired
}
+14 -14
View File
@@ -1,25 +1,25 @@
import { dirname } from "path"; import {dirname} from 'node:path'
import { fileURLToPath } from "url"; import {fileURLToPath} from 'node:url'
import { FlatCompat } from "@eslint/eslintrc"; import {FlatCompat} from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename); const __dirname = dirname(__filename)
const compat = new FlatCompat({ const compat = new FlatCompat({
baseDirectory: __dirname, baseDirectory: __dirname,
}); })
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals"), ...compat.extends('next/core-web-vitals'),
{ {
ignores: [ ignores: [
"node_modules/**", 'node_modules/**',
".next/**", '.next/**',
"out/**", 'out/**',
"build/**", 'build/**',
"next-env.d.ts", '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'], large: ['large', 'medium', 'small'],
medium: ['medium', 'small', 'large'], medium: ['medium', 'small', 'large'],
small: ['small', 'medium', 'large'], small: ['small', 'medium', 'large'],
thumbnail: ['thumbnail', 'small', 'medium', 'large'],
} }
export const formatKuveti = (kuveti, preferred = 'large') => { export const formatKuveti = (kuveti, preferred = 'large') => {
if (!kuveti) return null if (!kuveti) {
return null
}
const order = SIZE_ORDER[preferred] ?? SIZE_ORDER.large const order = SIZE_ORDER[preferred] ?? SIZE_ORDER.large
for (const size of order) { for (const size of order) {
if (kuveti.formats?.[size]) return kuveti.formats[size] if (kuveti.formats?.[size]) {
return kuveti.formats[size]
} }
}
return kuveti return kuveti
} }
+44 -47
View File
@@ -2,7 +2,7 @@ import qs from 'qs'
const OKI_API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337' 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 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 = { const headers = {
next: {revalidate: 60}, next: {revalidate: 60},
@@ -14,12 +14,18 @@ const headers = {
async function request(url, opts = {}) { async function request(url, opts = {}) {
const {...options} = opts const {...options} = opts
let res
try { try {
const res = await fetch(`${OKI_API}${url}`, options) res = await fetch(`${OKI_API}${url}`, options)
return res.json()
} catch { } catch {
throw new Error('Eshwe pu jwenn done-a') 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) { export async function jwennTeksEpiSlug(slug) {
@@ -32,7 +38,16 @@ export async function jwennTeksEpiSlug(slug) {
fields: ['username', 'firstname'] fields: ['username', 'firstname']
}, },
commentaires: { commentaires: {
fields: ['contenu', 'datePublication'], fields: [
'contenu',
'datePublication',
'origine',
'auteurNom',
'auteurHandle',
'auteurAvatarUrl',
'auteurProfilUrl',
'remoteUrl'
],
populate: { populate: {
user: { user: {
fields: ['username'] fields: ['username']
@@ -54,7 +69,7 @@ export async function jwennTeksEpiSlug(slug) {
streamVideo: { streamVideo: {
populate: '*' populate: '*'
}, },
stems: { kits: {
populate: '*' populate: '*'
}, },
traductions: { traductions: {
@@ -133,7 +148,7 @@ export async function jwennToutAwtis() {
populate: ['photo'], populate: ['photo'],
sort: ['alias:asc'], sort: ['alias:asc'],
pagination: { pagination: {
pageSize: 100 pageSize: 200
} }
}, { }, {
encodeValuesOnly: true encodeValuesOnly: true
@@ -146,39 +161,16 @@ export async function jwennToutAwtis() {
export async function jwennTeks() { export async function jwennTeks() {
const query = qs.stringify({ const query = qs.stringify({
populate: { populate: {
user: {
fields: ['username']
},
userAdmin: {
fields: ['username', 'firstname']
},
commentaires: {
fields: ['contenu', 'datePublication'],
populate: {
user: {
fields: ['username']
}
}
},
couverture: { couverture: {
populate: '*' populate: '*'
}, },
artistes: { artistes: {
fields: ['alias', 'slug'] fields: ['alias', 'slug']
},
streamAudio: {
populate: '*'
},
streamVideo: {
populate: '*'
},
traductions: {
populate: '*'
} }
}, },
sort: ['titre:asc'], sort: ['titre:asc'],
pagination: { pagination: {
pageSize: 100 pageSize: 200
} }
}, { }, {
encodeValuesOnly: true encodeValuesOnly: true
@@ -188,32 +180,37 @@ export async function jwennTeks() {
return data 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({ const query = qs.stringify({
sort: ['createdAt:desc'], sort: ['createdAt:desc'],
pagination: { fields: ['slug'],
pageSize: 100 pagination: {page, pageSize}
}
}, { }, {
encodeValuesOnly: true encodeValuesOnly: true
}) })
const {data} = await request(`/artistes?${query}`, headers) // 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
return data.map(({slug}) => slug) 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() { export async function jwennTeksSlug() {
const query = qs.stringify({ return jwennTouSlug('paroles')
sort: ['createdAt:desc'],
pagination: {
pageSize: 100
}
}, {
encodeValuesOnly: true
})
const {data} = await request(`/paroles?${query}`, headers)
return data.map(({slug}) => slug)
} }
export async function jwennDenyeTeks() { export async function jwennDenyeTeks() {
-4
View File
@@ -1,16 +1,12 @@
import YouTubeIcon from '@mui/icons-material/YouTube' import YouTubeIcon from '@mui/icons-material/YouTube'
import TelegramIcon from '@mui/icons-material/Telegram' import TelegramIcon from '@mui/icons-material/Telegram'
import PeertubeIcon from './icons/peertube'
import XmppIcon from './icons/xmpp'
import XIcon from './icons/x' import XIcon from './icons/x'
import GiteaIcon from './icons/gitea' import GiteaIcon from './icons/gitea'
import BlueskyIcon from './icons/bluesky' import BlueskyIcon from './icons/bluesky'
const gitURL = process.env.NEXT_PUBLIC_GIT || 'https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/pawol.nu' 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 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 youtubeUsername = process.env.NEXT_PUBLIC_YOUTUBE_USERNAME || 'OrganisationKInternationaleOKi'
const tiwtterUsername = process.env.NEXT_PUBLIC_TWITTER_USERNAME || 'OrganisationKA' const tiwtterUsername = process.env.NEXT_PUBLIC_TWITTER_USERNAME || 'OrganisationKA'
const telegramGroup = process.env.NEXT_PUBLIC_TELEGRAM_GROUP || '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 => export const formatJsonString = stringToFormat =>
stringToFormat.split('\n').map((string, index) => <div key={index}>{`${string}`}<br /></div>) // eslint-disable-line react/no-array-index-key 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})
}))
}
+10 -11
View File
@@ -11,26 +11,25 @@ function buildRemotePatterns() {
const [hostname, port] = entry.split(':') const [hostname, port] = entry.split(':')
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' const isLocal = hostname === 'localhost' || hostname === '127.0.0.1'
const pattern = {protocol: isLocal ? 'http' : 'https', hostname, pathname: '/uploads/**'} const pattern = {protocol: isLocal ? 'http' : 'https', hostname, pathname: '/uploads/**'}
if (port) pattern.port = port if (port) {
pattern.port = port
}
return pattern return pattern
}) })
if (!raw.includes('localhost')) if (!raw.includes('localhost')) {
patterns.push({protocol: 'http', hostname: 'localhost', port: '1337', pathname: '/uploads/**'}) 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/**'}) patterns.push({protocol: 'http', hostname: '127.0.0.1', port: '1337', pathname: '/uploads/**'})
}
return patterns return patterns
} }
module.exports = (withPWA({ module.exports = (withPWA({
turbopack: {}, turbopack: {},
webpack: config => {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack', 'url-loader'],
})
return config
},
images: { images: {
remotePatterns: buildRemotePatterns() remotePatterns: buildRemotePatterns()
} }
+16 -14
View File
@@ -17,7 +17,9 @@
"lint": "xo", "lint": "xo",
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start" "start": "next start",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@emotion/cache": "^11.11.0", "@emotion/cache": "^11.11.0",
@@ -32,36 +34,36 @@
"@mui/material-pigment-css": "^9.0.0", "@mui/material-pigment-css": "^9.0.0",
"@mui/styles": "^6.4.8", "@mui/styles": "^6.4.8",
"@pigment-css/react": "^0.0.30", "@pigment-css/react": "^0.0.30",
"@svgr/webpack": "^6.5.0",
"axios": "^0.21.0",
"compression": "^1.7.4",
"date-fns": "^2.16.1", "date-fns": "^2.16.1",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.17.1",
"file-saver": "^2.0.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"music-metadata-browser": "^2.5.11", "music-metadata-browser": "^2.5.11",
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"next-plausible": "^4.0.0", "next-plausible": "^4.0.0",
"next-pwa": "5.6.0", "next-pwa": "5.6.0",
"nextjs-toploader": "^3.9.17", "nextjs-toploader": "^3.9.17",
"nodemailer": "^6.7.2", "nodemailer": "^6.7.2",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"qs": "^6.15.3",
"react": "19.2.5", "react": "19.2.5",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"react-swipeable-views": "^0.13.9",
"react-virtuoso": "^4.4.0", "react-virtuoso": "^4.4.0",
"sharp": "^0.29.3", "sharp": "^0.29.3",
"slugify": "^1.6.6" "slugify": "^1.6.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@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": "^8.7.0",
"eslint-config-next": "16.2.4",
"eslint-config-xo-nextjs": "^4.0.3", "eslint-config-xo-nextjs": "^4.0.3",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.3.0",
"jsdom": "^29.1.1",
"vitest": "^4.1.9",
"xo": "^0.47.0" "xo": "^0.47.0"
}, },
"xo": { "xo": {
@@ -83,11 +85,11 @@
"error", "error",
{ {
"capIsNewExceptions": [ "capIsNewExceptions": [
"CredentialsProvider", "GET",
"TwitterProvider", "POST",
"GoogleProvider", "PUT",
"GitHubProvider", "DELETE",
"NextAuth" "PATCH"
] ]
} }
], ],
+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