39 Commits
Author SHA1 Message Date
cedric 4cc5c405bb feat: add ayisyen
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 31s
Déploiement FRONT PROD / deploy (push) Successful in 49s
2026-08-11 07:31:20 +04:00
cedric c8841a253f fix: rendre le proxy MIZIK plus robuste et tolérer l'ancien nommage des credentials
Déploiement FRONT BETA / check (push) Successful in 2m18s
Déploiement FRONT PROD / check (push) Successful in 2m8s
Déploiement FRONT BETA / deploy (push) Successful in 25s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-15 23:04:19 +04:00
cedric 4e6341c5d5 refactor: renommer middleware.js en proxy.js pour Next.js 16
Déploiement FRONT BETA / check (push) Successful in 2m8s
Déploiement FRONT PROD / check (push) Successful in 2m15s
Déploiement FRONT BETA / deploy (push) Successful in 24s
Déploiement FRONT PROD / deploy (push) Successful in 23s
2026-07-08 00:29:26 +04:00
cedric b5a3b278ba feat: ajouter les en-têtes de sécurité pour atteindre 100/100 sur MDN Observatory 2026-07-08 00:27:57 +04:00
cedric 99daeb014c feat: styliser le lien vers le fil BOKANTE
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m15s
Déploiement FRONT BETA / deploy (push) Successful in 24s
Déploiement FRONT PROD / deploy (push) Successful in 24s
2026-07-07 16:13:57 +04:00
cedric f5a4014e41 feat: ajouter un sélecteur d'instance Mastodon et expliciter BOKANTE 2026-07-07 16:11:46 +04:00
cedric d66c679fd1 revert: ne plus présélectionner selon la langue du navigateur
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-06 02:34:08 +04:00
cedric cab293d62e Merge pull request 'Sélecteur de langue : regroupement par continent, Afrique en tête' (#6) from feat/africa into master
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / deploy (push) Successful in 22s
Déploiement FRONT PROD / deploy (push) Successful in 19s
Reviewed-on: #6
2026-07-05 22:20:46 +00:00
cedric 5936de59ff fix: mettre en valeur l'option Transcription dans le select
Déploiement FRONT BETA / check (push) Successful in 2m7s
Vérification PR / check (pull_request) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Vérification PR / deploy-beta (pull_request) Successful in 19s
2026-07-06 02:01:44 +04:00
cedric 6804e3626f feat: remplacer les puces par un select groupé par continent
Déploiement FRONT BETA / check (push) Successful in 2m14s
Vérification PR / check (pull_request) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Vérification PR / deploy-beta (pull_request) Successful in 20s
2026-07-06 00:18:34 +04:00
cedric 461b063cff feat: regrouper les langues par continent, Afrique en tête 2026-07-06 00:10:34 +04:00
cedric 6601b00545 fix: ne plus exposer le jeton API au navigateur (recherche d'artiste)
Déploiement FRONT PROD / check (push) Successful in 2m13s
Déploiement FRONT PROD / deploy (push) Successful in 20s
Déploiement FRONT BETA / check (push) Successful in 2m6s
Déploiement FRONT BETA / deploy (push) Successful in 20s
2026-07-05 23:16:19 +04:00
cedric f88b0184ea feat: sélectionner la traduction par défaut selon la langue du navigateur
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-05 22:33:09 +04:00
cedric 43964ec91d feat: afficher les langues asiatiques avant les langues européennes 2026-07-05 22:28:57 +04:00
cedric ce19fa3b5d fix: remplacer le libellé de date par une icône universelle
Déploiement FRONT BETA / check (push) Successful in 2m10s
Déploiement FRONT PROD / check (push) Successful in 2m5s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 19s
2026-07-05 20:54:14 +04:00
cedric 33514bf633 feat: généraliser le formatage de date adaptatif à la locale 2026-07-05 20:47:52 +04:00
cedric 35c39a97f3 feat: adapter la date des commentaires à la locale du navigateur
Déploiement FRONT PROD / deploy (push) Successful in 20s
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
2026-07-05 20:39:50 +04:00
cedric 72b86fdc5c feat: mettre en valeur les mentions et afficher l'heure des commentaires
Déploiement FRONT PROD / deploy (push) Successful in 19s
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m9s
Déploiement FRONT BETA / deploy (push) Successful in 21s
2026-07-05 20:17:35 +04:00
cedric 53287f9c80 feat: remplacer les onglets de langue par des puces adaptatives
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-05 20:09:29 +04:00
cedric 8979970a90 feat: refonte visuelle des commentaires (cartes, compteur, date)
Déploiement FRONT BETA / check (push) Successful in 2m18s
Déploiement FRONT PROD / check (push) Successful in 2m7s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 19s
2026-07-05 19:50:54 +04:00
cedric 75c5979478 feat: ajouter le logo Mastodon au badge des commentaires fédérés
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Déploiement FRONT PROD / deploy (push) Successful in 22s
2026-07-05 19:35:40 +04:00
cedric 5778d865e2 docs: documenter les commentaires fédérés (bokante)
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-05 02:07:03 +04:00
cedric 0ac18faf5c Merge pull request 'Commentaires fédérés via Mastodon (bokante.o-k-i.net) — frontend' (#5) from feat/comment-fedi into master
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 29s
Déploiement FRONT BETA / check (push) Successful in 2m5s
Déploiement FRONT PROD / check (push) Successful in 2m12s
Reviewed-on: #5
2026-07-04 21:56:48 +00:00
cedric 21248b7138 feat: bouton Répondre via Mastodon (authorize_interaction)
Vérification PR / check (pull_request) Successful in 2m12s
Vérification PR / deploy-beta (pull_request) Successful in 21s
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT BETA / deploy (push) Successful in 31s
2026-07-05 01:15:55 +04:00
cedric eda30345c6 feat: afficher les commentaires (locaux et Mastodon) 2026-07-04 23:45:27 +04:00
cedric b40e2d71b0 fix: déplacer force-dynamic après les imports (import/first)
Déploiement FRONT PROD / check (push) Successful in 2m8s
Déploiement FRONT BETA / check (push) Successful in 2m19s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Déploiement FRONT PROD / deploy (push) Successful in 20s
Corrige une violation ESLint introduite par le commit précédent :
l'export de configuration de route doit venir après les imports.
2026-07-04 23:18:35 +04:00
cedric 4bc545ac72 fix: ne jamais redémarrer pm2 sur un build cassé
Ajoute set -e aux scripts de déploiement (prod, beta, pré-déploiement
PR) : si git pull, yarn install ou yarn build échoue, le script
s'arrête immédiatement au lieu de continuer et de redémarrer pm2 sur
un .next incomplet — l'ancien build en cours d'exécution reste servi
au lieu de crasher (502).
2026-07-04 23:15:47 +04:00
cedric 64cdd784c3 fix: rétablir force-dynamic sur la page d'accueil
Déploiement FRONT PROD / check (push) Failing after 2m6s
Déploiement FRONT PROD / deploy (push) Has been skipped
Déploiement FRONT BETA / check (push) Failing after 2m11s
Déploiement FRONT BETA / deploy (push) Has been skipped
Sa suppression (44e1a42) faisait basculer la page d'accueil en
génération statique au build : le moindre souci de récupération de
données à ce moment-là fait planter tout le build ("Export
encountered an error on /page: /"), et le script de déploiement
redémarre quand même pm2 sur un .next incomplet (502 en prod).
2026-07-04 23:12:52 +04:00
cedric d2d09c28b3 Merge pull request 'Audit qualité : corrections de bugs, nettoyage, lint 0 erreur, tests' (#4) from fix/audit-2026-07-04 into master
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 19s
Déploiement FRONT PROD / check (push) Successful in 2m7s
Déploiement FRONT PROD / deploy (push) Successful in 21s
Reviewed-on: #4
2026-07-04 17:01:35 +00:00
cedric eae8bb03a9 ci: rendre le lint à nouveau bloquant
Déploiement FRONT BETA / check (push) Successful in 2m6s
Déploiement FRONT BETA / deploy (push) Successful in 49s
Vérification PR / deploy-beta (pull_request) Successful in 20s
Vérification PR / check (pull_request) Successful in 2m6s
2026-07-04 14:35:08 +04:00
cedric 454eefbe58 fix: corriger toutes les erreurs de lint restantes (263 -> 0) 2026-07-04 14:34:50 +04:00
cedric b193914c57 fix: restaurer components/cgu supprimé par erreur (utilisé par /sipote) 2026-07-04 14:34:16 +04:00
cedric d700ee6def chore: déclarer qs et retirer les dépendances mortes (axios, next-auth...) 2026-07-04 11:48:09 +04:00
cedric 2132df3e0c ci: exécuter lint et tests sur chaque déploiement 2026-07-04 11:47:38 +04:00
cedric c7e3faf10a chore: supprimer le code mort (soumission, connexion, stats, CGU) 2026-07-04 11:46:46 +04:00
cedric 4c662e89b5 chore: retirer le state et l'UI morts de navigasyon.js 2026-07-04 11:46:00 +04:00
cedric 2bec90210a chore: retirer la config webpack SVG morte 2026-07-04 11:45:49 +04:00
cedric 44e1a426b0 fix: retirer force-dynamic sur la page d'accueil 2026-07-04 11:45:39 +04:00
cedric d2971870c1 fix: corriger les bugs du lecteur audio (rendus, effets dupliqués) 2026-07-04 11:45:22 +04:00
71 changed files with 1129 additions and 5833 deletions
+6 -6
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.
@@ -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)
+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)
}
@@ -4,6 +4,7 @@ import {GET} from '../route'
function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) { function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) {
return { return {
status, status,
ok: status >= 200 && status < 300,
body, body,
headers: { headers: {
get: key => headers[key.toLowerCase()] ?? null get: key => headers[key.toLowerCase()] ?? null
@@ -52,4 +53,16 @@ describe('GET /api/mizik-stream/[id]', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('audio/mpeg') expect(response.headers.get('content-type')).toBe('audio/mpeg')
}) })
it('renvoie une erreur JSON si le serveur audio répond en erreur', async () => {
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 401, headers: {'content-type': 'application/json'}, body: '{"error": "unauthorized"}'}))
const request = new Request('http://localhost/api/mizik-stream/7')
const response = await GET(request, {params: Promise.resolve({id: '7'})})
expect(response.status).toBe(401)
expect(response.headers.get('content-type')).toContain('application/json')
const json = await response.json()
expect(json.error).toContain('401')
})
}) })
+10 -2
View File
@@ -1,6 +1,8 @@
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.MIZIK_API_USER || 'user' // Les variables publiques sont conservées en fallback pendant la migration
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || 'password' // des déploiements qui utiliseraient encore lancien nommage.
const MIZIK_API_USER = process.env.MIZIK_API_USER || process.env.NEXT_PUBLIC_MIZIK_API_USER || 'user'
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || process.env.NEXT_PUBLIC_MIZIK_API_PASSWORD || 'password'
const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'] const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges']
@@ -14,6 +16,12 @@ export async function GET(request, props) {
headers: range ? {range} : {} headers: range ? {range} : {}
}) })
if (!upstreamResponse.ok) {
const message = `Le serveur audio a répondu ${upstreamResponse.status}`
console.error(message, upstreamUrl.replace(/p=[^&]+/, 'p=***'))
return Response.json({error: message}, {status: upstreamResponse.status})
}
const headers = new Headers() const headers = new Headers()
for (const key of FORWARDED_HEADERS) { for (const key of FORWARDED_HEADERS) {
const value = upstreamResponse.headers.get(key) const value = upstreamResponse.headers.get(key)
-17
View File
@@ -1,17 +0,0 @@
'use client'
import Proptypes from 'prop-types'
import {SessionProvider} from 'next-auth/react'
export default function AuthProvider({children, session}) {
return (
<SessionProvider session={session}>
{children}
</SessionProvider>
)
}
AuthProvider.propTypes = {
children: Proptypes.node,
session: Proptypes.object
}
+6 -2
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation' import {notFound} from 'next/navigation'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
@@ -20,7 +21,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,9 +65,11 @@ 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)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {photo} = anAwtis const {photo} = anAwtis
const kuvetiFormat = formatKuveti(photo) const kuvetiFormat = formatKuveti(photo)
@@ -91,6 +94,7 @@ export default async function AwtisPajSlug(props) {
<section> <section>
<script <script
type='application/ld+json' type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/> />
</section> </section>
+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)
+6 -3
View File
@@ -7,10 +7,10 @@ import {useServerInsertedHTML} from 'next/navigation'
import {CacheProvider as DefaultCacheProvider} from '@emotion/react' import {CacheProvider as DefaultCacheProvider} from '@emotion/react'
export default function NextAppDirEmotionCacheProvider(props) { export default function NextAppDirEmotionCacheProvider(props) {
const {options, CacheProvider = DefaultCacheProvider, children} = props const {options, CacheProvider = DefaultCacheProvider, children, nonce} = props
const [registry] = React.useState(() => { const [registry] = React.useState(() => {
const cache = createCache(options) const cache = createCache({...options, nonce})
cache.compat = true cache.compat = true
const prevInsert = cache.insert const prevInsert = cache.insert
let inserted = [] let inserted = []
@@ -65,12 +65,14 @@ export default function NextAppDirEmotionCacheProvider(props) {
<style <style
key={name} key={name}
dangerouslySetInnerHTML={{__html: style}} dangerouslySetInnerHTML={{__html: style}}
nonce={nonce}
data-emotion={`${registry.cache.key}-global ${name}`} data-emotion={`${registry.cache.key}-global ${name}`}
/> />
))} ))}
{styles !== '' && ( {styles !== '' && (
<style <style
dangerouslySetInnerHTML={{__html: styles}} dangerouslySetInnerHTML={{__html: styles}}
nonce={nonce}
data-emotion={dataEmotionAttribute} data-emotion={dataEmotionAttribute}
/> />
)} )}
@@ -84,5 +86,6 @@ export default function NextAppDirEmotionCacheProvider(props) {
NextAppDirEmotionCacheProvider.propTypes = { NextAppDirEmotionCacheProvider.propTypes = {
options: PropTypes.object.isRequired, options: PropTypes.object.isRequired,
CacheProvider: PropTypes.func, CacheProvider: PropTypes.func,
children: PropTypes.node.isRequired children: PropTypes.node.isRequired,
nonce: PropTypes.string
} }
+9 -4
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import PlausibleProvider from 'next-plausible' import PlausibleProvider from 'next-plausible'
import TopLoader from '../components/top-loader' import TopLoader from '../components/top-loader'
import Navigasyon from '../components/navigasyon' import Navigasyon from '../components/navigasyon'
@@ -61,16 +62,20 @@ const jsonLd = {
} }
export default async function RootLayout({children}) { export default async function RootLayout({children}) {
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const inner = ( const inner = (
<> <>
<TopLoader color='#ffeb3b' /> <TopLoader color='#ffeb3b' nonce={nonce} />
<ThemeRegistry> <ThemeRegistry nonce={nonce}>
<Navigasyon /> <Navigasyon />
{children} {children}
</ThemeRegistry> </ThemeRegistry>
<section> <section>
<script <script
type='application/ld+json' type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/> />
</section> </section>
@@ -78,10 +83,10 @@ 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} scriptProps={{nonce}}>{inner}</PlausibleProvider>
: inner} : inner}
</body> </body>
</html> </html>
+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} />
+8 -4
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation' import {notFound} from 'next/navigation'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
@@ -21,10 +22,10 @@ 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}`
@@ -67,12 +68,14 @@ 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)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {couverture} = anTeks const {couverture} = anTeks
const teksKuvetiFormat = formatKuveti(couverture) const teksKuvetiFormat = formatKuveti(couverture)
const jsonLd = { const jsonLd = {
'@context': 'http://schema.org', '@context': 'http://schema.org',
@@ -126,6 +129,7 @@ export default async function AnPawolPaj(props) {
<section> <section>
<script <script
type='application/ld+json' type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/> />
</section> </section>
+5
View File
@@ -1,4 +1,5 @@
import {Suspense} from 'react' import {Suspense} from 'react'
import PropTypes from 'prop-types'
import TeksDrawerLoader from '../../components/teks/teks-drawer-loader' import TeksDrawerLoader from '../../components/teks/teks-drawer-loader'
import {CurrentTrackProvider} from '../../components/teks/current-track-context' import {CurrentTrackProvider} from '../../components/teks/current-track-context'
import Loading from './loading' import Loading from './loading'
@@ -50,3 +51,7 @@ export default function PawolLayout({children}) {
</CurrentTrackProvider> </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.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production'
if (!isProduction) { if (!isProduction) {
return { return {
+1 -1
View File
@@ -14,7 +14,7 @@ export default function Sipote() {
<Box sx={{display: 'flex', marginTop: 10, flexDirection: 'column', minHeight: '100vh'}}> <Box sx={{display: 'flex', marginTop: 10, flexDirection: 'column', minHeight: '100vh'}}>
<Box sx={{flexGrow: 1, marginBottom: 10}}> <Box sx={{flexGrow: 1, marginBottom: 10}}>
<Container> <Container>
<Typography gutterBottom sx={{fontWeight: 'bold', textAlign:'center' }} variant='h4' component='h1'> <Typography gutterBottom sx={{fontWeight: 'bold', textAlign: 'center'}} variant='h4' component='h1'>
Soutenir ORGANISATION KA INTERNATIONALE ! Soutenir ORGANISATION KA INTERNATIONALE !
</Typography> </Typography>
<Paper sx={{padding: 2, marginTop: 3}}> <Paper sx={{padding: 2, marginTop: 3}}>
+11 -10
View File
@@ -64,22 +64,23 @@ const theme = createTheme({
}) })
export default function ThemeRegistry(props) { export default function ThemeRegistry(props) {
const {children} = props const {children, nonce} = props
return ( return (
<> <>
<InitColorSchemeScript attribute='class' /> <InitColorSchemeScript attribute='class' nonce={nonce} />
<NextAppDirEmotionCacheProvider options={{key: 'mui'}}> <NextAppDirEmotionCacheProvider options={{key: 'mui'}} nonce={nonce}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<ChanjeTem /> <ChanjeTem />
{children} {children}
</ThemeProvider> </ThemeProvider>
</NextAppDirEmotionCacheProvider> </NextAppDirEmotionCacheProvider>
</> </>
) )
} }
ThemeRegistry.propTypes = { ThemeRegistry.propTypes = {
children: PropTypes.node.isRequired children: PropTypes.node.isRequired,
nonce: PropTypes.string
} }
+2 -2
View File
@@ -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
}
+10 -9
View File
@@ -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>
)} )}
@@ -149,9 +150,9 @@ export default function AwtisDetay({anAwtis}) {
<Link href={`/paroles/${titrePhare.slug}`} style={{display: 'block', height: '100%'}}> <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'
@@ -163,7 +164,7 @@ export default function AwtisDetay({anAwtis}) {
)} )}
<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'}}> <Link href={`/paroles/${titrePhare.slug}`} style={{color: 'inherit', textDecoration: 'none'}}>
{titrePhare.titre} {titrePhare.titre}
</Link> </Link>
@@ -172,8 +173,8 @@ export default function AwtisDetay({anAwtis}) {
É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>
@@ -202,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>
@@ -213,7 +214,7 @@ export default function AwtisDetay({anAwtis}) {
<MizikLyen anPawol={paroles[0]} kuveti={formatKuveti(paroles[0].couverture, 'thumbnail')} /> <MizikLyen anPawol={paroles[0]} kuveti={formatKuveti(paroles[0].couverture, 'thumbnail')} />
</Paper> </Paper>
</Box> </Box>
)} ))}
</Grid> </Grid>
</Grid> </Grid>
+1 -2
View File
@@ -19,7 +19,6 @@ 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'
@@ -84,7 +83,7 @@ export default function AwtisKat({artiste}) {
component='img' component='img'
alt={alias} alt={alias}
image={`${photo?.url ? `${IMAGE_URL}${formatKuveti(photo, 'thumbnail')?.url}` : noImageUrl}`} image={`${photo?.url ? `${IMAGE_URL}${formatKuveti(photo, 'thumbnail')?.url}` : noImageUrl}`}
loading='lazy' loading='lazy'
title={alias} title={alias}
/> />
<CardContent> <CardContent>
+16 -20
View File
@@ -5,10 +5,8 @@ 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' 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'
@@ -28,8 +26,8 @@ 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()
return { return {
@@ -50,15 +48,13 @@ 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
sx={{ display: 'flex', justifyContent: 'center', marginBottom: 3 }} sx={{display: 'flex', justifyContent: 'center', marginBottom: 3}}
> >
<Autocomplete <Autocomplete
autoHighlight autoHighlight
@@ -70,35 +66,35 @@ 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}${formatKuveti(option?.photo, 'thumbnail')?.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'
slotProps={{ slotProps={{
...params.slotProps, ...params.slotProps,
htmlInput: { ...params.slotProps.htmlInput }, htmlInput: {...params.slotProps.htmlInput},
}} }}
/> />
)} )}
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)}
+25 -17
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'
@@ -11,23 +12,23 @@ import {
} from '@icons-pack/react-simple-icons' } from '@icons-pack/react-simple-icons'
const SOCIAL_CONFIG = { const SOCIAL_CONFIG = {
Mastodon: {label: 'Mastodon', bg: '#6364FF', color: '#fff', Icon: Mastodon}, Mastodon: {label: 'Mastodon', bg: '#6364FF', color: '#fff', Icon: Mastodon},
Peertube: {label: 'PeerTube', bg: '#F2690D', color: '#fff', Icon: Peertube}, Peertube: {label: 'PeerTube', bg: '#F2690D', color: '#fff', Icon: Peertube},
Pixelfed: {label: 'Pixelfed', bg: '#11D49D', color: '#fff', Icon: null}, Pixelfed: {label: 'Pixelfed', bg: '#11D49D', color: '#fff', Icon: null},
Funkwhale: {label: 'Funkwhale', bg: '#E01B60', color: '#fff', Icon: null}, Funkwhale: {label: 'Funkwhale', bg: '#E01B60', color: '#fff', Icon: null},
Bluesky: {label: 'Bluesky', bg: '#0085FF', color: '#fff', Icon: null}, Bluesky: {label: 'Bluesky', bg: '#0085FF', color: '#fff', Icon: null},
Instagram: {label: 'Instagram', bg: '#E4405F', color: '#fff', Icon: Instagram}, Instagram: {label: 'Instagram', bg: '#E4405F', color: '#fff', Icon: Instagram},
Youtube: {label: 'YouTube', bg: '#FF0000', color: '#fff', Icon: Youtube}, Youtube: {label: 'YouTube', bg: '#FF0000', color: '#fff', Icon: Youtube},
Tiktok: {label: 'TikTok', bg: '#000000', color: '#fff', Icon: Tiktok}, Tiktok: {label: 'TikTok', bg: '#000000', color: '#fff', Icon: Tiktok},
Spotify: {label: 'Spotify', bg: '#1DB954', color: '#fff', Icon: Spotify}, Spotify: {label: 'Spotify', bg: '#1DB954', color: '#fff', Icon: Spotify},
Deezer: {label: 'Deezer', bg: '#EF5466', color: '#fff', Icon: Deezer}, Deezer: {label: 'Deezer', bg: '#EF5466', color: '#fff', Icon: Deezer},
Applemusic: {label: 'Apple Music', bg: '#FC3C44', color: '#fff', Icon: Applemusic}, Applemusic: {label: 'Apple Music', bg: '#FC3C44', color: '#fff', Icon: Applemusic},
Bandcamp: {label: 'Bandcamp', bg: '#1DA0C3', color: '#fff', Icon: Bandcamp}, Bandcamp: {label: 'Bandcamp', bg: '#1DA0C3', color: '#fff', Icon: Bandcamp},
Soundcloud: {label: 'SoundCloud', bg: '#FF5500', color: '#fff', Icon: Soundcloud}, Soundcloud: {label: 'SoundCloud', bg: '#FF5500', color: '#fff', Icon: Soundcloud},
Facebook: {label: 'Facebook', bg: '#1877F2', color: '#fff', Icon: Facebook}, Facebook: {label: 'Facebook', bg: '#1877F2', color: '#fff', Icon: Facebook},
Twitter: {label: 'X / Twitter', bg: '#000000', color: '#fff', Icon: Twitter}, Twitter: {label: 'X / Twitter', bg: '#000000', color: '#fff', Icon: Twitter},
Linktree: {label: 'Linktree', bg: '#43E660', color: '#000', Icon: null}, Linktree: {label: 'Linktree', bg: '#43E660', color: '#000', Icon: null},
SiteWeb: {label: 'Site web', bg: '#555555', color: '#fff', Icon: null}, SiteWeb: {label: 'Site web', bg: '#555555', color: '#fff', Icon: null},
} }
export function SocialButton({rezo}) { export function SocialButton({rezo}) {
@@ -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 => {
+2 -2
View File
@@ -48,10 +48,10 @@ export default function KitsDialog({kits}) {
<DialogTitle>Télécharger un kit</DialogTitle> <DialogTitle>Télécharger un kit</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<List> <List>
{kits.map((kit, index) => { {kits.map(kit => {
const PlatformIcon = PLATFORM_ICON[kit.plateforme] ?? CloudDownloadIcon const PlatformIcon = PLATFORM_ICON[kit.plateforme] ?? CloudDownloadIcon
return ( return (
<ListItem key={index} disablePadding> <ListItem key={kit.url} disablePadding>
<ListItemButton component='a' href={kit.url} target='_blank' rel='noopener noreferrer'> <ListItemButton component='a' href={kit.url} target='_blank' rel='noopener noreferrer'>
<ListItemIcon> <ListItemIcon>
<PlatformIcon /> <PlatformIcon />
-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
}
+16 -8
View File
@@ -1,18 +1,19 @@
'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'
export const PLATFORM_CONFIG = { export const PLATFORM_CONFIG = {
Spotify: {label: 'Spotify', bg: '#1DB954', color: '#fff', Icon: Spotify}, Spotify: {label: 'Spotify', bg: '#1DB954', color: '#fff', Icon: Spotify},
Applemusic: {label: 'Apple Music', bg: '#FC3C44', color: '#fff', Icon: Applemusic}, Applemusic: {label: 'Apple Music', bg: '#FC3C44', color: '#fff', Icon: Applemusic},
Deezer: {label: 'Deezer', bg: '#EF5466', color: '#fff', Icon: Deezer}, Deezer: {label: 'Deezer', bg: '#EF5466', color: '#fff', Icon: Deezer},
Tidal: {label: 'Tidal', bg: '#000000', color: '#fff', Icon: Tidal}, Tidal: {label: 'Tidal', bg: '#000000', color: '#fff', Icon: Tidal},
Qobuz: {label: 'Qobuz', bg: '#00245B', color: '#fff', Icon: null}, Qobuz: {label: 'Qobuz', bg: '#00245B', color: '#fff', Icon: null},
Youtubemusic: {label: 'YouTube Music', bg: '#FF0000', color: '#fff', Icon: Youtubemusic}, Youtubemusic: {label: 'YouTube Music', bg: '#FF0000', color: '#fff', Icon: Youtubemusic},
Amazon: {label: 'Amazon Music', bg: '#00A8E1', color: '#fff', Icon: Amazon}, Amazon: {label: 'Amazon Music', bg: '#00A8E1', color: '#fff', Icon: Amazon},
Soundcloud: {label: 'SoundCloud', bg: '#FF5500', color: '#fff', Icon: Soundcloud}, Soundcloud: {label: 'SoundCloud', bg: '#FF5500', color: '#fff', Icon: Soundcloud},
} }
export function StreamButton({lyen}) { export function StreamButton({lyen}) {
@@ -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,74 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ReponsMastodon from '../repons-mastodon'
describe('ReponsMastodon', () => {
const originalOpen = window.open
afterEach(() => {
window.open = originalOpen
})
it('ne rend rien sans bokanteStatusId', () => {
const {container} = render(<ReponsMastodon bokanteStatusId={null} />)
expect(container).toBeEmptyDOMElement()
})
it('affiche un lien direct vers le fil BOKANTE', () => {
render(<ReponsMastodon bokanteStatusId='112233' />)
const lien = screen.getByRole('link', {name: 'BOKANTE (Mastodon)'})
expect(lien).toHaveAttribute('href', 'https://bokante.o-k-i.net/@pawol_nu/112233')
})
it('redirige vers BOKANTE par défaut quand on continue', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith(
'https://bokante.o-k-i.net/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank',
'noopener,noreferrer'
)
})
it('permet de choisir une autre instance dans le select', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.click(screen.getByLabelText(/instance mastodon/i))
await user.click(screen.getByRole('option', {name: /autre instance/i}))
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social')
await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith(
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank',
'noopener,noreferrer'
)
})
it('accepte un identifiant complet pour une autre instance', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.click(screen.getByLabelText(/instance mastodon/i))
await user.click(screen.getByRole('option', {name: /autre instance/i}))
await user.type(screen.getByLabelText(/@vous@votre-instance/i), '@quelqun@mastodon.social')
await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith(
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank',
'noopener,noreferrer'
)
})
})
-1
View File
@@ -8,7 +8,6 @@ export default function AnTeks({parole, paroleId}) {
<Teks <Teks
parole={parole} parole={parole}
paroleId={paroleId} paroleId={paroleId}
commentaires={parole?.commentaires}
/> />
) )
} }
+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}>
+1 -1
View File
@@ -20,7 +20,7 @@ const getMizikFiltered = (paroles, filter) => {
const filteredTitre = paroles.filter(({titre}) => { const filteredTitre = paroles.filter(({titre}) => {
const deburredTit = deburr(titre) const deburredTit = deburr(titre)
return deburredTit.toLowerCase().includes(deburr(filter.toLowerCase())) return deburredTit.toLowerCase().includes(deburr(filter.toLowerCase()))
}) })
const filteredAlias = paroles.filter(({artistes}) => { const filteredAlias = paroles.filter(({artistes}) => {
const aliasLis = artistes.map(({alias}) => deburr(alias)).join(', ') const aliasLis = artistes.map(({alias}) => deburr(alias)).join(', ')
+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
}
+20 -25
View File
@@ -30,9 +30,10 @@ function createAudio(src) {
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()})
} }
} }
@@ -75,9 +76,12 @@ const TinyText = styled(Typography)({
}) })
export default function Lekte({audio, url, parole}) { export default function Lekte({audio, url, parole}) {
const audioRef = useRef(createAudio(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()
@@ -104,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()
@@ -132,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)
@@ -158,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 () => {
@@ -167,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)))
@@ -175,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 => {
@@ -330,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}
+146
View File
@@ -0,0 +1,146 @@
'use client'
import {useState} from 'react'
import PropTypes from 'prop-types'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import Select from '@mui/material/Select'
import MenuItem from '@mui/material/MenuItem'
import Link from '@mui/material/Link'
import {Mastodon} from '@icons-pack/react-simple-icons'
const BOKANTE_URL = process.env.NEXT_PUBLIC_BOKANTE_URL || 'https://bokante.o-k-i.net'
const BOKANTE_ACCOUNT = process.env.NEXT_PUBLIC_BOKANTE_ACCOUNT || 'pawol_nu'
const INSTANCES_SUGGEREES = [
{domain: 'bokante.o-k-i.net', label: 'BOKANTE - notre instance'},
{domain: 'mastodon.social', label: 'mastodon.social'},
{domain: 'piaille.fr', label: 'piaille.fr'},
]
const AUTRE_INSTANCE = 'autre'
function extraireInstance(saisie) {
const valeur = saisie.trim().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/$/, '')
return valeur.includes('@') ? valeur.split('@').pop() : valeur
}
export default function ReponsMastodon({bokanteStatusId}) {
const [ouvert, setOuvert] = useState(false)
const [instance, setInstance] = useState(INSTANCES_SUGGEREES[0].domain)
const [autreInstance, setAutreInstance] = useState('')
if (!bokanteStatusId) {
return null
}
const tootUrl = `${BOKANTE_URL}/@${BOKANTE_ACCOUNT}/${bokanteStatusId}`
const repondre = () => {
const domaine = instance === AUTRE_INSTANCE ? extraireInstance(autreInstance) : instance
if (!domaine) {
return
}
window.open(`https://${domaine}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer')
setOuvert(false)
setAutreInstance('')
}
return (
<Box sx={{maxWidth: 700, margin: '1em auto 0', textAlign: 'center'}}>
<Typography variant='body2' sx={{mb: 1}}>
Rejoignez la discussion sur le Fediverse via{' '}
<Link
href={tootUrl}
target='_blank'
rel='noopener noreferrer'
underline='hover'
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.25,
borderRadius: 1,
bgcolor: 'action.hover',
color: 'primary.main',
fontWeight: 500,
textDecoration: 'none',
'&:hover': {
bgcolor: 'action.selected'
}
}}
>
BOKANTE (Mastodon)
</Link>
</Typography>
<Button
variant='outlined'
size='small'
startIcon={<Mastodon size={16} color='#6364FF' title='' />}
onClick={() => setOuvert(true)}
>
Répondre sur Mastodon
</Button>
<Dialog open={ouvert} onClose={() => setOuvert(false)}>
<DialogTitle>Répondre depuis Mastodon</DialogTitle>
<DialogContent>
<Typography variant='body2' sx={{mb: 2}}>
Choisissez linstance Mastodon sur laquelle vous avez un compte.
BOKANTE est linstance de OKI : vous pouvez aussi utiliser
nimporte quelle autre instance du Fediverse.
</Typography>
<FormControl fullWidth sx={{mb: 2}}>
<InputLabel id='instance-mastodon-label'>Instance Mastodon</InputLabel>
<Select
labelId='instance-mastodon-label'
id='instance-mastodon'
value={instance}
label='Instance Mastodon'
onChange={event => setInstance(event.target.value)}
>
{INSTANCES_SUGGEREES.map(({domain, label}) => (
<MenuItem key={domain} value={domain}>
{label}
</MenuItem>
))}
<MenuItem value={AUTRE_INSTANCE}>Autre instance</MenuItem>
</Select>
</FormControl>
{instance === AUTRE_INSTANCE && (
<TextField
autoFocus
fullWidth
label='@vous@votre-instance'
helperText='Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre instance, vous serez redirigé·e vers votre compte pour répondre.'
value={autreInstance}
onChange={event => setAutreInstance(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
repondre()
}
}}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOuvert(false)}>Annuler</Button>
<Button variant='contained' onClick={repondre}>Continuer</Button>
</DialogActions>
</Dialog>
</Box>
)
}
ReponsMastodon.propTypes = {
bokanteStatusId: PropTypes.string
}
+12 -6
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,6 +11,7 @@ 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/get-alias' import {getAlias} from '../../lib/utils/get-alias'
@@ -37,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 => {
@@ -51,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
fill
src={`${IMAGE_URL}${formatKuveti(couverture, 'thumbnail')?.url}`} src={`${IMAGE_URL}${formatKuveti(couverture, 'thumbnail')?.url}`}
alt={titre} alt={titre}
fill
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'
@@ -103,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>
+99 -49
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,8 +16,9 @@ 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'
@@ -29,6 +33,8 @@ 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' 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'
@@ -89,15 +95,35 @@ 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: 'ayisyen', title: 'Ayisyen 🇭🇹', continent: 'Afrique', vedette: true},
{field: 'espagnol', title: 'Español'}, {field: 'yoruba', title: 'Yorùbá', continent: 'Afrique', vedette: true},
{field: 'allemand', title: 'Deutsch'}, {field: 'lingala', title: 'Lingála', continent: 'Afrique', vedette: true},
{field: 'italien', title: 'Italiano'}, {field: 'wolof', title: 'Wolof', continent: 'Afrique', vedette: true},
{field: 'portugais', title: 'Português'}, {field: 'swahili', title: 'Kiswahili', continent: 'Afrique', vedette: true},
{field: 'japonais', title: '日本語'}, {field: 'hausa', title: 'Hausa', continent: 'Afrique', vedette: true},
{field: 'coreen', title: '한국어'}, {field: 'arabe', title: 'العربية', continent: 'Afrique'},
{field: 'oromo', title: 'Oromoo', continent: 'Afrique'},
{field: 'igbo', title: 'Igbo', continent: 'Afrique'},
{field: 'zoulou', title: 'isiZulu', continent: 'Afrique'},
{field: 'malgache', title: 'Malagasy', continent: 'Afrique'},
{field: 'xhosa', title: 'isiXhosa', continent: 'Afrique'},
{field: 'tswana', title: 'Setswana', continent: 'Afrique'},
{field: 'tsonga', title: 'Xitsonga', continent: 'Afrique'},
{field: 'sesotho', title: 'Sesotho', continent: 'Afrique'},
// Asie
{field: 'japonais', title: '日本語', continent: 'Asie'},
{field: 'coreen', title: '한국어', continent: 'Asie'},
// Europe
{field: 'anglais', title: 'English', continent: 'Europe'},
{field: 'francais', title: 'Français', continent: 'Europe'},
{field: 'espagnol', title: 'Español', continent: 'Europe'},
{field: 'allemand', title: 'Deutsch', continent: 'Europe'},
{field: 'italien', title: 'Italiano', continent: 'Europe'},
{field: 'portugais', title: 'Português', continent: 'Europe'},
] ]
const langToArray = parole => { const langToArray = parole => {
@@ -107,7 +133,7 @@ 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 ExplicitTooltip = Tooltip const ExplicitTooltip = Tooltip
@@ -183,7 +209,7 @@ export default function Teks({parole}) {
<i>parole soumise par {parole.user.username}</i> <i>parole soumise par {parole.user.username}</i>
</Typography> </Typography>
)} )}
{parole?.userAdmin&& !parole.user && ( {parole?.userAdmin && !parole.user && (
<Typography style={{marginBottom: '1.5em'}} display='block' variant='caption'> <Typography style={{marginBottom: '1.5em'}} display='block' variant='caption'>
<i>parole soumise par {parole.userAdmin.firstname}</i> <i>parole soumise par {parole.userAdmin.firstname}</i>
</Typography> </Typography>
@@ -220,47 +246,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}}>
value={tab} <FormControl size='small' sx={{minWidth: 240, width: isMobile ? '100%' : 'auto'}}>
onChange={(event, value) => setTab(value)} <Select
variant='scrollable' value={tab}
scrollButtons MenuProps={{PaperProps: {sx: {maxHeight: 420}}}}
allowScrollButtonsMobile renderValue={value => {
centered={langArray.length === 0} const label = value === 0 ? 'Transcription' : langArray[value - 1]?.title
slots={{ const Icon = value === 0 ? ArticleIcon : TranslateIcon
startScrollButtonIcon: ArrowBackIosNewIcon, return (
endScrollButtonIcon: ArrowForwardIosIcon <Box sx={{display: 'flex', alignItems: 'center', gap: 1, fontWeight: value === 0 ? 700 : 400}}>
}} <Icon fontSize='small' color='primary' />
sx={{ {label}
borderBottom: 1, </Box>
borderColor: 'divider', )
mb: 2, }}
'& .MuiTabs-scrollButtons': { onChange={event => setTab(event.target.value)}
width: 36, >
height: 36, <MenuItem value={0} sx={{display: 'flex', gap: 0.75, fontWeight: 700}}>
borderRadius: '50%', <ArticleIcon fontSize='inherit' color='primary' />
color: 'primary.main', Transcription
backgroundColor: 'action.hover', </MenuItem>
mx: 0.5, <Divider />
'&.Mui-disabled': { {CONTINENTS.flatMap(continent => {
opacity: 0 const items = langArray
} .map((item, index) => ({...item, index}))
} .filter(item => item.continent === continent)
}}
> if (items.length === 0) {
<Tab label='Transcription' /> return []
{langArray.map(({title}) => ( }
<Tab key={title} label={title} />
))} return [
</Tabs> <ListSubheader
key={`entet-${continent}`}
sx={{
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
color: 'primary.main',
lineHeight: '2.5em'
}}
>
{continent}
</ListSubheader>,
...items.map(({title, index, vedette}) => (
<MenuItem key={title} value={index + 1} sx={{display: 'flex', gap: 0.75}}>
{vedette && <StarIcon fontSize='inherit' color='primary' />}
{title}
</MenuItem>
))
]
})}
</Select>
</FormControl>
</Box>
{tab === 0 && ( {tab === 0 && (
<> <>
{parole.langueSource && parole.langueSource !== 'ka' && ( {parole.langueSource && parole.langueSource !== 'ka' && (
@@ -281,6 +329,8 @@ export default function Teks({parole}) {
) )
))} ))}
</Box> </Box>
<ReponsMastodon bokanteStatusId={parole.bokanteStatusId} />
<Komante commentaires={parole.commentaires} />
</Root> </Root>
) )
} }
+12 -2
View File
@@ -1,7 +1,17 @@
'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, nonce}) {
return <NextTopLoader color={color} /> return <NextTopLoader color={color} nonce={nonce} />
}
TopLoader.propTypes = {
color: PropTypes.string.isRequired,
nonce: PropTypes.string
}
TopLoader.propTypes = {
color: PropTypes.string.isRequired
} }
+14 -14
View File
@@ -1,25 +1,25 @@
import { dirname } from "path"; import {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
+11 -5
View File
@@ -1,15 +1,21 @@
const SIZE_ORDER = { 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'], 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
} }
+14 -4
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},
@@ -38,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']
@@ -186,6 +195,7 @@ async function jwennTouSlug(endpoint) {
encodeValuesOnly: true encodeValuesOnly: true
}) })
// eslint-disable-next-line no-await-in-loop -- pagination séquentielle, chaque page dépend du nombre de résultats de la précédente
const {data} = await request(`/${endpoint}?${query}`, headers) const {data} = await request(`/${endpoint}?${query}`, headers)
slugs.push(...data.map(({slug}) => slug)) slugs.push(...data.map(({slug}) => slug))
hasMore = data.length === pageSize hasMore = data.length === pageSize
@@ -223,9 +233,9 @@ export async function jwennAnVedette() {
const query = qs.stringify({ const query = qs.stringify({
populate: ['artistes', 'couverture'], populate: ['artistes', 'couverture'],
filters: { filters: {
isNewRelease: { $eq: true } isNewRelease: {$eq: true}
}, },
pagination: { limit: 1 } pagination: {limit: 1}
}, { }, {
encodeValuesOnly: true encodeValuesOnly: true
}) })
-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'
+51 -12
View File
@@ -11,27 +11,66 @@ 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
} }
async function headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Cross-Origin-Resource-Policy',
value: 'same-site'
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin'
},
{
key: 'Permissions-Policy',
value: 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
}
]
}
]
}
module.exports = (withPWA({ module.exports = (withPWA({
turbopack: {}, turbopack: {},
webpack: config => { poweredByHeader: false,
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack', 'url-loader'],
})
return config
},
images: { images: {
remotePatterns: buildRemotePatterns() remotePatterns: buildRemotePatterns()
} },
headers
})) }))
+7 -13
View File
@@ -34,37 +34,31 @@
"@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/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^6.0.3", "@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",
@@ -91,11 +85,11 @@
"error", "error",
{ {
"capIsNewExceptions": [ "capIsNewExceptions": [
"CredentialsProvider", "GET",
"TwitterProvider", "POST",
"GoogleProvider", "PUT",
"GitHubProvider", "DELETE",
"NextAuth" "PATCH"
] ]
} }
], ],
+121
View File
@@ -0,0 +1,121 @@
/* eslint-disable @next/next/no-server-import-in-page */
import {NextResponse} from 'next/server'
const nonceCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function generateNonce() {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
let nonce = ''
for (const byte of array) {
nonce += nonceCharset[byte % 64]
}
return nonce
}
function extractOrigin(url) {
if (!url) {
return ''
}
try {
return new URL(url).origin
} catch {
return ''
}
}
function extractHostSource(raw) {
if (!raw) {
return ''
}
const [hostname, port] = raw.split(':')
if (!hostname) {
return ''
}
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `http://${hostname}${port ? `:${port}` : ''}`
}
return `https://${hostname}${port ? `:${port}` : ''}`
}
function buildCsp(nonce) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_URL_ROOT || ''
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL || ''
const mizikUrl = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || ''
const imageDomainsRaw = process.env.NEXT_PUBLIC_DOMAINS_IMAGE || ''
const apiOrigin = extractOrigin(apiUrl)
const plausibleOrigin = extractOrigin(plausibleUrl)
const mizikOrigin = extractOrigin(mizikUrl)
const imageOrigins = imageDomainsRaw
.split(' ')
.filter(Boolean)
.map(entry => extractHostSource(entry))
.filter(Boolean)
const imageOriginsSet = new Set([apiOrigin, ...imageOrigins])
const imageOriginsUnique = [...imageOriginsSet].filter(Boolean)
const connectSrc = ['\'self\'', apiOrigin, plausibleOrigin, mizikOrigin].filter(Boolean)
const imgSrc = ['\'self\'', 'data:', 'blob:', ...imageOriginsUnique].filter(Boolean)
const mediaSrc = ['\'self\'', apiOrigin, mizikOrigin].filter(Boolean)
// Frame-src is intentionally broad: the application embeds user-supplied
// PeerTube instances in addition to known platforms (Tidal, Deezer, Spotify,
// Soundcloud, Apple Music). Restricting this to a fixed list would break
// user-generated content.
const frameSrc = ['\'self\'', 'https:']
const directives = [
'default-src \'self\'',
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src-elem 'self' 'nonce-${nonce}'`,
'style-src-attr \'unsafe-inline\'',
`img-src ${imgSrc.join(' ')}`,
'font-src \'self\'',
`connect-src ${connectSrc.join(' ')}`,
`frame-src ${frameSrc.join(' ')}`,
`media-src ${mediaSrc.join(' ')}`,
'manifest-src \'self\'',
'object-src \'none\'',
'base-uri \'self\'',
'form-action \'self\'',
'frame-ancestors \'self\'',
'upgrade-insecure-requests'
]
return directives.join('; ')
}
export function proxy(request) {
const nonce = generateNonce()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
const response = NextResponse.next({
request: {
headers: requestHeaders
}
})
response.headers.set('Content-Security-Policy', buildCsp(nonce))
return response
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{type: 'header', key: 'next-router-prefetch'},
{type: 'header', key: 'purpose', value: 'prefetch'}
]
}
]
}
+1 -1
View File
@@ -1 +1 @@
import '@testing-library/jest-dom/vitest' import '@testing-library/jest-dom/vitest' // eslint-disable-line import/no-unassigned-import
+110 -3238
View File
File diff suppressed because it is too large Load Diff