Compare commits
53
Commits
7234c1408f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc5c405bb
|
||
|
|
c8841a253f
|
||
|
|
4e6341c5d5
|
||
|
|
b5a3b278ba
|
||
|
|
99daeb014c
|
||
|
|
f5a4014e41
|
||
|
|
d66c679fd1
|
||
|
|
cab293d62e | ||
|
|
5936de59ff
|
||
|
|
6804e3626f
|
||
|
|
461b063cff
|
||
|
|
6601b00545
|
||
|
|
f88b0184ea
|
||
|
|
43964ec91d
|
||
|
|
ce19fa3b5d
|
||
|
|
33514bf633
|
||
|
|
35c39a97f3
|
||
|
|
72b86fdc5c
|
||
|
|
53287f9c80
|
||
|
|
8979970a90
|
||
|
|
75c5979478
|
||
|
|
5778d865e2
|
||
|
|
0ac18faf5c | ||
|
|
21248b7138
|
||
|
|
eda30345c6
|
||
|
|
b40e2d71b0
|
||
|
|
4bc545ac72
|
||
|
|
64cdd784c3
|
||
|
|
d2d09c28b3 | ||
|
|
eae8bb03a9
|
||
|
|
454eefbe58
|
||
|
|
b193914c57
|
||
|
|
d700ee6def
|
||
|
|
2132df3e0c
|
||
|
|
c7e3faf10a
|
||
|
|
4c662e89b5
|
||
|
|
2bec90210a
|
||
|
|
44e1a426b0
|
||
|
|
d2971870c1
|
||
|
|
1d8edbbcd5
|
||
|
|
a669abbfc2
|
||
|
|
69715a1a22
|
||
|
|
7b9968cb82
|
||
|
|
77f8cea399
|
||
|
|
c407a1fad9
|
||
|
|
c7104c6a0b
|
||
|
|
c4dca87b61
|
||
|
|
24f4f65853
|
||
|
|
3f30f699f7
|
||
|
|
399796bb73
|
||
|
|
b67bdf0632
|
||
|
|
9f9b408f1f
|
||
|
|
15367f388e
|
+8
-8
@@ -3,8 +3,7 @@ API_URL=http://localhost:1337/api
|
||||
NEXT_PUBLIC_API_URL=$API_URL
|
||||
NEXT_PUBLIC_API_URL_ROOT=http://localhost:1337
|
||||
SITE_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_READ_TOKEN=
|
||||
NEXT_PUBLIC_ADMIN_JWT_SECRET=
|
||||
API_READ_TOKEN=
|
||||
|
||||
# IDENTITÉ DU SITE (branding)
|
||||
NEXT_PUBLIC_SITE_NAME=PAWÒL-NU. Paroles et traductions.
|
||||
@@ -18,8 +17,8 @@ NEXT_PUBLIC_EXCLUSIVE_ARTIST_LABEL=OKI Exclusif
|
||||
|
||||
# FUNKWHALE VARIABLE
|
||||
NEXT_PUBLIC_OKI_MIZIK_URL=https://funkwhale-server.com
|
||||
NEXT_PUBLIC_MIZIK_API_USER=user
|
||||
NEXT_PUBLIC_MIZIK_API_PASSWORD=password
|
||||
MIZIK_API_USER=user
|
||||
MIZIK_API_PASSWORD=password
|
||||
|
||||
NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ=6
|
||||
NEXT_PUBLIC_SITE_URL=$SITE_URL
|
||||
@@ -77,18 +76,19 @@ NEXT_PUBLIC_LIBERAPAY_DONATE=
|
||||
NEXT_PUBLIC_PALE_USERNAME=
|
||||
NEXT_PUBLIC_GADE_USERNAME=
|
||||
NEXT_PUBLIC_YOUTUBE_USERNAME=
|
||||
NEXT_PUBLIC_TELEGRAM_GROUP=
|
||||
NEXT_PUBLIC_XMPP=
|
||||
NEXT_PUBLIC_GIT=
|
||||
NEXT_PUBLIC_CODEBERG=
|
||||
NEXT_PUBLIC_BLUESKY_URL=
|
||||
|
||||
# Commentaires fédérés (bokante.o-k-i.net)
|
||||
|
||||
NEXT_PUBLIC_BOKANTE_URL=https://bokante.o-k-i.net
|
||||
NEXT_PUBLIC_BOKANTE_ACCOUNT=pawol_nu
|
||||
|
||||
# DOMAIN IMAGE
|
||||
NEXT_PUBLIC_DOMAINS_IMAGE="localhost:1337 strapi.mondomaine.com"
|
||||
|
||||
# JWT SECRET
|
||||
NEXT_PUBLIC_JWT_SECRET=
|
||||
|
||||
# STRIPE
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
|
||||
|
||||
@@ -22,6 +22,12 @@ jobs:
|
||||
- name: Vérifier les dépendances
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Lancer le lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Lancer les tests
|
||||
run: yarn test
|
||||
|
||||
deploy-beta:
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -33,6 +39,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
set -e
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
cd ${{ secrets.FRONT_DEPLOY_PATH }}
|
||||
|
||||
@@ -20,6 +20,12 @@ jobs:
|
||||
- name: Vérifier les dépendances
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Lancer le lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Lancer les tests
|
||||
run: yarn test
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,6 +37,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
set -e
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
cd ${{ secrets.FRONT_DEPLOY_PATH }}
|
||||
|
||||
@@ -22,6 +22,12 @@ jobs:
|
||||
- name: Vérifier les dépendances
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Lancer le lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Lancer les tests
|
||||
run: yarn test
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -33,6 +39,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
set -e
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
cd ${{ secrets.PROD_DEPLOY_PATH }}
|
||||
|
||||
@@ -24,6 +24,24 @@ cp .env.sample .env
|
||||
yarn && yarn dev
|
||||
```
|
||||
|
||||
## Commentaires fédérés (Mastodon / bokante.o-k-i.net)
|
||||
|
||||
Les commentaires sous une parole peuvent provenir d'un compte pawol.nu ou du Fediverse
|
||||
(réponses reçues sur le statut miroir publié sur bokante.o-k-i.net par le backend). Un
|
||||
bouton permet de répondre directement depuis son propre compte Mastodon, quelle que soit
|
||||
son instance, via `authorize_interaction` — aucun compte pawol.nu requis.
|
||||
|
||||
**Variables d'environnement :**
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_BOKANTE_URL` | Instance Mastodon de l'organisation (défaut : `https://bokante.o-k-i.net`) |
|
||||
| `NEXT_PUBLIC_BOKANTE_ACCOUNT` | Compte bot dont les statuts servent de miroir (défaut : `pawol_nu`) |
|
||||
|
||||
Voir le RFC dédié pour le détail de la conception
|
||||
(`RFC-commentaires-activitypub-2026-07-04.md`, à la racine du dossier `PAWOL.NU`) et le
|
||||
`README.md` de `api.pawol.nu` pour la configuration côté backend.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2020 - 2026 Cédric Famibelle-Pronzola & ORGANISATION KA INTERNATIONALE (OKI)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {describe, it, expect, afterEach} from 'vitest'
|
||||
import robots from '../robots'
|
||||
|
||||
describe('robots', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
})
|
||||
|
||||
it('autorise l\'indexation en production', () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
expect(robots().rules).toEqual({userAgent: '*', allow: '/'})
|
||||
})
|
||||
|
||||
it('bloque l\'indexation hors production', () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
expect(robots().rules).toEqual({userAgent: '*', disallow: '/'})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import {jwennToutAwtis} from '../../../lib/oki-api'
|
||||
|
||||
export async function GET() {
|
||||
const data = await jwennToutAwtis()
|
||||
return Response.json(data)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {describe, it, expect, vi, afterEach} from 'vitest'
|
||||
import {GET} from '../route'
|
||||
|
||||
function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
body,
|
||||
headers: {
|
||||
get: key => headers[key.toLowerCase()] ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/mizik-stream/[id]', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('n\'expose pas les identifiants au client, uniquement l\'id dans l\'URL amont', async () => {
|
||||
global.fetch = vi.fn(async () => fakeUpstreamResponse())
|
||||
|
||||
const request = new Request('http://localhost/api/mizik-stream/42')
|
||||
await GET(request, {params: Promise.resolve({id: '42'})})
|
||||
|
||||
const [calledUrl] = global.fetch.mock.calls[0]
|
||||
expect(calledUrl).toContain('id=42')
|
||||
expect(calledUrl).toMatch(/[?&]u=/)
|
||||
expect(calledUrl).toMatch(/[?&]p=/)
|
||||
})
|
||||
|
||||
it('transmet l\'en-tête Range pour permettre le seek audio', async () => {
|
||||
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 206, headers: {'content-range': 'bytes 0-99/200'}}))
|
||||
|
||||
const request = new Request('http://localhost/api/mizik-stream/42', {headers: {range: 'bytes=0-99'}})
|
||||
const response = await GET(request, {params: Promise.resolve({id: '42'})})
|
||||
|
||||
const [, options] = global.fetch.mock.calls[0]
|
||||
expect(options.headers.range).toBe('bytes=0-99')
|
||||
expect(response.status).toBe(206)
|
||||
expect(response.headers.get('content-range')).toBe('bytes 0-99/200')
|
||||
})
|
||||
|
||||
it('renvoie le flux et le statut renvoyés par le serveur Funkwhale', async () => {
|
||||
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 200, headers: {'content-type': 'audio/mpeg'}}))
|
||||
|
||||
const request = new Request('http://localhost/api/mizik-stream/7')
|
||||
const response = await GET(request, {params: Promise.resolve({id: '7'})})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('audio/mpeg')
|
||||
})
|
||||
|
||||
it('renvoie une erreur JSON si le serveur audio répond en erreur', async () => {
|
||||
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 401, headers: {'content-type': 'application/json'}, body: '{"error": "unauthorized"}'}))
|
||||
|
||||
const request = new Request('http://localhost/api/mizik-stream/7')
|
||||
const response = await GET(request, {params: Promise.resolve({id: '7'})})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('content-type')).toContain('application/json')
|
||||
const json = await response.json()
|
||||
expect(json.error).toContain('401')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
|
||||
// Les variables publiques sont conservées en fallback pendant la migration
|
||||
// des déploiements qui utiliseraient encore l’ancien nommage.
|
||||
const MIZIK_API_USER = process.env.MIZIK_API_USER || process.env.NEXT_PUBLIC_MIZIK_API_USER || 'user'
|
||||
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || process.env.NEXT_PUBLIC_MIZIK_API_PASSWORD || 'password'
|
||||
|
||||
const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges']
|
||||
|
||||
export async function GET(request, props) {
|
||||
const {id} = await props.params
|
||||
|
||||
const upstreamUrl = `${MIZIK_URL}/rest/stream?u=${encodeURIComponent(MIZIK_API_USER)}&p=${encodeURIComponent(MIZIK_API_PASSWORD)}&id=${encodeURIComponent(id)}`
|
||||
const range = request.headers.get('range')
|
||||
|
||||
const upstreamResponse = await fetch(upstreamUrl, {
|
||||
headers: range ? {range} : {}
|
||||
})
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
const message = `Le serveur audio a répondu ${upstreamResponse.status}`
|
||||
console.error(message, upstreamUrl.replace(/p=[^&]+/, 'p=***'))
|
||||
return Response.json({error: message}, {status: upstreamResponse.status})
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
for (const key of FORWARDED_HEADERS) {
|
||||
const value = upstreamResponse.headers.get(key)
|
||||
if (value) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
status: upstreamResponse.status,
|
||||
headers
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {headers} from 'next/headers'
|
||||
import {notFound} from 'next/navigation'
|
||||
import Box from '@mui/material/Box'
|
||||
|
||||
@@ -20,7 +21,7 @@ async function jwennAwtis(slug) {
|
||||
}
|
||||
|
||||
export async function generateMetadata(props) {
|
||||
const params = await props.params;
|
||||
const params = await props.params
|
||||
const {slug} = params
|
||||
const anAwtis = await jwennAwtis(slug)
|
||||
|
||||
@@ -64,9 +65,11 @@ export async function generateMetadata(props) {
|
||||
}
|
||||
|
||||
export default async function AwtisPajSlug(props) {
|
||||
const params = await props.params;
|
||||
const params = await props.params
|
||||
const {slug} = params
|
||||
const anAwtis = await jwennAwtis(slug)
|
||||
const requestHeaders = await headers()
|
||||
const nonce = requestHeaders.get('x-nonce') || undefined
|
||||
|
||||
const {photo} = anAwtis
|
||||
const kuvetiFormat = formatKuveti(photo)
|
||||
@@ -91,6 +94,7 @@ export default async function AwtisPajSlug(props) {
|
||||
<section>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ async function jwennDone(paj) {
|
||||
}
|
||||
|
||||
export default async function AwitsPaj(props) {
|
||||
const searchParams = await props.searchParams;
|
||||
const searchParams = await props.searchParams
|
||||
const {paj} = searchParams
|
||||
const {pajTotal, awtisPouChakPaj, pajParsed} = await jwennDone(paj || 1)
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import {useServerInsertedHTML} from 'next/navigation'
|
||||
import {CacheProvider as DefaultCacheProvider} from '@emotion/react'
|
||||
|
||||
export default function NextAppDirEmotionCacheProvider(props) {
|
||||
const {options, CacheProvider = DefaultCacheProvider, children} = props
|
||||
const {options, CacheProvider = DefaultCacheProvider, children, nonce} = props
|
||||
|
||||
const [registry] = React.useState(() => {
|
||||
const cache = createCache(options)
|
||||
const cache = createCache({...options, nonce})
|
||||
cache.compat = true
|
||||
const prevInsert = cache.insert
|
||||
let inserted = []
|
||||
@@ -65,12 +65,14 @@ export default function NextAppDirEmotionCacheProvider(props) {
|
||||
<style
|
||||
key={name}
|
||||
dangerouslySetInnerHTML={{__html: style}}
|
||||
nonce={nonce}
|
||||
data-emotion={`${registry.cache.key}-global ${name}`}
|
||||
/>
|
||||
))}
|
||||
{styles !== '' && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{__html: styles}}
|
||||
nonce={nonce}
|
||||
data-emotion={dataEmotionAttribute}
|
||||
/>
|
||||
)}
|
||||
@@ -84,5 +86,6 @@ export default function NextAppDirEmotionCacheProvider(props) {
|
||||
NextAppDirEmotionCacheProvider.propTypes = {
|
||||
options: PropTypes.object.isRequired,
|
||||
CacheProvider: PropTypes.func,
|
||||
children: PropTypes.node.isRequired
|
||||
children: PropTypes.node.isRequired,
|
||||
nonce: PropTypes.string
|
||||
}
|
||||
|
||||
+9
-4
@@ -1,3 +1,4 @@
|
||||
import {headers} from 'next/headers'
|
||||
import PlausibleProvider from 'next-plausible'
|
||||
import TopLoader from '../components/top-loader'
|
||||
import Navigasyon from '../components/navigasyon'
|
||||
@@ -61,16 +62,20 @@ const jsonLd = {
|
||||
}
|
||||
|
||||
export default async function RootLayout({children}) {
|
||||
const requestHeaders = await headers()
|
||||
const nonce = requestHeaders.get('x-nonce') || undefined
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<TopLoader color='#ffeb3b' />
|
||||
<ThemeRegistry>
|
||||
<TopLoader color='#ffeb3b' nonce={nonce} />
|
||||
<ThemeRegistry nonce={nonce}>
|
||||
<Navigasyon />
|
||||
{children}
|
||||
</ThemeRegistry>
|
||||
<section>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
|
||||
/>
|
||||
</section>
|
||||
@@ -78,10 +83,10 @@ export default async function RootLayout({children}) {
|
||||
)
|
||||
|
||||
return (
|
||||
<html lang='fr' suppressHydrationWarning>
|
||||
<html suppressHydrationWarning lang='fr'>
|
||||
<body>
|
||||
{plausibleUrl
|
||||
? <PlausibleProvider src={plausibleUrl}>{inner}</PlausibleProvider>
|
||||
? <PlausibleProvider src={plausibleUrl} scriptProps={{nonce}}>{inner}</PlausibleProvider>
|
||||
: inner}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+3
-4
@@ -1,5 +1,3 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import Box from '@mui/material/Box'
|
||||
import Container from '@mui/material/Container'
|
||||
import {notFound} from 'next/navigation'
|
||||
@@ -9,10 +7,11 @@ import Statistik from '../components/akey/statistik'
|
||||
import Akey from '../components/akey'
|
||||
import AnVedette from '../components/akey/an-vedette'
|
||||
|
||||
import okiLogo from '../public/logo-512x512.png'
|
||||
import Footer from '../components/footer'
|
||||
import Aso from '../components/akey/aso'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function jwennDone() {
|
||||
const [statistik, denyeTeks, anVedette] = await Promise.all([jwennStats(), jwennDenyeTeks(), jwennAnVedette()])
|
||||
|
||||
@@ -28,7 +27,7 @@ export default async function Page() {
|
||||
|
||||
return (
|
||||
<Box sx={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}>
|
||||
<Akey logo={okiLogo} />
|
||||
<Akey />
|
||||
<Container sx={{flexGrow: 100}}>
|
||||
{dernierTeks && <AnVedette teks={dernierTeks} />}
|
||||
<Statistik statistik={statistik} />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {headers} from 'next/headers'
|
||||
import {notFound} from 'next/navigation'
|
||||
import Box from '@mui/material/Box'
|
||||
|
||||
import {jwennTeksEpiSlug} from '../../../lib/oki-api'
|
||||
import AnTeks from '../../../components/teks/an-teks'
|
||||
import {getAlias} from '../../../lib/utils/format'
|
||||
import {getAlias} from '../../../lib/utils/get-alias'
|
||||
import {formatKuveti} from '../../../lib/kuveti'
|
||||
import Footer from '../../../components/footer'
|
||||
|
||||
@@ -21,14 +22,14 @@ async function jwennAnTeks(slug) {
|
||||
}
|
||||
|
||||
export async function generateMetadata(props) {
|
||||
const params = await props.params;
|
||||
const params = await props.params
|
||||
const {slug} = params
|
||||
|
||||
const anTeks = await jwennAnTeks(slug)
|
||||
|
||||
const awtis = anTeks?.artistes?.length === 1 ? anTeks?.artistes[0].alias : getAlias(anTeks.artistes, anTeks.prioriteArtistes)
|
||||
const title = `PAWÒL-NU | ${awtis} - ${anTeks.titre}`
|
||||
const description = `Paroles de « ${anTeks?.titre} » : ${anTeks?.transcription.slice(0, 100)}...`
|
||||
const description = `Paroles de « ${anTeks?.titre} » : ${anTeks?.transcription?.slice(0, 100) ?? ''}...`
|
||||
const url = `${siteUrl}/paroles/${slug}`
|
||||
|
||||
const {couverture} = anTeks
|
||||
@@ -67,10 +68,12 @@ export async function generateMetadata(props) {
|
||||
}
|
||||
|
||||
export default async function AnPawolPaj(props) {
|
||||
const params = await props.params;
|
||||
const params = await props.params
|
||||
const {slug} = params
|
||||
|
||||
const anTeks = await jwennAnTeks(slug)
|
||||
const requestHeaders = await headers()
|
||||
const nonce = requestHeaders.get('x-nonce') || undefined
|
||||
const {couverture} = anTeks
|
||||
const teksKuvetiFormat = formatKuveti(couverture)
|
||||
|
||||
@@ -126,6 +129,7 @@ export default async function AnPawolPaj(props) {
|
||||
<section>
|
||||
<script
|
||||
type='application/ld+json'
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
+8
-17
@@ -1,7 +1,6 @@
|
||||
import {Suspense} from 'react'
|
||||
import {notFound} from 'next/navigation'
|
||||
import {jwennTeks} from '../../lib/oki-api'
|
||||
import TeksDrawer from '../../components/teks/teks-drawer'
|
||||
import PropTypes from 'prop-types'
|
||||
import TeksDrawerLoader from '../../components/teks/teks-drawer-loader'
|
||||
import {CurrentTrackProvider} from '../../components/teks/current-track-context'
|
||||
import Loading from './loading'
|
||||
|
||||
@@ -40,23 +39,11 @@ export const metadata = {
|
||||
}
|
||||
}
|
||||
|
||||
async function jwennDotDone() {
|
||||
const teks = await jwennTeks()
|
||||
|
||||
if (!teks) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return teks
|
||||
}
|
||||
|
||||
export default async function PawolLayout({children}) {
|
||||
const teks = await jwennDotDone()
|
||||
|
||||
export default function PawolLayout({children}) {
|
||||
return (
|
||||
<CurrentTrackProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<TeksDrawer paroles={teks} />
|
||||
<TeksDrawerLoader />
|
||||
</Suspense>
|
||||
<section>
|
||||
{children}
|
||||
@@ -64,3 +51,7 @@ export default async function PawolLayout({children}) {
|
||||
</CurrentTrackProvider>
|
||||
)
|
||||
}
|
||||
|
||||
PawolLayout.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export default function robots() {
|
||||
const isProduction = process.env.NEXT_PUBLIC_ENV === 'production';
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
if (!isProduction) {
|
||||
return {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export default function Sipote() {
|
||||
<Box sx={{display: 'flex', marginTop: 10, flexDirection: 'column', minHeight: '100vh'}}>
|
||||
<Box sx={{flexGrow: 1, marginBottom: 10}}>
|
||||
<Container>
|
||||
<Typography gutterBottom sx={{fontWeight: 'bold', textAlign:'center' }} variant='h4' component='h1'>
|
||||
<Typography gutterBottom sx={{fontWeight: 'bold', textAlign: 'center'}} variant='h4' component='h1'>
|
||||
Soutenir ORGANISATION KA INTERNATIONALE !
|
||||
</Typography>
|
||||
<Paper sx={{padding: 2, marginTop: 3}}>
|
||||
|
||||
@@ -31,10 +31,6 @@ export default async function sitemap() {
|
||||
{
|
||||
url: `${url}/awtis`,
|
||||
priority: 0.6
|
||||
},
|
||||
{
|
||||
url: `${url}/pwopose`,
|
||||
priority: 0.5
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -64,12 +64,12 @@ const theme = createTheme({
|
||||
})
|
||||
|
||||
export default function ThemeRegistry(props) {
|
||||
const {children} = props
|
||||
const {children, nonce} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<InitColorSchemeScript attribute='class' />
|
||||
<NextAppDirEmotionCacheProvider options={{key: 'mui'}}>
|
||||
<InitColorSchemeScript attribute='class' nonce={nonce} />
|
||||
<NextAppDirEmotionCacheProvider options={{key: 'mui'}} nonce={nonce}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline enableColorScheme />
|
||||
<ChanjeTem />
|
||||
@@ -81,5 +81,6 @@ export default function ThemeRegistry(props) {
|
||||
}
|
||||
|
||||
ThemeRegistry.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
children: PropTypes.node.isRequired,
|
||||
nonce: PropTypes.string
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import Chip from '@mui/material/Chip'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {getAlias} from '../../lib/utils/format'
|
||||
import {getAlias} from '../../lib/utils/get-alias'
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
@@ -36,10 +36,10 @@ export default function AnVedette({teks}) {
|
||||
<Box sx={{position: 'relative', width: '100%', aspectRatio: {xs: '1 / 1', sm: '16 / 9'}, maxHeight: {sm: 420}}}>
|
||||
{fmt?.url ? (
|
||||
<Image
|
||||
src={`${IMAGE_URL}${fmt.url}`}
|
||||
alt={titre}
|
||||
fill
|
||||
priority
|
||||
src={`${IMAGE_URL}${fmt.url}`}
|
||||
alt={titre}
|
||||
placeholder='blur'
|
||||
blurDataURL={BLUR_DATA_URL}
|
||||
sizes='(max-width: 600px) 100vw, 800px'
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import Container from '@mui/material/Container'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Box from '@mui/material/Box'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import Button from '@mui/material/Button'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Akey({logo}) {
|
||||
export default function Akey() {
|
||||
return (
|
||||
<Box sx={{flexGrow: 1, marginBottom: 3, marginTop: 7}}>
|
||||
<Container align='center'>
|
||||
@@ -25,7 +20,3 @@ export default function Akey({logo}) {
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Akey.propTypes = {
|
||||
logo: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ export default function AwtisDetay({anAwtis}) {
|
||||
|
||||
const hasStreaming = isExclusiveArtist && titrePhare?.streamAudio?.length > 0
|
||||
const coverUrl = titrePhare?.couverture
|
||||
? `${IMAGE_URL}${titrePhare.couverture.formats?.small?.url || titrePhare.couverture.formats?.thumbnail?.url || titrePhare.couverture.url}`
|
||||
? `${IMAGE_URL}${formatKuveti(titrePhare.couverture, 'small')?.url}`
|
||||
: null
|
||||
|
||||
const photoUrl = photo?.url
|
||||
? `${IMAGE_URL}${photo.formats?.small?.url || photo.formats?.thumbnail?.url || photo.url}`
|
||||
? `${IMAGE_URL}${formatKuveti(photo, 'small')?.url}`
|
||||
: null
|
||||
|
||||
return (
|
||||
@@ -72,7 +72,8 @@ export default function AwtisDetay({anAwtis}) {
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
mb: 2,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{photoUrl ? (
|
||||
<Image
|
||||
src={photoUrl}
|
||||
@@ -94,8 +95,8 @@ export default function AwtisDetay({anAwtis}) {
|
||||
|
||||
{rezoSosyal?.length > 0 && (
|
||||
<Box sx={{display: 'flex', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center', mb: 1.5}}>
|
||||
{rezoSosyal.map((rezo, i) => (
|
||||
<SocialButton key={i} rezo={rezo} />
|
||||
{rezoSosyal.map(rezo => (
|
||||
<SocialButton key={rezo.url} rezo={rezo} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
@@ -149,9 +150,9 @@ export default function AwtisDetay({anAwtis}) {
|
||||
<Link href={`/paroles/${titrePhare.slug}`} style={{display: 'block', height: '100%'}}>
|
||||
<Box sx={{position: 'relative', minHeight: 130, height: '100%'}}>
|
||||
<Image
|
||||
fill
|
||||
src={coverUrl}
|
||||
alt={titrePhare.titre}
|
||||
fill
|
||||
placeholder='blur'
|
||||
blurDataURL={BLUR_DATA_URL}
|
||||
sizes='200px'
|
||||
@@ -163,7 +164,7 @@ export default function AwtisDetay({anAwtis}) {
|
||||
)}
|
||||
<Grid size={{xs: 12, sm: coverUrl ? 8 : 12}}>
|
||||
<CardContent>
|
||||
<Typography variant='subtitle1' sx={{fontWeight: 700}} gutterBottom>
|
||||
<Typography gutterBottom variant='subtitle1' sx={{fontWeight: 700}}>
|
||||
<Link href={`/paroles/${titrePhare.slug}`} style={{color: 'inherit', textDecoration: 'none'}}>
|
||||
{titrePhare.titre}
|
||||
</Link>
|
||||
@@ -172,8 +173,8 @@ export default function AwtisDetay({anAwtis}) {
|
||||
Écouter sur
|
||||
</Typography>
|
||||
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1}}>
|
||||
{titrePhare.streamAudio.map((lyen, i) => (
|
||||
<StreamButton key={i} lyen={lyen} />
|
||||
{titrePhare.streamAudio.map(lyen => (
|
||||
<StreamButton key={lyen.url} lyen={lyen} />
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
@@ -193,7 +194,7 @@ export default function AwtisDetay({anAwtis}) {
|
||||
<AccordionDetails sx={{paddingInline: 0}}>
|
||||
{sortedTeks.map(anPawol => {
|
||||
const {couverture} = anPawol
|
||||
const kuvetiFormat = couverture?.formats?.thumbnail || formatKuveti(couverture)
|
||||
const kuvetiFormat = formatKuveti(couverture, 'thumbnail')
|
||||
return (
|
||||
<Box key={anPawol.id} sx={{paddingBlock: 0.5}}>
|
||||
<MizikLyen anPawol={anPawol} kuveti={kuvetiFormat} />
|
||||
@@ -202,7 +203,7 @@ export default function AwtisDetay({anAwtis}) {
|
||||
})}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
) : paroles.length === 0 ? (
|
||||
) : (paroles.length === 0 ? (
|
||||
<Typography textAlign='center' variant='body1' color='text.secondary'>
|
||||
Aucune parole pour le moment
|
||||
</Typography>
|
||||
@@ -210,10 +211,10 @@ export default function AwtisDetay({anAwtis}) {
|
||||
<Box>
|
||||
<Typography gutterBottom variant='body1'><strong>Parole</strong></Typography>
|
||||
<Paper sx={{paddingBlock: 2}}>
|
||||
<MizikLyen anPawol={paroles[0]} kuveti={paroles[0].couverture?.formats?.thumbnail || formatKuveti(paroles[0].couverture)} />
|
||||
<MizikLyen anPawol={paroles[0]} kuveti={formatKuveti(paroles[0].couverture, 'thumbnail')} />
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ import Chip from '@mui/material/Chip'
|
||||
import {styled} from '@mui/material/styles'
|
||||
import VerifiedIcon from '@mui/icons-material/Verified'
|
||||
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
import AwtisBiyografi from './awtis-biyografi'
|
||||
|
||||
const PREFIX = 'awtis-kat'
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3001'
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
const EXCLUSIVE_LABEL = process.env.NEXT_PUBLIC_EXCLUSIVE_ARTIST_LABEL || 'OKI Exclusif'
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function AwtisKat({artiste}) {
|
||||
className={classes.media}
|
||||
component='img'
|
||||
alt={alias}
|
||||
image={`${photo?.url ? `${IMAGE_URL}${photo?.formats?.thumbnail?.url || photo?.url}` : noImageUrl}`}
|
||||
image={`${photo?.url ? `${IMAGE_URL}${formatKuveti(photo, 'thumbnail')?.url}` : noImageUrl}`}
|
||||
loading='lazy'
|
||||
title={alias}
|
||||
/>
|
||||
|
||||
@@ -5,10 +5,9 @@ import {useRouter} from 'next/navigation'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Autocomplete from '@mui/material/Autocomplete'
|
||||
import Avatar from '@mui/material/Avatar'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Container from '@mui/material/Container'
|
||||
|
||||
import {jwennToutAwtis} from '../../lib/oki-api'
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
|
||||
@@ -27,7 +26,7 @@ export default function ChecheAwtis() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const {data} = await jwennToutAwtis()
|
||||
const {data} = await fetch('/api/awtis').then(response => response.json())
|
||||
|
||||
const filteredData = data.map(artiste => {
|
||||
const firstLetter = artiste.alias[0].toUpperCase()
|
||||
@@ -49,15 +48,13 @@ export default function ChecheAwtis() {
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
return [...options].sort((a, b) =>
|
||||
const sortedOptions = useMemo(() => [...options].sort((a, b) =>
|
||||
-b.firstLetter.localeCompare(a.firstLetter)
|
||||
);
|
||||
}, [options]);
|
||||
), [options])
|
||||
|
||||
return (
|
||||
<Container
|
||||
sx={{ display: 'flex', justifyContent: 'center', marginBottom: 3 }}
|
||||
sx={{display: 'flex', justifyContent: 'center', marginBottom: 3}}
|
||||
>
|
||||
<Autocomplete
|
||||
autoHighlight
|
||||
@@ -69,35 +66,35 @@ export default function ChecheAwtis() {
|
||||
noOptionsText='Aucun résultat'
|
||||
id='cheche-awtis'
|
||||
options={sortedOptions}
|
||||
groupBy={(option) => option?.firstLetter}
|
||||
getOptionLabel={(option) => option?.alias}
|
||||
renderOption={(props, option) => {
|
||||
const {key, ...rest} = props;
|
||||
groupBy={option => option?.firstLetter}
|
||||
getOptionLabel={option => option?.alias}
|
||||
renderOption={(optionProps, option) => {
|
||||
const {key, ...rest} = optionProps
|
||||
|
||||
return (
|
||||
<li key={key} {...rest}>
|
||||
<Avatar
|
||||
style={{ marginRight: 8 }}
|
||||
style={{marginRight: 8}}
|
||||
alt={option?.alias}
|
||||
src={`${IMAGE_URL}${option?.photo?.formats?.thumbnail?.url || option?.photo?.url || ''}`}
|
||||
src={`${IMAGE_URL}${formatKuveti(option?.photo, 'thumbnail')?.url || ''}`}
|
||||
/>
|
||||
{option?.alias}
|
||||
</li>
|
||||
);
|
||||
)
|
||||
}}
|
||||
sx={{ width: 300 }}
|
||||
renderInput={(params) => (
|
||||
sx={{width: 300}}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
label='Rechercher un artiste'
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
htmlInput: { ...params.slotProps.htmlInput },
|
||||
htmlInput: {...params.slotProps.htmlInput},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onChange={(event, newValue) => {
|
||||
router.push(`/awtis/${newValue.slug}`);
|
||||
router.push(`/awtis/${newValue.slug}`)
|
||||
}}
|
||||
onOpen={() => setOpen(true)}
|
||||
onClose={() => setOpen(false)}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function MizikLis({niAwtis, paroles, meteEsMobilOuve}) {
|
||||
itemContent={index => {
|
||||
const anPawol = pawol[index]
|
||||
const {couverture} = anPawol
|
||||
const kuvetiFormat = couverture?.formats?.thumbnail || formatKuveti(couverture)
|
||||
const kuvetiFormat = formatKuveti(couverture, 'thumbnail')
|
||||
|
||||
return (
|
||||
<MizikLyen niAwtis={niAwtis} anPawol={anPawol} kuveti={kuvetiFormat} slug={params.slug} meteEsMobilOuve={meteEsMobilOuve} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import LibraryMusicIcon from '@mui/icons-material/LibraryMusic'
|
||||
import ExplicitIcon from '@mui/icons-material/Explicit'
|
||||
|
||||
import {esBrandNew} from '../../lib/date'
|
||||
import {getAlias} from '../../lib/utils/format'
|
||||
import {getAlias} from '../../lib/utils/get-alias'
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function MizikLyen({niAwtis, anPawol, kuveti, slug, meteEsMobilOu
|
||||
return (
|
||||
<Link
|
||||
passHref
|
||||
prefetch={false}
|
||||
href={`/paroles/${anPawol.slug}#${anPawol.slug}`}
|
||||
style={{textDecoration: 'none', width: '100%', display: 'flex', alignItems: 'center'}}
|
||||
onClick={() => meteEsMobilOuve(false)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
@@ -59,15 +60,22 @@ export function SocialButton({rezo}) {
|
||||
return (
|
||||
<Tooltip title={rezo.url}>
|
||||
<Chip
|
||||
clickable
|
||||
label={config.label}
|
||||
component='a'
|
||||
href={rezo.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
clickable
|
||||
size='small'
|
||||
sx={{bgcolor: config.bg, color: config.color, fontWeight: 600}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
SocialButton.propTypes = {
|
||||
rezo: PropTypes.shape({
|
||||
plateforme: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function LicenseModal({license, sourceOriginale, remixes}) {
|
||||
<DialogContent dividers>
|
||||
{sourceOriginale && (
|
||||
<Box sx={{mb: 3}}>
|
||||
<Typography variant='overline' color='text.secondary' display='block' gutterBottom>
|
||||
<Typography gutterBottom variant='overline' color='text.secondary' display='block'>
|
||||
Basé sur
|
||||
</Typography>
|
||||
<Link href={`/paroles/${sourceOriginale.slug}`} underline='hover' color='inherit'>
|
||||
@@ -76,7 +76,7 @@ export default function LicenseModal({license, sourceOriginale, remixes}) {
|
||||
)}
|
||||
{remixes && (
|
||||
<Box sx={{mb: 3}}>
|
||||
<Typography variant='overline' color='text.secondary' display='block' gutterBottom>
|
||||
<Typography gutterBottom variant='overline' color='text.secondary' display='block'>
|
||||
Déclinaisons
|
||||
</Typography>
|
||||
{remixes.map(remix => (
|
||||
|
||||
@@ -530,7 +530,7 @@ export default function Cgu() {
|
||||
10. Propriété intellectuelle et licence
|
||||
</Typography>
|
||||
|
||||
<Typography paragraph='true' gutterBottom variant='body1'>
|
||||
<Typography gutterBottom paragraph='true' variant='body1'>
|
||||
Le site internet <Link href='https://pawol.nu'><strong>pawol.nu</strong></Link> et les éléments qui y sont accessibles
|
||||
(textes, images, graphismes, logos, vidéos, icônes, sons, etc.) sont, sauf mention contraire,
|
||||
mis à disposition sous la licence <Link href='https://www.gnu.org/licenses/agpl-3.0.html' target='_blank' rel='noopener noreferrer'> <strong>GNU Affero General Public License Version 3 (AGPL-3.0)</strong></Link>. Cette licence garantit aux utilisateurs les libertés suivantes :
|
||||
|
||||
@@ -70,12 +70,14 @@ export default function FilesList({files}) {
|
||||
|
||||
useEffect(() => {
|
||||
const audioFiles = files.filter(f => f.mime.startsWith('audio'))
|
||||
if (audioFiles.length === 0) return
|
||||
if (audioFiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function fetchAllMeta() {
|
||||
const mm = await import('music-metadata-browser')
|
||||
const mm = await import('music-metadata-browser') // eslint-disable-line node/no-unsupported-features/es-syntax
|
||||
const results = {}
|
||||
await Promise.all(
|
||||
audioFiles.map(async file => {
|
||||
@@ -98,7 +100,9 @@ export default function FilesList({files}) {
|
||||
}
|
||||
})
|
||||
)
|
||||
if (!cancelled) setAudioMeta(results)
|
||||
if (!cancelled) {
|
||||
setAudioMeta(results)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAllMeta()
|
||||
@@ -121,7 +125,8 @@ export default function FilesList({files}) {
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '0.05rem',
|
||||
textTransform: 'uppercase',
|
||||
}}>{isHaute ? 'Haute' : 'Faible'}</Typography>
|
||||
}}
|
||||
>{isHaute ? 'Haute' : 'Faible'}</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,12 +155,16 @@ export default function FilesList({files}) {
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
Object.values(controllersRef.current).forEach(c => c.abort())
|
||||
for (const c of Object.values(controllersRef.current)) {
|
||||
c.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClick = async (e, url, fileName, fileId) => {
|
||||
e.stopPropagation()
|
||||
if (fileId in downloading) return
|
||||
if (fileId in downloading) {
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
controllersRef.current[fileId] = controller
|
||||
@@ -163,14 +172,18 @@ export default function FilesList({files}) {
|
||||
setDownloading(prev => ({...prev, [fileId]: 0}))
|
||||
try {
|
||||
const response = await fetch(url, {signal: controller.signal})
|
||||
const contentLength = +response.headers.get('content-length')
|
||||
const contentLength = Number(response.headers.get('content-length'))
|
||||
const reader = response.body.getReader()
|
||||
const chunks = []
|
||||
let received = 0
|
||||
|
||||
while (true) {
|
||||
for (;;) {
|
||||
// eslint-disable-next-line no-await-in-loop -- chaque chunk dépend du précédent, la lecture est intrinsèquement séquentielle
|
||||
const {done, value} = await reader.read()
|
||||
if (done) break
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
chunks.push(value)
|
||||
received += value.length
|
||||
if (contentLength) {
|
||||
@@ -186,7 +199,9 @@ export default function FilesList({files}) {
|
||||
a.click()
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') throw error
|
||||
if (error.name !== 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
delete controllersRef.current[fileId]
|
||||
setDownloading(prev => {
|
||||
|
||||
@@ -48,10 +48,10 @@ export default function KitsDialog({kits}) {
|
||||
<DialogTitle>Télécharger un kit</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<List>
|
||||
{kits.map((kit, index) => {
|
||||
{kits.map(kit => {
|
||||
const PlatformIcon = PLATFORM_ICON[kit.plateforme] ?? CloudDownloadIcon
|
||||
return (
|
||||
<ListItem key={index} disablePadding>
|
||||
<ListItem key={kit.url} disablePadding>
|
||||
<ListItemButton component='a' href={kit.url} target='_blank' rel='noopener noreferrer'>
|
||||
<ListItemIcon>
|
||||
<PlatformIcon />
|
||||
|
||||
@@ -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 s’est 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import {useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {useRouter, usePathname} from 'next/navigation'
|
||||
|
||||
import {styled} from '@mui/material/styles'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Tabs, {tabsClasses} from '@mui/material/Tabs'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Box from '@mui/material/Box'
|
||||
import {useMediaQuery} from '@mui/material'
|
||||
|
||||
import MusicNoteIcon from '@mui/icons-material/MusicNote'
|
||||
@@ -49,32 +45,6 @@ const tabPath = {
|
||||
awtis: 3,
|
||||
}
|
||||
|
||||
function TabPanel(props) {
|
||||
const {children, value, index, ...other} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
role='tabpanel'
|
||||
hidden={value !== index}
|
||||
id={`scrollable-force-tabpanel-${index}`}
|
||||
aria-labelledby={`scrollable-force-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box p={3}>
|
||||
<Typography component='div'>{children}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
TabPanel.propTypes = {
|
||||
children: PropTypes.node,
|
||||
index: PropTypes.any.isRequired,
|
||||
value: PropTypes.any.isRequired
|
||||
}
|
||||
|
||||
function a11yProps(index) {
|
||||
return {
|
||||
id: `scrollable-force-tab-${index}`,
|
||||
@@ -89,11 +59,8 @@ export default function Navigasyon() {
|
||||
const selectedTab = tabPath[selectedPath]
|
||||
|
||||
const isMobile = useMediaQuery('(max-width:422px)')
|
||||
const [value, setValue] = useState(0)
|
||||
|
||||
const handleChange = (event, newValue) => {
|
||||
setValue(newValue)
|
||||
|
||||
if (newValue !== selectedTab) {
|
||||
router.push(tabRouteHref[newValue])
|
||||
}
|
||||
@@ -103,7 +70,8 @@ export default function Navigasyon() {
|
||||
<Appdiv>
|
||||
<div className={classes.root}>
|
||||
<AppBar style={{zIndex: 1, boxShadow: 'none'}} position='fixed' color='default'>
|
||||
{selectedTab !== undefined && <Tabs
|
||||
{selectedTab !== undefined && (
|
||||
<Tabs
|
||||
allowScrollButtonsMobile
|
||||
scrollButtons
|
||||
centered={Boolean(!isMobile)}
|
||||
@@ -124,12 +92,8 @@ export default function Navigasyon() {
|
||||
<Tab icon={<MusicNoteIcon />} aria-label='Paroles' {...a11yProps(2)} />
|
||||
<Tab icon={<RecordVoiceOverIcon />} aria-label='Artistes' {...a11yProps(3)} />
|
||||
</Tabs>
|
||||
}
|
||||
)}
|
||||
</AppBar>
|
||||
<TabPanel value={value} index={0} />
|
||||
<TabPanel value={value} index={1} />
|
||||
<TabPanel value={value} index={2} />
|
||||
<TabPanel value={value} index={3} />
|
||||
</div>
|
||||
</Appdiv>
|
||||
)
|
||||
|
||||
@@ -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 s’est 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
|
||||
}
|
||||
@@ -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 s’est 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,
|
||||
}
|
||||
@@ -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 l’adresse 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 s’est 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 d’utilisateur 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='S’inscrire' {...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 d’utilisateur</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 l’e-mail de confirmation
|
||||
</Typography>
|
||||
</Button>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
<ResetDialog
|
||||
activation
|
||||
lyen='send-email-confirmation'
|
||||
title='Envoi de l’e-mail de confirmation'
|
||||
content='Si vous n’avez pas reçu l’e-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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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: '🇫🇷'
|
||||
},
|
||||
en: {
|
||||
title: 'Translation',
|
||||
help: 'Translation of the lyrics',
|
||||
emoji: '🇬🇧'
|
||||
},
|
||||
es: {
|
||||
title: 'Traducción',
|
||||
help: 'Traducción de la letra',
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
de: {
|
||||
title: 'Übersetzung',
|
||||
help: 'Übersetzung von Liedtexten',
|
||||
emoji: '🇩🇪'
|
||||
},
|
||||
it: {
|
||||
title: 'Traduzione',
|
||||
help: 'Traduzione del testo',
|
||||
emoji: '🇮🇹'
|
||||
},
|
||||
pt: {
|
||||
title: 'Tradução',
|
||||
help: 'Tradução da letra',
|
||||
emoji: '🇧🇷'
|
||||
},
|
||||
ja: {
|
||||
title: '翻訳',
|
||||
help: '歌詞の翻訳',
|
||||
emoji: '🇯🇵'
|
||||
},
|
||||
ko: {
|
||||
title: '번역',
|
||||
help: '가사 번역',
|
||||
emoji: '🇰🇷'
|
||||
}
|
||||
}
|
||||
|
||||
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 l’artiste (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 s’est 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt'
|
||||
|
||||
import CGUDialog from '../cgu/cgu-dialog'
|
||||
|
||||
const CGU_PDF_LINK = process.env.NEXT_PUBLIC_CGU_DOWNLOAD_LINK
|
||||
|
||||
export default function Presantasyon() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import Button from '@mui/material/Button'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
import {Spotify, Applemusic, Deezer, Tidal, Youtubemusic, Amazon, Soundcloud} from '@icons-pack/react-simple-icons'
|
||||
@@ -39,3 +40,10 @@ export function StreamButton({lyen}) {
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
StreamButton.propTypes = {
|
||||
lyen: PropTypes.shape({
|
||||
plateforme: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import Komante from '../komante'
|
||||
|
||||
describe('Komante', () => {
|
||||
it('ne rend rien quand il n\'y a aucun commentaire', () => {
|
||||
const {container} = render(<Komante commentaires={[]} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('affiche un commentaire local avec le pseudo pawol.nu', () => {
|
||||
render(<Komante commentaires={[
|
||||
{id: 1, contenu: 'Bèl tèks', origine: 'local', user: {username: 'foo'}}
|
||||
]} />)
|
||||
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bèl tèks')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Mastodon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche un commentaire fédéré avec le badge et le lien Mastodon', () => {
|
||||
render(<Komante commentaires={[{
|
||||
id: 2,
|
||||
contenu: 'Trè bèl',
|
||||
origine: 'activitypub',
|
||||
auteurNom: 'Quelqu\'un',
|
||||
auteurHandle: '@quelqun@mastodon.social',
|
||||
auteurProfilUrl: 'https://mastodon.social/@quelqun',
|
||||
remoteUrl: 'https://bokante.o-k-i.net/@quelqun/1'
|
||||
}]} />)
|
||||
|
||||
expect(screen.getByText('Quelqu\'un')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mastodon')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', {name: 'Quelqu\'un'})).toHaveAttribute('href', 'https://mastodon.social/@quelqun')
|
||||
expect(screen.getByRole('link', {name: /voir sur mastodon/i})).toHaveAttribute('href', 'https://bokante.o-k-i.net/@quelqun/1')
|
||||
})
|
||||
|
||||
it('met en valeur les mentions @ dans le contenu', () => {
|
||||
render(<Komante commentaires={[{
|
||||
id: 3,
|
||||
contenu: '@pawol_nu oh oh ye ye, avec @cybermawonaj@bokante.o-k-i.net aussi',
|
||||
origine: 'activitypub',
|
||||
auteurNom: 'Quelqu\'un'
|
||||
}]} />)
|
||||
|
||||
const mansyon = screen.getByText('@pawol_nu')
|
||||
expect(mansyon).toBeInTheDocument()
|
||||
expect(screen.getByText('@cybermawonaj@bokante.o-k-i.net')).toBeInTheDocument()
|
||||
expect(screen.getByText(/oh oh ye ye/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import {describe, it, expect, vi, afterEach} from 'vitest'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReponsMastodon from '../repons-mastodon'
|
||||
|
||||
describe('ReponsMastodon', () => {
|
||||
const originalOpen = window.open
|
||||
|
||||
afterEach(() => {
|
||||
window.open = originalOpen
|
||||
})
|
||||
|
||||
it('ne rend rien sans bokanteStatusId', () => {
|
||||
const {container} = render(<ReponsMastodon bokanteStatusId={null} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('affiche un lien direct vers le fil BOKANTE', () => {
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
const lien = screen.getByRole('link', {name: 'BOKANTE (Mastodon)'})
|
||||
expect(lien).toHaveAttribute('href', 'https://bokante.o-k-i.net/@pawol_nu/112233')
|
||||
})
|
||||
|
||||
it('redirige vers BOKANTE par défaut quand on continue', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://bokante.o-k-i.net/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('permet de choisir une autre instance dans le select', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
|
||||
await user.click(screen.getByLabelText(/instance mastodon/i))
|
||||
await user.click(screen.getByRole('option', {name: /autre instance/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepte un identifiant complet pour une autre instance', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
|
||||
await user.click(screen.getByLabelText(/instance mastodon/i))
|
||||
await user.click(screen.getByRole('option', {name: /autre instance/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), '@quelqun@mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,6 @@ export default function AnTeks({parole, paroleId}) {
|
||||
<Teks
|
||||
parole={parole}
|
||||
paroleId={paroleId}
|
||||
commentaires={parole?.commentaires}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, {forwardRef, useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {format} from 'date-fns'
|
||||
import {fr} from 'date-fns/locale'
|
||||
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
@@ -50,7 +47,7 @@ export default function DiferansDialog({difference}) {
|
||||
<List sx={{width: '100%', maxWidth: 360, bgcolor: 'background.paper'}}>
|
||||
{difference.map(({id, admin_user, date, jsonDiff}) => {
|
||||
const {firstname} = admin_user
|
||||
const diferansDate = format(new Date(date), 'PPPppp', {locale: fr})
|
||||
const diferansDate = new Intl.DateTimeFormat(undefined, {dateStyle: 'full', timeStyle: 'medium'}).format(new Date(date))
|
||||
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {Box, IconButton} from '@mui/material'
|
||||
import PropTypes from 'prop-types'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {usePathname} from 'next/navigation'
|
||||
|
||||
import {
|
||||
Tidal,
|
||||
@@ -69,10 +69,12 @@ function RannIframe({plateforme, url, titre, isMobile}) {
|
||||
{plateforme === 'Tidal' && (
|
||||
<iframe
|
||||
src={src}
|
||||
title={titre}
|
||||
allowFullScreen='allowfullscreen'
|
||||
frameBorder='0'
|
||||
width={`${isMobile ? '100%' : '50%'}`}
|
||||
height='96px'
|
||||
sandbox='allow-same-origin allow-scripts allow-popups'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -90,38 +92,44 @@ function RannIframe({plateforme, url, titre, isMobile}) {
|
||||
|
||||
{plateforme === 'Deezer' && (
|
||||
<iframe
|
||||
title='deezer-widget'
|
||||
title={titre || 'deezer-widget'}
|
||||
frameBorder='0'
|
||||
src={src}
|
||||
allow='encrypted-media; clipboard-write'
|
||||
allowtransparency='true'
|
||||
sandbox='allow-same-origin allow-scripts allow-popups'
|
||||
/>
|
||||
)}
|
||||
|
||||
{plateforme === 'Spotify' && (
|
||||
<iframe
|
||||
src={src}
|
||||
title={titre}
|
||||
width={`${isMobile ? '100%' : '50%'}`}
|
||||
height='80'
|
||||
frameBorder='0'
|
||||
allowtransparency='true'
|
||||
allow='encrypted-media'
|
||||
sandbox='allow-same-origin allow-scripts allow-popups'
|
||||
/>
|
||||
)}
|
||||
|
||||
{plateforme === 'Soundcloud' && (
|
||||
<iframe
|
||||
src={src}
|
||||
title={titre}
|
||||
width={`${isMobile ? '100%' : '50%'}`}
|
||||
height='166'
|
||||
scrolling='no'
|
||||
frameBorder='no'
|
||||
sandbox='allow-same-origin allow-scripts allow-popups'
|
||||
/>
|
||||
)}
|
||||
|
||||
{plateforme === 'Applemusic' && (
|
||||
<iframe
|
||||
src={src}
|
||||
title={titre}
|
||||
width={`${isMobile ? '100%' : '50%'}`}
|
||||
height='175'
|
||||
frameBorder='0'
|
||||
@@ -136,7 +144,7 @@ function RannIframe({plateforme, url, titre, isMobile}) {
|
||||
|
||||
function EntegreMizik({parole, isMobile}) {
|
||||
const [chwaMizik, meteChwaMizik] = useState(null)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const {streamAudio, slug, okiMizikID, gadeEmbed, titre} = parole
|
||||
|
||||
const filteredKouteyAchtey = streamAudio.filter(({plateforme}) => plateforme === 'Tidal' || plateforme === 'Spotify' || plateforme === 'Deezer' || plateforme === 'Soundcloud' || plateforme === 'Applemusic')
|
||||
@@ -150,10 +158,10 @@ function EntegreMizik({parole, isMobile}) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (router.asPath === `/paroles/${slug}` || router.asPath === `/paroles/${slug}#${slug}`) {
|
||||
if (pathname === `/paroles/${slug}`) {
|
||||
meteChwaMizik(null)
|
||||
}
|
||||
}, [router, slug])
|
||||
}, [pathname, slug])
|
||||
|
||||
return (
|
||||
<Box align='center'>
|
||||
@@ -162,7 +170,7 @@ function EntegreMizik({parole, isMobile}) {
|
||||
key={id}
|
||||
aria-label='player'
|
||||
size='large'
|
||||
onClick={() => handleClick(plateforme, url)}
|
||||
onClick={() => handleClick(plateforme, url, titre)}
|
||||
>
|
||||
{kouteyAchteyIcons[plateforme]}
|
||||
</IconButton>
|
||||
|
||||
@@ -21,7 +21,7 @@ const pulse = keyframes`
|
||||
`
|
||||
|
||||
const SlideUp = forwardRef(function SlideUp(props, ref) {
|
||||
return <Slide direction='up' ref={ref} {...props} />
|
||||
return <Slide ref={ref} direction='up' {...props} />
|
||||
})
|
||||
|
||||
function toEmbedUrl(url) {
|
||||
@@ -31,6 +31,7 @@ function toEmbedUrl(url) {
|
||||
if (match) {
|
||||
return `${u.origin}/videos/embed/${match[1]}?title=0&warningTitle=0&peertubeLink=0&controlBar=1`
|
||||
}
|
||||
|
||||
return url
|
||||
} catch {
|
||||
return url
|
||||
@@ -45,7 +46,9 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
const activeUrl = (!isMobile && desktopUrl) ? desktopUrl : url
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setLoaded(false)
|
||||
if (!open) {
|
||||
setLoaded(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
@@ -55,13 +58,13 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
color='primary'
|
||||
size={isMobile ? 'medium' : 'large'}
|
||||
aria-label='karaoké'
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: {xs: 24, sm: 32},
|
||||
right: {xs: 24, sm: 32},
|
||||
zIndex: 1050,
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<MicIcon />
|
||||
</Fab>
|
||||
@@ -69,7 +72,6 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
slots={{transition: SlideUp}}
|
||||
maxWidth={false}
|
||||
sx={{
|
||||
@@ -102,6 +104,7 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
{/* Header superposé en gradient */}
|
||||
<Box sx={{
|
||||
@@ -118,7 +121,8 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<Box sx={{pointerEvents: 'none'}}>
|
||||
{titre && (
|
||||
<Typography variant='subtitle1' sx={{color: '#fff', fontWeight: 700, lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.5)'}}>
|
||||
@@ -133,7 +137,6 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
</Box>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label='fermer'
|
||||
sx={{
|
||||
pointerEvents: 'auto',
|
||||
@@ -143,6 +146,7 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
'&:hover': {bgcolor: 'rgba(255,255,255,0.25)'},
|
||||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<CloseIcon fontSize='small' />
|
||||
</IconButton>
|
||||
@@ -166,7 +170,8 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MicIcon sx={{fontSize: 44, color: 'rgba(255,255,255,0.55)', animation: `${pulse} 1.6s ease-in-out infinite`}} />
|
||||
<Typography
|
||||
variant='caption'
|
||||
@@ -187,27 +192,28 @@ export default function KaraokeModal({url, desktopUrl, titre, artistes}) {
|
||||
transform: 'translateX(-50%)',
|
||||
height: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={toEmbedUrl(activeUrl)}
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
|
||||
allowFullScreen
|
||||
src={toEmbedUrl(activeUrl)}
|
||||
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
|
||||
allow='autoplay; fullscreen'
|
||||
sandbox='allow-same-origin allow-scripts allow-popups allow-forms'
|
||||
title='Karaoké'
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
/* Vidéo 16:9 desktop : player remplit exactement le dialog paysage */
|
||||
<iframe
|
||||
src={toEmbedUrl(activeUrl)}
|
||||
onLoad={() => setLoaded(true)}
|
||||
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
|
||||
allowFullScreen
|
||||
src={toEmbedUrl(activeUrl)}
|
||||
style={{width: '100%', height: '100%', border: 'none', display: 'block', opacity: loaded ? 1 : 0, transition: 'opacity 0.4s ease'}}
|
||||
allow='autoplay; fullscreen'
|
||||
sandbox='allow-same-origin allow-scripts allow-popups allow-forms'
|
||||
title='Karaoké'
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import Box from '@mui/material/Box'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Avatar from '@mui/material/Avatar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Link from 'next/link'
|
||||
import {Mastodon} from '@icons-pack/react-simple-icons'
|
||||
|
||||
function formatAuteur(commentaire) {
|
||||
if (commentaire.origine === 'activitypub') {
|
||||
return commentaire.auteurNom || commentaire.auteurHandle || 'Yon moun'
|
||||
}
|
||||
|
||||
return commentaire.user?.username || 'Anonim'
|
||||
}
|
||||
|
||||
function formatDat(commentaire) {
|
||||
if (!commentaire.datePublication) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {dateStyle: 'short', timeStyle: 'short'})
|
||||
.format(new Date(commentaire.datePublication))
|
||||
}
|
||||
|
||||
const MENTION_REGEX = /(?:@[\w.-]+){1,2}/g
|
||||
|
||||
function metanValèMansyon(contenu) {
|
||||
const mansyon = contenu.match(MENTION_REGEX) || []
|
||||
return contenu.split(MENTION_REGEX).flatMap((moso, index) => (
|
||||
mansyon[index] ? [
|
||||
moso,
|
||||
<Box key={index} component='span' sx={{color: 'primary.main', fontWeight: 700}}>
|
||||
{mansyon[index]}
|
||||
</Box>
|
||||
] : [moso]
|
||||
))
|
||||
}
|
||||
|
||||
export default function Komante({commentaires}) {
|
||||
if (!commentaires || commentaires.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{maxWidth: 700, margin: '2em auto 0'}}>
|
||||
<Typography gutterBottom variant='h6'>
|
||||
Komantè ({commentaires.length})
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{commentaires.map(commentaire => {
|
||||
const auteur = formatAuteur(commentaire)
|
||||
const estFedere = commentaire.origine === 'activitypub'
|
||||
const dat = formatDat(commentaire)
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={commentaire.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'action.hover'
|
||||
}}
|
||||
>
|
||||
<Avatar src={commentaire.auteurAvatarUrl} alt={auteur} />
|
||||
<Box sx={{flex: 1, minWidth: 0}}>
|
||||
<Stack direction='row' alignItems='center' flexWrap='wrap' rowGap={0.5}>
|
||||
<Stack direction='row' alignItems='center' spacing={1}>
|
||||
<Typography variant='subtitle2' component='div'>
|
||||
{estFedere && commentaire.auteurProfilUrl ? (
|
||||
<Link href={commentaire.auteurProfilUrl} target='_blank' rel='noopener noreferrer'>
|
||||
{auteur}
|
||||
</Link>
|
||||
) : auteur}
|
||||
</Typography>
|
||||
{estFedere && (
|
||||
<Chip
|
||||
label='Mastodon'
|
||||
size='small'
|
||||
icon={<Mastodon size={14} color='#6364FF' title='' />}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{dat && (
|
||||
<Typography variant='caption' color='text.secondary' sx={{ml: 'auto', pl: 2}}>
|
||||
{dat}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant='body2' sx={{mt: 0.5}}>{metanValèMansyon(commentaire.contenu)}</Typography>
|
||||
{estFedere && commentaire.remoteUrl && (
|
||||
<Link href={commentaire.remoteUrl} target='_blank' rel='noopener noreferrer'>
|
||||
<Typography variant='caption' color='text.secondary'>Voir sur Mastodon</Typography>
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Komante.propTypes = {
|
||||
commentaires: PropTypes.array
|
||||
}
|
||||
+33
-30
@@ -16,16 +16,24 @@ import Image from 'next/image'
|
||||
import {grey} from '@mui/material/colors'
|
||||
import {Link} from '@mui/material'
|
||||
|
||||
import {getAlias} from '../../lib/utils/format'
|
||||
import {getAlias} from '../../lib/utils/get-alias'
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
|
||||
function createAudio(src) {
|
||||
const audio = new Audio(src)
|
||||
audio.preload = 'metadata'
|
||||
return audio
|
||||
}
|
||||
|
||||
function parseLrc(text) {
|
||||
const lines = []
|
||||
for (const raw of text.split('\n')) {
|
||||
const match = raw.match(/^\[(\d{2}):(\d{2})[.:]\d+\](.*)$/)
|
||||
const match = raw.match(/^\[(\d{2}):(\d{2})[.:]\d+](.*)$/)
|
||||
if (match) {
|
||||
lines.push({time: parseInt(match[1]) * 60 + parseInt(match[2]), text: match[3].trim()})
|
||||
const time = (Number.parseInt(match[1], 10) * 60) + Number.parseInt(match[2], 10)
|
||||
lines.push({time, text: match[3].trim()})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +76,12 @@ const TinyText = styled(Typography)({
|
||||
})
|
||||
|
||||
export default function Lekte({audio, url, parole}) {
|
||||
const audioRef = useRef(new Audio(audio))
|
||||
const audioRef = useRef(null)
|
||||
if (audioRef.current === null) {
|
||||
audioRef.current = createAudio(audio)
|
||||
}
|
||||
|
||||
const intervalRef = useRef()
|
||||
const isReady = useRef(false)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const theme = useTheme()
|
||||
const {mode} = useColorScheme()
|
||||
@@ -97,25 +108,9 @@ export default function Lekte({audio, url, parole}) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = setInterval(() => {
|
||||
setPosition(Math.round(audioRef.current.currentTime))
|
||||
}, [1000])
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady.current) {
|
||||
audioRef.current.play()
|
||||
setIsPlaying(true)
|
||||
startTimer()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current.play()
|
||||
} else {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
useEffect(() =>
|
||||
() => {
|
||||
audioRef.current.pause()
|
||||
@@ -125,7 +120,7 @@ export default function Lekte({audio, url, parole}) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
audioRef.current.play()
|
||||
audioRef.current.play().catch(() => {})
|
||||
startTimer()
|
||||
} else {
|
||||
clearInterval(intervalRef.current)
|
||||
@@ -135,7 +130,7 @@ export default function Lekte({audio, url, parole}) {
|
||||
|
||||
useEffect(() => {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = new Audio(audio)
|
||||
audioRef.current = createAudio(audio)
|
||||
setIsPlaying(false)
|
||||
setVolume(100)
|
||||
setPosition(0)
|
||||
@@ -151,6 +146,7 @@ export default function Lekte({audio, url, parole}) {
|
||||
setPosition(0)
|
||||
el.currentTime = 0
|
||||
}
|
||||
|
||||
el.addEventListener('loadedmetadata', onLoaded)
|
||||
el.addEventListener('ended', onEnded)
|
||||
return () => {
|
||||
@@ -160,7 +156,10 @@ export default function Lekte({audio, url, parole}) {
|
||||
}, [audio])
|
||||
|
||||
useEffect(() => {
|
||||
if (!parole?.pawol?.url) return
|
||||
if (!parole?.pawol?.url) {
|
||||
return
|
||||
}
|
||||
|
||||
fetch(new URL(parole.pawol.url, IMAGE_URL).toString())
|
||||
.then(r => r.text())
|
||||
.then(text => setLrcLines(parseLrc(text)))
|
||||
@@ -168,7 +167,7 @@ export default function Lekte({audio, url, parole}) {
|
||||
}, [parole?.pawol?.url])
|
||||
|
||||
const activeIndex = lrcLines
|
||||
? lrcLines.reduce((last, line, i) => line.time <= position ? i : last, -1)
|
||||
? lrcLines.findLastIndex(line => line.time <= position)
|
||||
: -1
|
||||
|
||||
const handleChangePosition = value => {
|
||||
@@ -181,9 +180,10 @@ export default function Lekte({audio, url, parole}) {
|
||||
audioRef.current.volume = value / 100
|
||||
}
|
||||
|
||||
const imagePath = parole?.couverture?.formats?.thumbnail?.url
|
||||
const width = parole?.couverture?.formats?.thumbnail?.width || 192
|
||||
const height = parole?.couverture?.formats?.thumbnail?.height || 192
|
||||
const couvertureFormat = formatKuveti(parole?.couverture, 'thumbnail')
|
||||
const imagePath = couvertureFormat?.url
|
||||
const width = couvertureFormat?.width || 192
|
||||
const height = couvertureFormat?.height || 192
|
||||
|
||||
const imageSrc = imagePath
|
||||
? new URL(imagePath, IMAGE_URL).toString()
|
||||
@@ -322,7 +322,10 @@ export default function Lekte({audio, url, parole}) {
|
||||
{[-1, 0, 1].map(offset => {
|
||||
const idx = activeIndex + offset
|
||||
const line = lrcLines[idx]
|
||||
if (!line) return <Box key={offset} sx={{height: offset === 0 ? 28 : 20}} />
|
||||
if (!line) {
|
||||
return <Box key={offset} sx={{height: offset === 0 ? 28 : 20}} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography
|
||||
key={offset}
|
||||
|
||||
@@ -3,8 +3,6 @@ import {Box, CircularProgress} from '@mui/material'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
|
||||
const MIZIK_API_USER = process.env.NEXT_PUBLIC_MIZIK_API_USER || 'user'
|
||||
const MIZIK_API_PASSWORD = process.env.NEXT_PUBLIC_MIZIK_API_PASSWORD || 'password'
|
||||
|
||||
const DinamikLekte = dynamic(
|
||||
() => import('./lekte'), // eslint-disable-line node/no-unsupported-features/es-syntax
|
||||
@@ -12,7 +10,7 @@ const DinamikLekte = dynamic(
|
||||
)
|
||||
|
||||
export default function OkiMizik({id, parole}) {
|
||||
const mizikStreamUrl = `${MIZIK_URL}/rest/stream?u=${MIZIK_API_USER}&p=${MIZIK_API_PASSWORD}&id=${id}`
|
||||
const mizikStreamUrl = `/api/mizik-stream/${id}`
|
||||
const detailsUrl = `${MIZIK_URL}/library/tracks/${id}`
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 l’instance Mastodon sur laquelle vous avez un compte.
|
||||
BOKANTE est l’instance de OKI : vous pouvez aussi utiliser
|
||||
n’importe 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
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {notFound} from 'next/navigation'
|
||||
import {jwennTeks} from '../../lib/oki-api'
|
||||
import TeksDrawer from './teks-drawer'
|
||||
|
||||
export default async function TeksDrawerLoader() {
|
||||
const teks = await jwennTeks()
|
||||
|
||||
if (!teks) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <TeksDrawer paroles={teks} />
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export default function TeksDrawer({paroles}) {
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
{parole && (
|
||||
<Link passHref href='/paroles'>
|
||||
<Link passHref prefetch={false} href='/paroles'>
|
||||
<IconButton aria-label='return' size='medium'>
|
||||
<KeyboardBackspaceIcon style={{fontSize: '1.5em'}} />
|
||||
</IconButton>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import {format} from 'date-fns'
|
||||
import {fr} from 'date-fns/locale'
|
||||
import Card from '@mui/material/Card'
|
||||
|
||||
import CardActionArea from '@mui/material/CardActionArea'
|
||||
@@ -13,9 +11,11 @@ import Typography from '@mui/material/Typography'
|
||||
import Box from '@mui/material/Box'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import ExplicitIcon from '@mui/icons-material/Explicit'
|
||||
import EventIcon from '@mui/icons-material/Event'
|
||||
import {styled} from '@mui/material/styles'
|
||||
|
||||
import {getAlias} from '../../lib/utils/format'
|
||||
import {getAlias} from '../../lib/utils/get-alias'
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
|
||||
const PREFIX = 'teks-kat'
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
@@ -36,7 +36,7 @@ export default function TeksKat({parole}) {
|
||||
const router = useRouter()
|
||||
const {titre, artistes, annee, couverture, createdAt, slug} = parole
|
||||
|
||||
const datPiblikasyon = format(new Date(createdAt), 'P', {locale: fr})
|
||||
const datPiblikasyon = new Intl.DateTimeFormat(undefined, {dateStyle: 'short'}).format(new Date(createdAt))
|
||||
const aliases = getAlias(artistes, parole.prioriteArtistes)
|
||||
|
||||
const handleClick = slug => {
|
||||
@@ -50,9 +50,9 @@ export default function TeksKat({parole}) {
|
||||
<Box sx={{position: 'relative', height: 240}}>
|
||||
{couverture?.url ? (
|
||||
<Image
|
||||
src={`${IMAGE_URL}${couverture.formats?.thumbnail?.url || couverture.url}`}
|
||||
alt={titre}
|
||||
fill
|
||||
src={`${IMAGE_URL}${formatKuveti(couverture, 'thumbnail')?.url}`}
|
||||
alt={titre}
|
||||
placeholder='blur'
|
||||
blurDataURL={BLUR_DATA_URL}
|
||||
sizes='(max-width: 600px) 100vw, (max-width: 900px) 50vw, 33vw'
|
||||
@@ -102,8 +102,15 @@ export default function TeksKat({parole}) {
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography align='center' style={{marginTop: '0.5em'}} variant='body1' color='textSecondary' component='p'>
|
||||
Publiée le : {datPiblikasyon}
|
||||
<Typography
|
||||
align='center'
|
||||
style={{marginTop: '0.5em'}}
|
||||
variant='body1'
|
||||
color='textSecondary'
|
||||
component='p'
|
||||
sx={{display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5}}
|
||||
>
|
||||
<EventIcon fontSize='inherit' /> {datPiblikasyon}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
+95
-44
@@ -3,8 +3,11 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Box from '@mui/material/Box'
|
||||
import Tabs from '@mui/material/Tabs'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import Select from '@mui/material/Select'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import ListSubheader from '@mui/material/ListSubheader'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import {useMediaQuery} from '@mui/material'
|
||||
@@ -13,12 +16,14 @@ import slugify from 'slugify'
|
||||
|
||||
import {styled} from '@mui/material/styles'
|
||||
import ExplicitIcon from '@mui/icons-material/Explicit'
|
||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
|
||||
import TranslateIcon from '@mui/icons-material/Translate'
|
||||
import StarIcon from '@mui/icons-material/Star'
|
||||
import ArticleIcon from '@mui/icons-material/Article'
|
||||
|
||||
import Image from 'next/image'
|
||||
|
||||
import {formatJsonString, getAlias} from '../../lib/utils/format'
|
||||
import {formatJsonString} from '../../lib/utils/format'
|
||||
import {getAlias} from '../../lib/utils/get-alias'
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
|
||||
import LicenseModal from '../cc/license-modal'
|
||||
@@ -28,6 +33,8 @@ import {StreamButton} from '../streaming-buttons'
|
||||
import EntegreMizik from './entegre-mizik'
|
||||
import OkiMizik from './oki-mizik'
|
||||
import DiferansDialog from './diferans-dialog'
|
||||
import Komante from './komante'
|
||||
import ReponsMastodon from './repons-mastodon'
|
||||
import {useCurrentTrack} from './current-track-context'
|
||||
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
@@ -88,15 +95,35 @@ const LANG_NAMES = {
|
||||
pt: 'Português', ja: '日本語', ko: '한국어',
|
||||
}
|
||||
|
||||
const CONTINENTS = ['Afrique', 'Asie', 'Europe']
|
||||
|
||||
const TRAD_FIELDS = [
|
||||
{field: 'anglais', title: 'English'},
|
||||
{field: 'francais', title: 'Français'},
|
||||
{field: 'espagnol', title: 'Español'},
|
||||
{field: 'allemand', title: 'Deutsch'},
|
||||
{field: 'italien', title: 'Italiano'},
|
||||
{field: 'portugais', title: 'Português'},
|
||||
{field: 'japonais', title: '日本語'},
|
||||
{field: 'coreen', title: '한국어'},
|
||||
// Afrique — langues vedettes en premier
|
||||
{field: 'ayisyen', title: 'Ayisyen 🇭🇹', continent: 'Afrique', vedette: true},
|
||||
{field: 'yoruba', title: 'Yorùbá', continent: 'Afrique', vedette: true},
|
||||
{field: 'lingala', title: 'Lingála', continent: 'Afrique', vedette: true},
|
||||
{field: 'wolof', title: 'Wolof', continent: 'Afrique', vedette: true},
|
||||
{field: 'swahili', title: 'Kiswahili', continent: 'Afrique', vedette: true},
|
||||
{field: 'hausa', title: 'Hausa', continent: 'Afrique', vedette: true},
|
||||
{field: 'arabe', title: 'العربية', continent: 'Afrique'},
|
||||
{field: 'oromo', title: 'Oromoo', continent: 'Afrique'},
|
||||
{field: 'igbo', title: 'Igbo', continent: 'Afrique'},
|
||||
{field: 'zoulou', title: 'isiZulu', continent: 'Afrique'},
|
||||
{field: 'malgache', title: 'Malagasy', continent: 'Afrique'},
|
||||
{field: 'xhosa', title: 'isiXhosa', continent: 'Afrique'},
|
||||
{field: 'tswana', title: 'Setswana', continent: 'Afrique'},
|
||||
{field: 'tsonga', title: 'Xitsonga', continent: 'Afrique'},
|
||||
{field: 'sesotho', title: 'Sesotho', continent: 'Afrique'},
|
||||
// Asie
|
||||
{field: 'japonais', title: '日本語', continent: 'Asie'},
|
||||
{field: 'coreen', title: '한국어', continent: 'Asie'},
|
||||
// Europe
|
||||
{field: 'anglais', title: 'English', continent: 'Europe'},
|
||||
{field: 'francais', title: 'Français', continent: 'Europe'},
|
||||
{field: 'espagnol', title: 'Español', continent: 'Europe'},
|
||||
{field: 'allemand', title: 'Deutsch', continent: 'Europe'},
|
||||
{field: 'italien', title: 'Italiano', continent: 'Europe'},
|
||||
{field: 'portugais', title: 'Português', continent: 'Europe'},
|
||||
]
|
||||
|
||||
const langToArray = parole => {
|
||||
@@ -106,7 +133,7 @@ const langToArray = parole => {
|
||||
|
||||
return TRAD_FIELDS
|
||||
.filter(({field}) => parole.traductions[field])
|
||||
.map(({field, title}) => ({title, lang: parole.traductions[field]}))
|
||||
.map(({field, title, continent, vedette}) => ({field, title, continent, vedette, lang: parole.traductions[field]}))
|
||||
}
|
||||
|
||||
const ExplicitTooltip = Tooltip
|
||||
@@ -182,7 +209,7 @@ export default function Teks({parole}) {
|
||||
<i>parole soumise par {parole.user.username}</i>
|
||||
</Typography>
|
||||
)}
|
||||
{parole?.userAdmin&& !parole.user && (
|
||||
{parole?.userAdmin && !parole.user && (
|
||||
<Typography style={{marginBottom: '1.5em'}} display='block' variant='caption'>
|
||||
<i>parole soumise par {parole.userAdmin.firstname}</i>
|
||||
</Typography>
|
||||
@@ -219,47 +246,69 @@ export default function Teks({parole}) {
|
||||
Écouter sur
|
||||
</Typography>
|
||||
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}>
|
||||
{parole.streamAudio.map((lyen, i) => (
|
||||
<StreamButton key={i} lyen={lyen} />
|
||||
{parole.streamAudio.map(lyen => (
|
||||
<StreamButton key={lyen.url} lyen={lyen} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}>
|
||||
<Tabs
|
||||
<Box sx={{display: 'flex', justifyContent: 'center', borderBottom: 1, borderColor: 'divider', mb: 2, pb: 2}}>
|
||||
<FormControl size='small' sx={{minWidth: 240, width: isMobile ? '100%' : 'auto'}}>
|
||||
<Select
|
||||
value={tab}
|
||||
onChange={(event, value) => setTab(value)}
|
||||
variant='scrollable'
|
||||
scrollButtons
|
||||
allowScrollButtonsMobile
|
||||
centered={langArray.length === 0}
|
||||
slots={{
|
||||
StartScrollButtonIcon: ArrowBackIosNewIcon,
|
||||
EndScrollButtonIcon: ArrowForwardIosIcon
|
||||
MenuProps={{PaperProps: {sx: {maxHeight: 420}}}}
|
||||
renderValue={value => {
|
||||
const label = value === 0 ? 'Transcription' : langArray[value - 1]?.title
|
||||
const Icon = value === 0 ? ArticleIcon : TranslateIcon
|
||||
return (
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, fontWeight: value === 0 ? 700 : 400}}>
|
||||
<Icon fontSize='small' color='primary' />
|
||||
{label}
|
||||
</Box>
|
||||
)
|
||||
}}
|
||||
onChange={event => setTab(event.target.value)}
|
||||
>
|
||||
<MenuItem value={0} sx={{display: 'flex', gap: 0.75, fontWeight: 700}}>
|
||||
<ArticleIcon fontSize='inherit' color='primary' />
|
||||
Transcription
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
{CONTINENTS.flatMap(continent => {
|
||||
const items = langArray
|
||||
.map((item, index) => ({...item, index}))
|
||||
.filter(item => item.continent === continent)
|
||||
|
||||
if (items.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
<ListSubheader
|
||||
key={`entet-${continent}`}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
mb: 2,
|
||||
'& .MuiTabs-scrollButtons': {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'primary.main',
|
||||
backgroundColor: 'action.hover',
|
||||
mx: 0.5,
|
||||
'&.Mui-disabled': {
|
||||
opacity: 0
|
||||
}
|
||||
}
|
||||
lineHeight: '2.5em'
|
||||
}}
|
||||
>
|
||||
<Tab label='Transcription' />
|
||||
{langArray.map(({title}) => (
|
||||
<Tab key={title} label={title} />
|
||||
))}
|
||||
</Tabs>
|
||||
{continent}
|
||||
</ListSubheader>,
|
||||
...items.map(({title, index, vedette}) => (
|
||||
<MenuItem key={title} value={index + 1} sx={{display: 'flex', gap: 0.75}}>
|
||||
{vedette && <StarIcon fontSize='inherit' color='primary' />}
|
||||
{title}
|
||||
</MenuItem>
|
||||
))
|
||||
]
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
{tab === 0 && (
|
||||
<>
|
||||
{parole.langueSource && parole.langueSource !== 'ka' && (
|
||||
@@ -280,6 +329,8 @@ export default function Teks({parole}) {
|
||||
)
|
||||
))}
|
||||
</Box>
|
||||
<ReponsMastodon bokanteStatusId={parole.bokanteStatusId} />
|
||||
<Komante commentaires={parole.commentaires} />
|
||||
</Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import NextTopLoader from 'nextjs-toploader'
|
||||
|
||||
export default function TopLoader({color}) {
|
||||
return <NextTopLoader color={color} />
|
||||
export default function TopLoader({color, nonce}) {
|
||||
return <NextTopLoader color={color} nonce={nonce} />
|
||||
}
|
||||
|
||||
TopLoader.propTypes = {
|
||||
color: PropTypes.string.isRequired,
|
||||
nonce: PropTypes.string
|
||||
}
|
||||
|
||||
TopLoader.propTypes = {
|
||||
color: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,25 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import {dirname} from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
import {FlatCompat} from '@eslint/eslintrc'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
})
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
...compat.extends('next/core-web-vitals'),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
'node_modules/**',
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
],
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export default eslintConfig;
|
||||
export default eslintConfig
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {formatKuveti} from '../kuveti'
|
||||
|
||||
const kuveti = {
|
||||
url: '/uploads/original.jpg',
|
||||
formats: {
|
||||
thumbnail: {url: '/uploads/thumbnail.jpg', width: 156, height: 156},
|
||||
small: {url: '/uploads/small.jpg', width: 500, height: 500},
|
||||
medium: {url: '/uploads/medium.jpg', width: 750, height: 750},
|
||||
large: {url: '/uploads/large.jpg', width: 1000, height: 1000},
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatKuveti', () => {
|
||||
it('returns null when there is no image', () => {
|
||||
expect(formatKuveti(null)).toBeNull()
|
||||
expect(formatKuveti(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults to the large format', () => {
|
||||
expect(formatKuveti(kuveti)).toBe(kuveti.formats.large)
|
||||
})
|
||||
|
||||
it('prefers the thumbnail format when asked', () => {
|
||||
expect(formatKuveti(kuveti, 'thumbnail')).toBe(kuveti.formats.thumbnail)
|
||||
})
|
||||
|
||||
it('falls back to small then medium then large when thumbnail is missing', () => {
|
||||
const {thumbnail, ...rest} = kuveti.formats
|
||||
const withoutThumbnail = {...kuveti, formats: rest}
|
||||
expect(formatKuveti(withoutThumbnail, 'thumbnail')).toBe(rest.small)
|
||||
})
|
||||
|
||||
it('falls back to the original file when no format matches', () => {
|
||||
const noFormats = {url: '/uploads/original.jpg', formats: {}}
|
||||
expect(formatKuveti(noFormats, 'thumbnail')).toBe(noFormats)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import {describe, it, expect, vi, afterEach} from 'vitest'
|
||||
import {jwennTeksSlug} from '../oki-api'
|
||||
|
||||
function fakeEntry(i) {
|
||||
return {slug: `slug-${i}`}
|
||||
}
|
||||
|
||||
describe('jwennTeksSlug (full pagination)', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('walks every page instead of stopping at the first pageSize chunk', async () => {
|
||||
const pageSize = 200
|
||||
const totalRecords = 250
|
||||
|
||||
let calls = 0
|
||||
global.fetch = vi.fn(async url => {
|
||||
calls++
|
||||
if (calls > 5) {
|
||||
throw new Error('too many pagination calls, likely an infinite loop')
|
||||
}
|
||||
|
||||
const pageMatch = url.match(/pagination\[page]=(\d+)/)
|
||||
const page = pageMatch ? Number(pageMatch[1]) : 1
|
||||
const start = (page - 1) * pageSize
|
||||
const end = Math.min(start + pageSize, totalRecords)
|
||||
const data = Array.from({length: Math.max(end - start, 0)}, (_, i) => fakeEntry(start + i))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({data})
|
||||
}
|
||||
})
|
||||
|
||||
const slugs = await jwennTeksSlug()
|
||||
|
||||
expect(slugs).toHaveLength(totalRecords)
|
||||
expect(slugs[0]).toBe('slug-0')
|
||||
expect(slugs.at(-1)).toBe('slug-249')
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops after a single page when there are fewer records than pageSize', async () => {
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({data: [fakeEntry(1), fakeEntry(2)]})
|
||||
}))
|
||||
|
||||
const slugs = await jwennTeksSlug()
|
||||
|
||||
expect(slugs).toEqual(['slug-1', 'slug-2'])
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import {describe, it, expect, vi, afterEach} from 'vitest'
|
||||
import {jwennAnTeks} from '../oki-api'
|
||||
|
||||
describe('request (via jwennAnTeks)', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the parsed body on a successful response', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({id: 1, titre: 'Test'})
|
||||
})
|
||||
|
||||
const result = await jwennAnTeks(1, 'token')
|
||||
expect(result).toEqual({id: 1, titre: 'Test'})
|
||||
})
|
||||
|
||||
it('throws a clear error when Strapi returns an error response', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({data: null, error: {status: 400, name: 'ValidationError', message: 'Invalid key'}})
|
||||
})
|
||||
|
||||
await expect(jwennAnTeks(1, 'token')).rejects.toThrow('Eshwe pu jwenn done-a')
|
||||
})
|
||||
|
||||
it('throws when the network request itself fails', async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new TypeError('network down'))
|
||||
|
||||
await expect(jwennAnTeks(1, 'token')).rejects.toThrow('Eshwe pu jwenn done-a')
|
||||
})
|
||||
})
|
||||
+9
-2
@@ -2,13 +2,20 @@ const SIZE_ORDER = {
|
||||
large: ['large', 'medium', 'small'],
|
||||
medium: ['medium', 'small', 'large'],
|
||||
small: ['small', 'medium', 'large'],
|
||||
thumbnail: ['thumbnail', 'small', 'medium', 'large'],
|
||||
}
|
||||
|
||||
export const formatKuveti = (kuveti, preferred = 'large') => {
|
||||
if (!kuveti) return null
|
||||
if (!kuveti) {
|
||||
return null
|
||||
}
|
||||
|
||||
const order = SIZE_ORDER[preferred] ?? SIZE_ORDER.large
|
||||
for (const size of order) {
|
||||
if (kuveti.formats?.[size]) return kuveti.formats[size]
|
||||
if (kuveti.formats?.[size]) {
|
||||
return kuveti.formats[size]
|
||||
}
|
||||
}
|
||||
|
||||
return kuveti
|
||||
}
|
||||
|
||||
+43
-23
@@ -2,7 +2,7 @@ import qs from 'qs'
|
||||
|
||||
const OKI_API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
|
||||
const AWTIS_POU_CHAK_PAJ = process.env.NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ || 6
|
||||
const readToken = process.env.NEXT_PUBLIC_READ_TOKEN || 'read-token'
|
||||
const readToken = process.env.API_READ_TOKEN || 'read-token'
|
||||
|
||||
const headers = {
|
||||
next: {revalidate: 60},
|
||||
@@ -14,12 +14,18 @@ const headers = {
|
||||
async function request(url, opts = {}) {
|
||||
const {...options} = opts
|
||||
|
||||
let res
|
||||
try {
|
||||
const res = await fetch(`${OKI_API}${url}`, options)
|
||||
return res.json()
|
||||
res = await fetch(`${OKI_API}${url}`, options)
|
||||
} catch {
|
||||
throw new Error('Eshwe pu jwenn done-a')
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Eshwe pu jwenn done-a')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function jwennTeksEpiSlug(slug) {
|
||||
@@ -32,7 +38,16 @@ export async function jwennTeksEpiSlug(slug) {
|
||||
fields: ['username', 'firstname']
|
||||
},
|
||||
commentaires: {
|
||||
fields: ['contenu', 'datePublication'],
|
||||
fields: [
|
||||
'contenu',
|
||||
'datePublication',
|
||||
'origine',
|
||||
'auteurNom',
|
||||
'auteurHandle',
|
||||
'auteurAvatarUrl',
|
||||
'auteurProfilUrl',
|
||||
'remoteUrl'
|
||||
],
|
||||
populate: {
|
||||
user: {
|
||||
fields: ['username']
|
||||
@@ -165,32 +180,37 @@ export async function jwennTeks() {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function jwennAwtisSlug() {
|
||||
async function jwennTouSlug(endpoint) {
|
||||
const pageSize = 200
|
||||
const slugs = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const query = qs.stringify({
|
||||
sort: ['createdAt:desc'],
|
||||
pagination: {
|
||||
pageSize: 200
|
||||
}
|
||||
fields: ['slug'],
|
||||
pagination: {page, pageSize}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
})
|
||||
|
||||
const {data} = await request(`/artistes?${query}`, headers)
|
||||
return data.map(({slug}) => slug)
|
||||
// eslint-disable-next-line no-await-in-loop -- pagination séquentielle, chaque page dépend du nombre de résultats de la précédente
|
||||
const {data} = await request(`/${endpoint}?${query}`, headers)
|
||||
slugs.push(...data.map(({slug}) => slug))
|
||||
hasMore = data.length === pageSize
|
||||
page += 1
|
||||
}
|
||||
|
||||
return slugs
|
||||
}
|
||||
|
||||
export async function jwennAwtisSlug() {
|
||||
return jwennTouSlug('artistes')
|
||||
}
|
||||
|
||||
export async function jwennTeksSlug() {
|
||||
const query = qs.stringify({
|
||||
sort: ['createdAt:desc'],
|
||||
pagination: {
|
||||
pageSize: 200
|
||||
}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
})
|
||||
|
||||
const {data} = await request(`/paroles?${query}`, headers)
|
||||
return data.map(({slug}) => slug)
|
||||
return jwennTouSlug('paroles')
|
||||
}
|
||||
|
||||
export async function jwennDenyeTeks() {
|
||||
@@ -213,9 +233,9 @@ export async function jwennAnVedette() {
|
||||
const query = qs.stringify({
|
||||
populate: ['artistes', 'couverture'],
|
||||
filters: {
|
||||
isNewRelease: { $eq: true }
|
||||
isNewRelease: {$eq: true}
|
||||
},
|
||||
pagination: { limit: 1 }
|
||||
pagination: {limit: 1}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
})
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube'
|
||||
import TelegramIcon from '@mui/icons-material/Telegram'
|
||||
|
||||
import PeertubeIcon from './icons/peertube'
|
||||
import XmppIcon from './icons/xmpp'
|
||||
import XIcon from './icons/x'
|
||||
import GiteaIcon from './icons/gitea'
|
||||
import BlueskyIcon from './icons/bluesky'
|
||||
|
||||
const gitURL = process.env.NEXT_PUBLIC_GIT || 'https://labola.o-k-i.net/ORGANISATION-KA-INTERNATIONALE/pawol.nu'
|
||||
const blueskyUrl = process.env.NEXT_PUBLIC_BLUESKY_URL || 'https://bsky.app/profile/organisationka.bsky.social'
|
||||
const xmppContact = process.env.NEXT_PUBLIC_XMPP || 'oki@xmpp.cz'
|
||||
const gadeUsername = process.env.NEXT_PUBLIC_GADE_USERNAME || 'oki'
|
||||
const youtubeUsername = process.env.NEXT_PUBLIC_YOUTUBE_USERNAME || 'OrganisationKInternationaleOKi'
|
||||
const tiwtterUsername = process.env.NEXT_PUBLIC_TWITTER_USERNAME || 'OrganisationKA'
|
||||
const telegramGroup = process.env.NEXT_PUBLIC_TELEGRAM_GROUP || 'OrganisationKA'
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {getAlias} from '../get-alias'
|
||||
|
||||
describe('getAlias', () => {
|
||||
it('retourne une chaîne vide sans planter quand artistes est undefined', () => {
|
||||
expect(getAlias(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('retourne une chaîne vide sans planter quand artistes est un tableau vide', () => {
|
||||
expect(getAlias([])).toBe('')
|
||||
})
|
||||
|
||||
it('retourne l\'alias unique quand il n\'y a qu\'un artiste', () => {
|
||||
expect(getAlias([{id: 1, alias: 'Foo'}])).toBe('Foo')
|
||||
})
|
||||
|
||||
it('ignore un id de priorité qui ne correspond à aucun artiste (donnée obsolète)', () => {
|
||||
const artistes = [{id: 1, alias: 'Foo'}, {id: 2, alias: 'Bar'}]
|
||||
expect(getAlias(artistes, '1-99-2')).toBe('Foo et Bar')
|
||||
})
|
||||
|
||||
it('formate plusieurs artistes selon la priorité fournie', () => {
|
||||
const artistes = [{id: 1, alias: 'Foo'}, {id: 2, alias: 'Bar'}]
|
||||
expect(getAlias(artistes, '2-1')).toBe('Bar et Foo')
|
||||
})
|
||||
})
|
||||
@@ -1,54 +1,2 @@
|
||||
import slugify from 'slugify'
|
||||
|
||||
export const formatJsonString = stringToFormat =>
|
||||
stringToFormat.split('\n').map((string, index) => <div key={index}>{`${string}`}<br /></div>) // eslint-disable-line react/no-array-index-key
|
||||
|
||||
export const getAlias = (artistes, priorite = null, enhanced = false) => {
|
||||
if (artistes.length === 1 && !enhanced) {
|
||||
return artistes[0].alias
|
||||
}
|
||||
|
||||
if (artistes.length === 1 && enhanced) {
|
||||
return [{
|
||||
type: 'element',
|
||||
value: artistes[0].alias
|
||||
}]
|
||||
}
|
||||
|
||||
const alias = []
|
||||
const splittedPriority = priorite ? priorite.split('-')
|
||||
.map(p => Number.parseInt(p, 10)) : null
|
||||
|
||||
if (splittedPriority) {
|
||||
for (const element of splittedPriority) {
|
||||
const foundedArtistes = artistes.find(({id}) => id === element)
|
||||
alias.push(foundedArtistes.alias)
|
||||
}
|
||||
} else {
|
||||
for (const artiste of artistes) {
|
||||
alias.push(artiste.alias)
|
||||
}
|
||||
}
|
||||
|
||||
if (enhanced) {
|
||||
const list = new Intl.ListFormat('fr').formatToParts(alias)
|
||||
return list
|
||||
}
|
||||
|
||||
return new Intl.ListFormat('fr').format(alias)
|
||||
}
|
||||
|
||||
function separateString(inputString) {
|
||||
const regex = /\s*,\s*|\s+et\s+/
|
||||
const strings = inputString.split(regex).map(element => element.trim())
|
||||
return strings
|
||||
}
|
||||
|
||||
export const getAliasObject = inputString => {
|
||||
const awtis = separateString(inputString)
|
||||
|
||||
return awtis.map(alias => ({
|
||||
alias,
|
||||
slug: slugify(alias, {lower: true, remove: /[*#+~.()'"!:@]/g})
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import slugify from 'slugify'
|
||||
|
||||
export const getAlias = (artistes, priorite = null, enhanced = false) => {
|
||||
const safeArtistes = artistes || []
|
||||
|
||||
if (safeArtistes.length === 1 && !enhanced) {
|
||||
return safeArtistes[0].alias
|
||||
}
|
||||
|
||||
if (safeArtistes.length === 1 && enhanced) {
|
||||
return [{
|
||||
type: 'element',
|
||||
value: safeArtistes[0].alias
|
||||
}]
|
||||
}
|
||||
|
||||
const alias = []
|
||||
const splittedPriority = priorite ? priorite.split('-')
|
||||
.map(p => Number.parseInt(p, 10)) : null
|
||||
|
||||
if (splittedPriority) {
|
||||
for (const element of splittedPriority) {
|
||||
const foundedArtistes = safeArtistes.find(({id}) => id === element)
|
||||
if (foundedArtistes) {
|
||||
alias.push(foundedArtistes.alias)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const artiste of safeArtistes) {
|
||||
alias.push(artiste.alias)
|
||||
}
|
||||
}
|
||||
|
||||
if (enhanced) {
|
||||
const list = new Intl.ListFormat('fr').formatToParts(alias)
|
||||
return list
|
||||
}
|
||||
|
||||
return new Intl.ListFormat('fr').format(alias)
|
||||
}
|
||||
|
||||
function separateString(inputString) {
|
||||
const regex = /\s*,\s*|\s+et\s+/
|
||||
const strings = inputString.split(regex).map(element => element.trim())
|
||||
return strings
|
||||
}
|
||||
|
||||
export const getAliasObject = inputString => {
|
||||
const awtis = separateString(inputString)
|
||||
|
||||
return awtis.map(alias => ({
|
||||
alias,
|
||||
slug: slugify(alias, {lower: true, remove: /[*#+~.()'"!:@]/g})
|
||||
}))
|
||||
}
|
||||
+51
-12
@@ -11,27 +11,66 @@ function buildRemotePatterns() {
|
||||
const [hostname, port] = entry.split(':')
|
||||
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1'
|
||||
const pattern = {protocol: isLocal ? 'http' : 'https', hostname, pathname: '/uploads/**'}
|
||||
if (port) pattern.port = port
|
||||
if (port) {
|
||||
pattern.port = port
|
||||
}
|
||||
|
||||
return pattern
|
||||
})
|
||||
if (!raw.includes('localhost'))
|
||||
if (!raw.includes('localhost')) {
|
||||
patterns.push({protocol: 'http', hostname: 'localhost', port: '1337', pathname: '/uploads/**'})
|
||||
if (!raw.includes('127.0.0.1'))
|
||||
}
|
||||
|
||||
if (!raw.includes('127.0.0.1')) {
|
||||
patterns.push({protocol: 'http', hostname: '127.0.0.1', port: '1337', pathname: '/uploads/**'})
|
||||
}
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
async function headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload'
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff'
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN'
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin'
|
||||
},
|
||||
{
|
||||
key: 'Cross-Origin-Resource-Policy',
|
||||
value: 'same-site'
|
||||
},
|
||||
{
|
||||
key: 'Cross-Origin-Opener-Policy',
|
||||
value: 'same-origin'
|
||||
},
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
module.exports = (withPWA({
|
||||
turbopack: {},
|
||||
webpack: config => {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ['@svgr/webpack', 'url-loader'],
|
||||
})
|
||||
|
||||
return config
|
||||
},
|
||||
poweredByHeader: false,
|
||||
images: {
|
||||
remotePatterns: buildRemotePatterns()
|
||||
}
|
||||
},
|
||||
headers
|
||||
}))
|
||||
|
||||
+16
-14
@@ -17,7 +17,9 @@
|
||||
"lint": "xo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/cache": "^11.11.0",
|
||||
@@ -32,36 +34,36 @@
|
||||
"@mui/material-pigment-css": "^9.0.0",
|
||||
"@mui/styles": "^6.4.8",
|
||||
"@pigment-css/react": "^0.0.30",
|
||||
"@svgr/webpack": "^6.5.0",
|
||||
"axios": "^0.21.0",
|
||||
"compression": "^1.7.4",
|
||||
"date-fns": "^2.16.1",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"music-metadata-browser": "^2.5.11",
|
||||
"next": "16.2.4",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-plausible": "^4.0.0",
|
||||
"next-pwa": "5.6.0",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"nodemailer": "^6.7.2",
|
||||
"nprogress": "^0.2.0",
|
||||
"qs": "^6.15.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-swipeable-views": "^0.13.9",
|
||||
"react-virtuoso": "^4.4.0",
|
||||
"sharp": "^0.29.3",
|
||||
"slugify": "^1.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^8.7.0",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"eslint-config-xo-nextjs": "^4.0.3",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"vitest": "^4.1.9",
|
||||
"xo": "^0.47.0"
|
||||
},
|
||||
"xo": {
|
||||
@@ -83,11 +85,11 @@
|
||||
"error",
|
||||
{
|
||||
"capIsNewExceptions": [
|
||||
"CredentialsProvider",
|
||||
"TwitterProvider",
|
||||
"GoogleProvider",
|
||||
"GitHubProvider",
|
||||
"NextAuth"
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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'}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {defineConfig} from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.js'],
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest' // eslint-disable-line import/no-unassigned-import
|
||||
Reference in New Issue
Block a user