Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||
|
|
7234c1408f
|
||
|
|
e4d31b0243
|
||
|
|
96b7a601c8
|
||
|
|
a56b355493
|
||
|
|
55967bfc3c
|
||
|
|
daa3510d73
|
||
|
|
cad6e39f53
|
||
|
|
25066e97ce
|
||
|
|
85e9142a32
|
||
|
|
054a45246e
|
||
|
|
9752e4bcdd
|
||
|
|
719522f665 | ||
|
|
1c86d0b962
|
||
|
|
96dafb7b54
|
||
|
|
d64aa863dc
|
+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,55 @@
|
||||
import {describe, it, expect, vi, afterEach} from 'vitest'
|
||||
import {GET} from '../route'
|
||||
|
||||
function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) {
|
||||
return {
|
||||
status,
|
||||
body,
|
||||
headers: {
|
||||
get: key => headers[key.toLowerCase()] ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('GET /api/mizik-stream/[id]', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('n\'expose pas les identifiants au client, uniquement l\'id dans l\'URL amont', async () => {
|
||||
global.fetch = vi.fn(async () => fakeUpstreamResponse())
|
||||
|
||||
const request = new Request('http://localhost/api/mizik-stream/42')
|
||||
await GET(request, {params: Promise.resolve({id: '42'})})
|
||||
|
||||
const [calledUrl] = global.fetch.mock.calls[0]
|
||||
expect(calledUrl).toContain('id=42')
|
||||
expect(calledUrl).toMatch(/[?&]u=/)
|
||||
expect(calledUrl).toMatch(/[?&]p=/)
|
||||
})
|
||||
|
||||
it('transmet l\'en-tête Range pour permettre le seek audio', async () => {
|
||||
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 206, headers: {'content-range': 'bytes 0-99/200'}}))
|
||||
|
||||
const request = new Request('http://localhost/api/mizik-stream/42', {headers: {range: 'bytes=0-99'}})
|
||||
const response = await GET(request, {params: Promise.resolve({id: '42'})})
|
||||
|
||||
const [, options] = global.fetch.mock.calls[0]
|
||||
expect(options.headers.range).toBe('bytes=0-99')
|
||||
expect(response.status).toBe(206)
|
||||
expect(response.headers.get('content-range')).toBe('bytes 0-99/200')
|
||||
})
|
||||
|
||||
it('renvoie le flux et le statut renvoyés par le serveur Funkwhale', async () => {
|
||||
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 200, headers: {'content-type': 'audio/mpeg'}}))
|
||||
|
||||
const request = new Request('http://localhost/api/mizik-stream/7')
|
||||
const response = await GET(request, {params: Promise.resolve({id: '7'})})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('audio/mpeg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
|
||||
const MIZIK_API_USER = process.env.MIZIK_API_USER || 'user'
|
||||
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || 'password'
|
||||
|
||||
const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges']
|
||||
|
||||
export async function GET(request, props) {
|
||||
const {id} = await props.params
|
||||
|
||||
const upstreamUrl = `${MIZIK_URL}/rest/stream?u=${encodeURIComponent(MIZIK_API_USER)}&p=${encodeURIComponent(MIZIK_API_PASSWORD)}&id=${encodeURIComponent(id)}`
|
||||
const range = request.headers.get('range')
|
||||
|
||||
const upstreamResponse = await fetch(upstreamUrl, {
|
||||
headers: range ? {range} : {}
|
||||
})
|
||||
|
||||
const headers = new Headers()
|
||||
for (const key of FORWARDED_HEADERS) {
|
||||
const value = upstreamResponse.headers.get(key)
|
||||
if (value) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
status: upstreamResponse.status,
|
||||
headers
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -20,7 +20,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,7 +64,7 @@ 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)
|
||||
|
||||
|
||||
+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)
|
||||
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export default async function RootLayout({children}) {
|
||||
)
|
||||
|
||||
return (
|
||||
<html lang='fr' suppressHydrationWarning>
|
||||
<html suppressHydrationWarning lang='fr'>
|
||||
<body>
|
||||
{plausibleUrl
|
||||
? <PlausibleProvider src={plausibleUrl}>{inner}</PlausibleProvider>
|
||||
|
||||
+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} />
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 +21,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,7 +67,7 @@ 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)
|
||||
|
||||
+11
-19
@@ -1,7 +1,7 @@
|
||||
import {Suspense} from 'react'
|
||||
import {notFound} from 'next/navigation'
|
||||
import {jwennTeks} from '../../lib/oki-api'
|
||||
import TeksDrawer from '../../components/teks/teks-drawer'
|
||||
import PropTypes from 'prop-types'
|
||||
import TeksDrawerLoader from '../../components/teks/teks-drawer-loader'
|
||||
import {CurrentTrackProvider} from '../../components/teks/current-track-context'
|
||||
import Loading from './loading'
|
||||
|
||||
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'PAWÒL-NU. Paroles et traductions.'
|
||||
@@ -39,27 +39,19 @@ export const metadata = {
|
||||
}
|
||||
}
|
||||
|
||||
async function jwennDotDone() {
|
||||
const teks = await jwennTeks()
|
||||
|
||||
if (!teks) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return teks
|
||||
}
|
||||
|
||||
export default async function PawolLayout({children}) {
|
||||
const teks = await jwennDotDone()
|
||||
|
||||
export default function PawolLayout({children}) {
|
||||
return (
|
||||
<>
|
||||
<CurrentTrackProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<TeksDrawer paroles={teks} />
|
||||
<TeksDrawerLoader />
|
||||
</Suspense>
|
||||
<section>
|
||||
{children}
|
||||
</section>
|
||||
</>
|
||||
</CurrentTrackProvider>
|
||||
)
|
||||
}
|
||||
|
||||
PawolLayout.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -69,13 +69,13 @@ export default function ThemeRegistry(props) {
|
||||
return (
|
||||
<>
|
||||
<InitColorSchemeScript attribute='class' />
|
||||
<NextAppDirEmotionCacheProvider options={{key: 'mui'}}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline enableColorScheme />
|
||||
<ChanjeTem />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</NextAppDirEmotionCacheProvider>
|
||||
<NextAppDirEmotionCacheProvider options={{key: 'mui'}}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline enableColorScheme />
|
||||
<ChanjeTem />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</NextAppDirEmotionCacheProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+116
-89
@@ -12,6 +12,7 @@ import Button from '@mui/material/Button'
|
||||
import CardActionArea from '@mui/material/CardActionArea'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Container from '@mui/material/Container'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Card from '@mui/material/Card'
|
||||
@@ -20,10 +21,12 @@ import Typography from '@mui/material/Typography'
|
||||
import {green} from '@mui/material/colors'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import KeyboardBackspaceIcon from '@mui/icons-material/KeyboardBackspace'
|
||||
import MusicNoteIcon from '@mui/icons-material/MusicNote'
|
||||
import VerifiedIcon from '@mui/icons-material/Verified'
|
||||
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
import {StreamButton} from '../streaming-buttons'
|
||||
import {SocialButton} from './social-buttons'
|
||||
import AwtisBiyografi from './awtis-biyografi'
|
||||
import MizikLyen from './mizik-lyen'
|
||||
|
||||
@@ -35,43 +38,48 @@ const sortTeks = paroles => paroles.sort((a, b) => a.titre.localeCompare(b.titre
|
||||
|
||||
export default function AwtisDetay({anAwtis}) {
|
||||
const [esByografiOuve, meteEsByografiOuve] = useState(false)
|
||||
const {alias, biographie, paroles, photo, isExclusiveArtist, titrePhare} = anAwtis
|
||||
const {alias, biographie, paroles, photo, isExclusiveArtist, titrePhare, rezoSosyal} = anAwtis
|
||||
const sortedTeks = sortTeks(paroles)
|
||||
const gwanBiyo = biographie && biographie.length > 100
|
||||
const biyo = gwanBiyo ? `${biographie.slice(0, 100)}...` : biographie
|
||||
|
||||
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 (
|
||||
<Container>
|
||||
<Box sx={{mt: 8, mb: 2}}>
|
||||
<Typography sx={{textAlign: 'center'}} variant='h6' component='h1'>
|
||||
{alias}
|
||||
</Typography>
|
||||
<Container maxWidth='sm'>
|
||||
<Box sx={{mt: 2, mb: 1}}>
|
||||
<Link passHref href='/awtis'>
|
||||
<Button size='small' variant='text' startIcon={<KeyboardBackspaceIcon />} sx={{color: 'text.secondary'}}>
|
||||
Retour aux artistes
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box sx={{display: 'flex', justifyContent: 'center', mb: 2}}>
|
||||
{/* Héro */}
|
||||
<Box sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 2, pb: 3}}>
|
||||
<Box sx={{
|
||||
width: 200, height: 200,
|
||||
width: 150, height: 150,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${green[500]}`,
|
||||
border: `3px solid ${green[500]}`,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{photoUrl ? (
|
||||
<Image
|
||||
src={photoUrl}
|
||||
alt={`Photo ${alias}`}
|
||||
width={200}
|
||||
height={200}
|
||||
width={150}
|
||||
height={150}
|
||||
placeholder='blur'
|
||||
blurDataURL={BLUR_DATA_URL}
|
||||
style={{objectFit: 'cover', width: '100%', height: '100%'}}
|
||||
@@ -80,32 +88,44 @@ export default function AwtisDetay({anAwtis}) {
|
||||
<Box sx={{width: '100%', height: '100%', bgcolor: 'grey.300'}} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant='h5' component='h1' sx={{fontWeight: 700, mb: 1, textAlign: 'center'}}>
|
||||
{alias}
|
||||
</Typography>
|
||||
|
||||
{rezoSosyal?.length > 0 && (
|
||||
<Box sx={{display: 'flex', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center', mb: 1.5}}>
|
||||
{rezoSosyal.map(rezo => (
|
||||
<SocialButton key={rezo.url} rezo={rezo} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExclusiveArtist && (
|
||||
<Box sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5}}>
|
||||
<Chip
|
||||
icon={<VerifiedIcon sx={{fontSize: 16}} />}
|
||||
label={`Artiste ${EXCLUSIVE_LABEL}`}
|
||||
size='small'
|
||||
sx={{bgcolor: '#FFD700', color: '#000', fontWeight: 700, '& .MuiChip-icon': {color: '#000'}}}
|
||||
/>
|
||||
<Typography variant='caption' sx={{color: 'text.secondary', textAlign: 'center', maxWidth: 340}}>
|
||||
Certains morceaux sont publiés en exclusivité sur PAWÒL-NU, avant toute sortie sur les plateformes de streaming.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isExclusiveArtist && (
|
||||
<Box sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75, mb: 2}}>
|
||||
<Chip
|
||||
icon={<VerifiedIcon sx={{fontSize: 16}} />}
|
||||
label={`Artiste ${EXCLUSIVE_LABEL}`}
|
||||
size='small'
|
||||
sx={{bgcolor: '#FFD700', color: '#000', fontWeight: 700, '& .MuiChip-icon': {color: '#000'}}}
|
||||
/>
|
||||
<Typography variant='caption' sx={{color: 'text.secondary', textAlign: 'center'}}>
|
||||
Certains morceaux sont publiés en exclusivité sur PAWÒL-NU, avant toute sortie sur les plateformes de streaming.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Grid sx={{alignItems: 'center'}} container direction='column' spacing={3}>
|
||||
<Grid container direction='column' spacing={2}>
|
||||
{biyo && (
|
||||
<Grid size={{xs: 12, md: 6}}>
|
||||
<Card sx={{minWidth: 300}}>
|
||||
<Grid size={12}>
|
||||
<Card>
|
||||
<CardActionArea onClick={() => meteEsByografiOuve(true)}>
|
||||
<CardContent>
|
||||
<Typography gutterBottom variant='body1' component='h2'>
|
||||
<strong>Biographie</strong>
|
||||
<Typography gutterBottom variant='body2' sx={{fontWeight: 700, textTransform: 'uppercase', color: 'text.secondary', letterSpacing: 0.5}}>
|
||||
Biographie
|
||||
</Typography>
|
||||
<Typography textalign='justify' variant='subtitle1' component='h3'>
|
||||
<Typography variant='body1'>
|
||||
{biyo}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
@@ -113,36 +133,48 @@ export default function AwtisDetay({anAwtis}) {
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{hasStreaming && (
|
||||
<Grid size={{xs: 12, md: 6}}>
|
||||
<Grid size={12}>
|
||||
<Card sx={{overflow: 'hidden'}}>
|
||||
<Box sx={{px: 2, pt: 1.5, pb: 0.5, display: 'flex', alignItems: 'center', gap: 0.75}}>
|
||||
<MusicNoteIcon sx={{fontSize: 14, color: 'text.secondary'}} />
|
||||
<Typography variant='caption' sx={{fontWeight: 700, textTransform: 'uppercase', color: 'text.secondary', letterSpacing: 0.5}}>
|
||||
Titre phare
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Grid container>
|
||||
{coverUrl && (
|
||||
<Grid size={{xs: 12, sm: 4}}>
|
||||
<Box sx={{position: 'relative', minHeight: 140, height: '100%'}}>
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={titrePhare.titre}
|
||||
fill
|
||||
placeholder='blur'
|
||||
blurDataURL={BLUR_DATA_URL}
|
||||
sizes='200px'
|
||||
style={{objectFit: 'cover'}}
|
||||
/>
|
||||
</Box>
|
||||
<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}
|
||||
placeholder='blur'
|
||||
blurDataURL={BLUR_DATA_URL}
|
||||
sizes='200px'
|
||||
style={{objectFit: 'cover'}}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid size={{xs: 12, sm: coverUrl ? 8 : 12}}>
|
||||
<CardContent>
|
||||
<Typography variant='subtitle1' sx={{fontWeight: 700}} gutterBottom>
|
||||
{titrePhare.titre}
|
||||
<Typography gutterBottom variant='subtitle1' sx={{fontWeight: 700}}>
|
||||
<Link href={`/paroles/${titrePhare.slug}`} style={{color: 'inherit', textDecoration: 'none'}}>
|
||||
{titrePhare.titre}
|
||||
</Link>
|
||||
</Typography>
|
||||
<Typography variant='caption' sx={{color: 'text.secondary', display: 'block', mb: 1.5}}>
|
||||
<Typography variant='caption' sx={{color: 'text.secondary', display: 'block', mb: 1}}>
|
||||
Écouter sur
|
||||
</Typography>
|
||||
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 1}}>
|
||||
{titrePhare.streamAudio.map((lyen, i) => (
|
||||
<StreamButton key={i} lyen={lyen} />
|
||||
{titrePhare.streamAudio.map(lyen => (
|
||||
<StreamButton key={lyen.url} lyen={lyen} />
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
@@ -151,47 +183,42 @@ export default function AwtisDetay({anAwtis}) {
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid size={{xs: 12, md: 6}}>
|
||||
<Box marginbottom={3}>
|
||||
{paroles.length > 1 ? (
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls='panel-teks-content'
|
||||
id='panel-teks-header'
|
||||
>
|
||||
<Typography sx={{marginRight: 2, textAlign: 'center'}} variant='body1' component='h2'><strong>Liste des paroles</strong></Typography>
|
||||
<Chip color='primary' label={paroles.length} size='small' variant='contained' />
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{paddingInline: 0}}>
|
||||
{sortedTeks.map(anPawol => {
|
||||
const {couverture} = anPawol
|
||||
const kuvetiFormat = couverture?.formats?.thumbnail || formatKuveti(couverture)
|
||||
return (
|
||||
<Box key={anPawol.id} sx={{paddingBlock: 0.5}}>
|
||||
<MizikLyen anPawol={anPawol} kuveti={kuvetiFormat} />
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
) : (
|
||||
paroles.length === 0 ? (
|
||||
<Typography gutterBottom textAlign='center' variant='body1' component='h2'><strong>Aucune parole pour le moment</strong></Typography>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography gutterBottom textalign='center' variant='body1' component='h2'><strong>Parole</strong></Typography>
|
||||
<Paper sx={{height: '100%', paddingBlock: 2}}>
|
||||
<MizikLyen anPawol={paroles[0]} kuveti={paroles[0].couverture?.formats?.thumbnail || formatKuveti(paroles[0].couverture)} />
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid size={12}>
|
||||
{paroles.length > 1 ? (
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls='panel-teks-content' id='panel-teks-header'>
|
||||
<Typography sx={{marginRight: 2}} variant='body1'><strong>Liste des paroles</strong></Typography>
|
||||
<Chip color='primary' label={paroles.length} size='small' variant='contained' />
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{paddingInline: 0}}>
|
||||
{sortedTeks.map(anPawol => {
|
||||
const {couverture} = anPawol
|
||||
const kuvetiFormat = formatKuveti(couverture, 'thumbnail')
|
||||
return (
|
||||
<Box key={anPawol.id} sx={{paddingBlock: 0.5}}>
|
||||
<MizikLyen anPawol={anPawol} kuveti={kuvetiFormat} />
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
) : (paroles.length === 0 ? (
|
||||
<Typography textAlign='center' variant='body1' color='text.secondary'>
|
||||
Aucune parole pour le moment
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography gutterBottom variant='body1'><strong>Parole</strong></Typography>
|
||||
<Paper sx={{paddingBlock: 2}}>
|
||||
<MizikLyen anPawol={paroles[0]} kuveti={formatKuveti(paroles[0].couverture, 'thumbnail')} />
|
||||
</Paper>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{textAlign: 'center', marginBlock: 3}}>
|
||||
<Box sx={{textAlign: 'center', my: 4}}>
|
||||
<Link passHref href='/awtis'>
|
||||
<Button variant='outlined' startIcon={<KeyboardBackspaceIcon />}>
|
||||
Retour aux artistes
|
||||
|
||||
@@ -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,8 +82,8 @@ export default function AwtisKat({artiste}) {
|
||||
className={classes.media}
|
||||
component='img'
|
||||
alt={alias}
|
||||
image={`${photo?.url ? `${IMAGE_URL}${photo?.formats?.thumbnail?.url || photo?.url}` : noImageUrl}`}
|
||||
loading='lazy'
|
||||
image={`${photo?.url ? `${IMAGE_URL}${formatKuveti(photo, 'thumbnail')?.url}` : noImageUrl}`}
|
||||
loading='lazy'
|
||||
title={alias}
|
||||
/>
|
||||
<CardContent>
|
||||
|
||||
@@ -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) =>
|
||||
-b.firstLetter.localeCompare(a.firstLetter)
|
||||
);
|
||||
}, [options]);
|
||||
const sortedOptions = useMemo(() => [...options].sort((a, b) =>
|
||||
-b.firstLetter.localeCompare(a.firstLetter)
|
||||
), [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)}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'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'
|
||||
import {
|
||||
Mastodon, Peertube,
|
||||
Instagram, Youtube, Tiktok,
|
||||
Spotify, Deezer, Applemusic, Bandcamp, Soundcloud,
|
||||
Facebook, Twitter,
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
|
||||
const SOCIAL_CONFIG = {
|
||||
Mastodon: {label: 'Mastodon', bg: '#6364FF', color: '#fff', Icon: Mastodon},
|
||||
Peertube: {label: 'PeerTube', bg: '#F2690D', color: '#fff', Icon: Peertube},
|
||||
Pixelfed: {label: 'Pixelfed', bg: '#11D49D', color: '#fff', Icon: null},
|
||||
Funkwhale: {label: 'Funkwhale', bg: '#E01B60', color: '#fff', Icon: null},
|
||||
Bluesky: {label: 'Bluesky', bg: '#0085FF', color: '#fff', Icon: null},
|
||||
Instagram: {label: 'Instagram', bg: '#E4405F', color: '#fff', Icon: Instagram},
|
||||
Youtube: {label: 'YouTube', bg: '#FF0000', color: '#fff', Icon: Youtube},
|
||||
Tiktok: {label: 'TikTok', bg: '#000000', color: '#fff', Icon: Tiktok},
|
||||
Spotify: {label: 'Spotify', bg: '#1DB954', color: '#fff', Icon: Spotify},
|
||||
Deezer: {label: 'Deezer', bg: '#EF5466', color: '#fff', Icon: Deezer},
|
||||
Applemusic: {label: 'Apple Music', bg: '#FC3C44', color: '#fff', Icon: Applemusic},
|
||||
Bandcamp: {label: 'Bandcamp', bg: '#1DA0C3', color: '#fff', Icon: Bandcamp},
|
||||
Soundcloud: {label: 'SoundCloud', bg: '#FF5500', color: '#fff', Icon: Soundcloud},
|
||||
Facebook: {label: 'Facebook', bg: '#1877F2', color: '#fff', Icon: Facebook},
|
||||
Twitter: {label: 'X / Twitter', bg: '#000000', color: '#fff', Icon: Twitter},
|
||||
Linktree: {label: 'Linktree', bg: '#43E660', color: '#000', Icon: null},
|
||||
SiteWeb: {label: 'Site web', bg: '#555555', color: '#fff', Icon: null},
|
||||
}
|
||||
|
||||
export function SocialButton({rezo}) {
|
||||
const config = SOCIAL_CONFIG[rezo.plateforme] ?? {label: rezo.plateforme, bg: '#555', color: '#fff', Icon: null}
|
||||
const PlatformIcon = config.Icon
|
||||
|
||||
if (PlatformIcon) {
|
||||
return (
|
||||
<Tooltip title={config.label}>
|
||||
<IconButton
|
||||
component='a'
|
||||
href={rezo.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
size='small'
|
||||
sx={{
|
||||
bgcolor: config.bg,
|
||||
width: 34,
|
||||
height: 34,
|
||||
'&:hover': {bgcolor: config.bg, opacity: 0.8},
|
||||
}}
|
||||
>
|
||||
<PlatformIcon size={17} color={config.color} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={rezo.url}>
|
||||
<Chip
|
||||
clickable
|
||||
label={config.label}
|
||||
component='a'
|
||||
href={rezo.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
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 => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import {useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import {useTheme} from '@mui/material/styles'
|
||||
|
||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||
import {Nextcloud} from '@icons-pack/react-simple-icons'
|
||||
|
||||
const PLATFORM_ICON = {
|
||||
NIYAJ: Nextcloud,
|
||||
kDrive: CloudDownloadIcon,
|
||||
}
|
||||
|
||||
export default function KitsDialog({kits}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const theme = useTheme()
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const handleClickOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button endIcon={<CloudDownloadIcon />} sx={{marginBottom: 2}} variant='outlined' onClick={handleClickOpen}>
|
||||
Kits
|
||||
</Button>
|
||||
<Dialog
|
||||
fullScreen={fullScreen}
|
||||
open={open}
|
||||
aria-labelledby='KitsDialog'
|
||||
onClose={handleClose}
|
||||
>
|
||||
<DialogTitle>Télécharger un kit</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<List>
|
||||
{kits.map(kit => {
|
||||
const PlatformIcon = PLATFORM_ICON[kit.plateforme] ?? CloudDownloadIcon
|
||||
return (
|
||||
<ListItem key={kit.url} disablePadding>
|
||||
<ListItemButton component='a' href={kit.url} target='_blank' rel='noopener noreferrer'>
|
||||
<ListItemIcon>
|
||||
<PlatformIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={kit.type} secondary={kit.plateforme} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button autoFocus onClick={handleClose}>
|
||||
Fermer
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
KitsDialog.propTypes = {
|
||||
kits: PropTypes.array.isRequired
|
||||
}
|
||||
@@ -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,141 +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>
|
||||
</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,433 +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: '🇮🇹'
|
||||
}
|
||||
}
|
||||
|
||||
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: ''})
|
||||
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} = 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
|
||||
},
|
||||
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
|
||||
},
|
||||
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: ''})
|
||||
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 || ''
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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 !== ''}
|
||||
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,18 +1,19 @@
|
||||
'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'
|
||||
|
||||
export const PLATFORM_CONFIG = {
|
||||
Spotify: {label: 'Spotify', bg: '#1DB954', color: '#fff', Icon: Spotify},
|
||||
Applemusic: {label: 'Apple Music', bg: '#FC3C44', color: '#fff', Icon: Applemusic},
|
||||
Deezer: {label: 'Deezer', bg: '#EF5466', color: '#fff', Icon: Deezer},
|
||||
Tidal: {label: 'Tidal', bg: '#000000', color: '#fff', Icon: Tidal},
|
||||
Qobuz: {label: 'Qobuz', bg: '#00245B', color: '#fff', Icon: null},
|
||||
Youtubemusic: {label: 'YouTube Music', bg: '#FF0000', color: '#fff', Icon: Youtubemusic},
|
||||
Amazon: {label: 'Amazon Music', bg: '#00A8E1', color: '#fff', Icon: Amazon},
|
||||
Soundcloud: {label: 'SoundCloud', bg: '#FF5500', color: '#fff', Icon: Soundcloud},
|
||||
Spotify: {label: 'Spotify', bg: '#1DB954', color: '#fff', Icon: Spotify},
|
||||
Applemusic: {label: 'Apple Music', bg: '#FC3C44', color: '#fff', Icon: Applemusic},
|
||||
Deezer: {label: 'Deezer', bg: '#EF5466', color: '#fff', Icon: Deezer},
|
||||
Tidal: {label: 'Tidal', bg: '#000000', color: '#fff', Icon: Tidal},
|
||||
Qobuz: {label: 'Qobuz', bg: '#00245B', color: '#fff', Icon: null},
|
||||
Youtubemusic: {label: 'YouTube Music', bg: '#FF0000', color: '#fff', Icon: Youtubemusic},
|
||||
Amazon: {label: 'Amazon Music', bg: '#00A8E1', color: '#fff', Icon: Amazon},
|
||||
Soundcloud: {label: 'SoundCloud', bg: '#FF5500', color: '#fff', Icon: Soundcloud},
|
||||
}
|
||||
|
||||
export function StreamButton({lyen}) {
|
||||
@@ -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,55 @@
|
||||
import {describe, it, expect, vi, afterEach} from 'vitest'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ReponsMastodon from '../repons-mastodon'
|
||||
|
||||
describe('ReponsMastodon', () => {
|
||||
const originalOpen = window.open
|
||||
|
||||
afterEach(() => {
|
||||
window.open = originalOpen
|
||||
})
|
||||
|
||||
it('ne rend rien sans bokanteStatusId', () => {
|
||||
const {container} = render(<ReponsMastodon bokanteStatusId={null} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('affiche un lien direct vers le fil bokante', () => {
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
const lien = screen.getByRole('link', {name: 'Mastodon'})
|
||||
expect(lien).toHaveAttribute('href', 'https://bokante.o-k-i.net/@pawol_nu/112233')
|
||||
})
|
||||
|
||||
it('redirige vers authorize_interaction avec le domaine extrait d\'un identifiant complet', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre via mastodon/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), '@quelqun@mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepte aussi juste un nom d\'instance sans @', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre via mastodon/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://mastodon.social/authorize_interaction'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,6 @@ export default function AnTeks({parole, paroleId}) {
|
||||
<Teks
|
||||
parole={parole}
|
||||
paroleId={paroleId}
|
||||
commentaires={parole?.commentaires}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import {createContext, useContext, useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const CurrentTrackContext = createContext([null, () => {}])
|
||||
|
||||
export function CurrentTrackProvider({children}) {
|
||||
const state = useState(null)
|
||||
|
||||
return (
|
||||
<CurrentTrackContext.Provider value={state}>
|
||||
{children}
|
||||
</CurrentTrackContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useCurrentTrack() {
|
||||
return useContext(CurrentTrackContext)
|
||||
}
|
||||
|
||||
CurrentTrackProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
}
|
||||
@@ -1,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,83 @@
|
||||
'use client'
|
||||
|
||||
import {useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Link from 'next/link'
|
||||
|
||||
const BOKANTE_URL = process.env.NEXT_PUBLIC_BOKANTE_URL || 'https://bokante.o-k-i.net'
|
||||
const BOKANTE_ACCOUNT = process.env.NEXT_PUBLIC_BOKANTE_ACCOUNT || 'pawol_nu'
|
||||
|
||||
function extraireInstance(saisie) {
|
||||
const valeur = saisie.trim().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
return valeur.includes('@') ? valeur.split('@').pop() : valeur
|
||||
}
|
||||
|
||||
export default function ReponsMastodon({bokanteStatusId}) {
|
||||
const [ouvert, setOuvert] = useState(false)
|
||||
const [saisie, setSaisie] = useState('')
|
||||
|
||||
if (!bokanteStatusId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tootUrl = `${BOKANTE_URL}/@${BOKANTE_ACCOUNT}/${bokanteStatusId}`
|
||||
|
||||
const repondre = () => {
|
||||
const instance = extraireInstance(saisie)
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
window.open(`https://${instance}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer')
|
||||
setOuvert(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{maxWidth: 700, margin: '1em auto 0', textAlign: 'center'}}>
|
||||
<Typography variant='body2' sx={{mb: 1}}>
|
||||
Rejoignez la discussion sur{' '}
|
||||
<Link href={tootUrl} target='_blank' rel='noopener noreferrer'>Mastodon</Link>
|
||||
</Typography>
|
||||
<Button variant='outlined' size='small' onClick={() => setOuvert(true)}>
|
||||
Répondre via Mastodon
|
||||
</Button>
|
||||
<Dialog open={ouvert} onClose={() => setOuvert(false)}>
|
||||
<DialogTitle>Répondre depuis votre compte Mastodon</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant='body2' sx={{mb: 2}}>
|
||||
Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre
|
||||
instance, vous serez redirigé·e vers votre compte pour répondre.
|
||||
</Typography>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label='@vous@votre-instance'
|
||||
value={saisie}
|
||||
onChange={event => setSaisie(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
repondre()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOuvert(false)}>Annuler</Button>
|
||||
<Button variant='contained' onClick={repondre}>Continuer</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
ReponsMastodon.propTypes = {
|
||||
bokanteStatusId: PropTypes.string
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {useState, useEffect, forwardRef} from 'react'
|
||||
import Link from 'next/link'
|
||||
import {useParams} from 'next/navigation'
|
||||
import PropTypes from 'prop-types'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Box from '@mui/material/Box'
|
||||
@@ -19,6 +18,7 @@ import VweKouteAchte from './vwe-koute-achte'
|
||||
import DrawerBar from './drawer-bar'
|
||||
import Pataje from './pataje'
|
||||
import KaraokeModal from './karaoke-modal'
|
||||
import {useCurrentTrack} from './current-track-context'
|
||||
|
||||
const drawerWidth = 240
|
||||
|
||||
@@ -27,14 +27,12 @@ const Alert = forwardRef(function Alert(props, ref) {
|
||||
})
|
||||
|
||||
export default function TeksDrawer({paroles}) {
|
||||
const {slug} = useParams()
|
||||
const [parole] = useCurrentTrack()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const parole = paroles.find((parole) => parole.slug === slug)
|
||||
|
||||
useEffect(() => {
|
||||
if (error || success) {
|
||||
setOpen(true)
|
||||
@@ -84,7 +82,7 @@ export default function TeksDrawer({paroles}) {
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
{parole && (
|
||||
<Link passHref href='/paroles'>
|
||||
<Link passHref prefetch={false} href='/paroles'>
|
||||
<IconButton aria-label='return' size='medium'>
|
||||
<KeyboardBackspaceIcon style={{fontSize: '1.5em'}} />
|
||||
</IconButton>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+169
-77
@@ -1,9 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect} from 'react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Box from '@mui/material/Box'
|
||||
import Grid from '@mui/material/Grid'
|
||||
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'
|
||||
@@ -12,18 +16,26 @@ import slugify from 'slugify'
|
||||
|
||||
import {styled} from '@mui/material/styles'
|
||||
import ExplicitIcon from '@mui/icons-material/Explicit'
|
||||
import TranslateIcon from '@mui/icons-material/Translate'
|
||||
import StarIcon from '@mui/icons-material/Star'
|
||||
import ArticleIcon from '@mui/icons-material/Article'
|
||||
|
||||
import Image from 'next/image'
|
||||
|
||||
import {formatJsonString, getAlias} from '../../lib/utils/format'
|
||||
import {formatJsonString} from '../../lib/utils/format'
|
||||
import {getAlias} from '../../lib/utils/get-alias'
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
|
||||
import LicenseModal from '../cc/license-modal'
|
||||
import FilesDialog from '../files/files-dialog'
|
||||
import KitsDialog from '../files/kits-dialog'
|
||||
import {StreamButton} from '../streaming-buttons'
|
||||
import EntegreMizik from './entegre-mizik'
|
||||
import OkiMizik from './oki-mizik'
|
||||
import DiferansDialog from './diferans-dialog'
|
||||
import Komante from './komante'
|
||||
import ReponsMastodon from './repons-mastodon'
|
||||
import {useCurrentTrack} from './current-track-context'
|
||||
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
|
||||
@@ -78,54 +90,57 @@ const Root = styled('div')((
|
||||
},
|
||||
}))
|
||||
|
||||
const LANG_NAMES = {fr: 'Français', en: 'English', es: 'Español', de: 'Deutsch', it: 'Italiano'}
|
||||
|
||||
const langToArray = parole => {
|
||||
const langArray = []
|
||||
|
||||
if (parole && parole.traductions) {
|
||||
const {francais, anglais, espagnol, allemand, italien} = parole.traductions
|
||||
|
||||
if (anglais) {
|
||||
langArray.push({title: 'English', lang: anglais})
|
||||
}
|
||||
|
||||
if (francais) {
|
||||
langArray.push({title: 'Français', lang: francais})
|
||||
}
|
||||
|
||||
if (espagnol) {
|
||||
langArray.push({title: 'Español', lang: espagnol})
|
||||
}
|
||||
|
||||
if (allemand) {
|
||||
langArray.push({title: 'Deutsch', lang: allemand})
|
||||
}
|
||||
|
||||
if (italien) {
|
||||
langArray.push({title: 'Italiano', lang: italien})
|
||||
}
|
||||
}
|
||||
|
||||
return langArray
|
||||
const LANG_NAMES = {
|
||||
fr: 'Français', en: 'English', es: 'Español', de: 'Deutsch', it: 'Italiano',
|
||||
pt: 'Português', ja: '日本語', ko: '한국어',
|
||||
}
|
||||
|
||||
const alignTeks = (langArray, isMobile) => {
|
||||
if (langArray.length > 0 && !isMobile) {
|
||||
return 'justify'
|
||||
const CONTINENTS = ['Afrique', 'Asie', 'Europe']
|
||||
|
||||
const TRAD_FIELDS = [
|
||||
// Afrique — langues vedettes en premier
|
||||
{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 => {
|
||||
if (!parole || !parole.traductions) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (langArray.length > 0 && isMobile) {
|
||||
return 'justify'
|
||||
}
|
||||
return TRAD_FIELDS
|
||||
.filter(({field}) => parole.traductions[field])
|
||||
.map(({field, title, continent, vedette}) => ({field, title, continent, vedette, lang: parole.traductions[field]}))
|
||||
}
|
||||
|
||||
if (langArray.length === 0 && isMobile) {
|
||||
return 'justify'
|
||||
}
|
||||
|
||||
if (langArray.length === 0 && !isMobile) {
|
||||
return 'center'
|
||||
}
|
||||
const BROWSER_LANG_TO_FIELD = {
|
||||
yo: 'yoruba', ln: 'lingala', wo: 'wolof', sw: 'swahili', ha: 'hausa',
|
||||
ar: 'arabe', om: 'oromo', ig: 'igbo', zu: 'zoulou', mg: 'malgache',
|
||||
xh: 'xhosa', tn: 'tswana', ts: 'tsonga', st: 'sesotho',
|
||||
ja: 'japonais', ko: 'coreen', en: 'anglais', fr: 'francais',
|
||||
es: 'espagnol', de: 'allemand', it: 'italien', pt: 'portugais',
|
||||
}
|
||||
|
||||
const ExplicitTooltip = Tooltip
|
||||
@@ -135,6 +150,8 @@ export default function Teks({parole}) {
|
||||
const langArray = langToArray(parole)
|
||||
const enhancedAliases = getAlias(parole.artistes, parole.prioriteArtistes, true)
|
||||
const coverFmt = formatKuveti(parole.couverture)
|
||||
const [tab, setTab] = useState(0)
|
||||
const [, setCurrentTrack] = useCurrentTrack()
|
||||
|
||||
useEffect(() => {
|
||||
const isBrowser = () => typeof window !== 'undefined'
|
||||
@@ -145,6 +162,25 @@ export default function Teks({parole}) {
|
||||
window.scrollTo({top: 0, behavior: 'smooth'})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTrack(parole)
|
||||
return () => setCurrentTrack(null)
|
||||
}, [parole, setCurrentTrack])
|
||||
|
||||
useEffect(() => {
|
||||
const browserLang = navigator.language?.split('-')[0]?.toLowerCase()
|
||||
if (!browserLang || browserLang === parole.langueSource) {
|
||||
return
|
||||
}
|
||||
|
||||
const champ = BROWSER_LANG_TO_FIELD[browserLang]
|
||||
const index = langArray.findIndex(lang => lang.field === champ)
|
||||
if (index !== -1) {
|
||||
setTab(index + 1)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- ne doit s'exécuter qu'au montage, pas à chaque changement d'onglet
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Root className={classes.container}>
|
||||
<Box sx={{textAlign: 'center', marginTop: 12}}>
|
||||
@@ -194,7 +230,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>
|
||||
@@ -204,8 +240,15 @@ export default function Teks({parole}) {
|
||||
<LicenseModal license={parole.creativeCommons.toLowerCase()} sourceOriginale={parole.sourceOriginale ?? null} remixes={parole.remixes?.length ? parole.remixes : null} />
|
||||
</Box>
|
||||
)}
|
||||
{parole?.files && (
|
||||
<FilesDialog files={parole.files} />
|
||||
{(parole?.files || (parole.creativeCommons && parole.kits?.length > 0)) && (
|
||||
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1}}>
|
||||
{parole?.files && (
|
||||
<FilesDialog files={parole.files} />
|
||||
)}
|
||||
{parole.creativeCommons && parole.kits?.length > 0 && (
|
||||
<KitsDialog kits={parole.kits} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{(parole.okiMizikID || parole.streamAudio.length > 0 || parole.gadeEmbed) && (
|
||||
<Box sx={{textAlign: 'center'}}>
|
||||
@@ -224,42 +267,91 @@ 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>
|
||||
<Grid container justifycontent='center' spacing={1}>
|
||||
<Grid size={{xs: 12, md: langArray.length > 0 ? 6 : null}}>
|
||||
<Box className={classes.gridText}>
|
||||
<Typography align='center' sx={{marginBottom: '0.5em'}} variant='h4'>
|
||||
Transcription
|
||||
{parole.langueSource && parole.langueSource !== 'ka' && (
|
||||
<Typography component='span' variant='body2' color='text.secondary' sx={{ml: 1}}>
|
||||
({LANG_NAMES[parole.langueSource]})
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography paragraph='true' align={alignTeks(langArray, isMobile)} component='span'>
|
||||
<Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}>
|
||||
<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}
|
||||
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={{
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'primary.main',
|
||||
lineHeight: '2.5em'
|
||||
}}
|
||||
>
|
||||
{continent}
|
||||
</ListSubheader>,
|
||||
...items.map(({title, index, vedette}) => (
|
||||
<MenuItem key={title} value={index + 1} sx={{display: 'flex', gap: 0.75}}>
|
||||
{vedette && <StarIcon fontSize='inherit' color='primary' />}
|
||||
{title}
|
||||
</MenuItem>
|
||||
))
|
||||
]
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
{tab === 0 && (
|
||||
<>
|
||||
{parole.langueSource && parole.langueSource !== 'ka' && (
|
||||
<Typography align='center' variant='body2' color='text.secondary' sx={{mb: 1}}>
|
||||
({LANG_NAMES[parole.langueSource]})
|
||||
</Typography>
|
||||
)}
|
||||
<Typography paragraph='true' align={isMobile ? 'justify' : 'center'} component='span'>
|
||||
{formatJsonString(parole.transcription)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
{langArray.map(({title, lang}) => (
|
||||
<Grid key={title} size={{xs: 12, md: 6}}>
|
||||
<Box className={classes.gridText}>
|
||||
<Typography align='center' sx={{marginBottom: '0.5em'}} variant='h4'>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography paragraph='true' align='justify' component='span'>
|
||||
{formatJsonString(lang)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
{langArray.map(({title, lang}, index) => (
|
||||
tab === index + 1 && (
|
||||
<Typography key={title} paragraph='true' align='justify' component='span'>
|
||||
{formatJsonString(lang)}
|
||||
</Typography>
|
||||
)
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
<ReponsMastodon bokanteStatusId={parole.bokanteStatusId} />
|
||||
<Komante commentaires={parole.commentaires} />
|
||||
</Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import PropTypes from 'prop-types'
|
||||
import NextTopLoader from 'nextjs-toploader'
|
||||
|
||||
export default function TopLoader({color}) {
|
||||
return <NextTopLoader color={color} />
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
+11
-4
@@ -1,14 +1,21 @@
|
||||
const SIZE_ORDER = {
|
||||
large: ['large', 'medium', 'small'],
|
||||
large: ['large', 'medium', 'small'],
|
||||
medium: ['medium', 'small', 'large'],
|
||||
small: ['small', 'medium', '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
|
||||
}
|
||||
|
||||
+54
-54
@@ -2,7 +2,7 @@ import qs from 'qs'
|
||||
|
||||
const OKI_API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
|
||||
const AWTIS_POU_CHAK_PAJ = process.env.NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ || 6
|
||||
const readToken = process.env.NEXT_PUBLIC_READ_TOKEN || 'read-token'
|
||||
const readToken = process.env.API_READ_TOKEN || 'read-token'
|
||||
|
||||
const headers = {
|
||||
next: {revalidate: 60},
|
||||
@@ -14,12 +14,18 @@ const headers = {
|
||||
async function request(url, opts = {}) {
|
||||
const {...options} = opts
|
||||
|
||||
let res
|
||||
try {
|
||||
const res = await fetch(`${OKI_API}${url}`, options)
|
||||
return res.json()
|
||||
res = await fetch(`${OKI_API}${url}`, options)
|
||||
} catch {
|
||||
throw new Error('Eshwe pu jwenn done-a')
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Eshwe pu jwenn done-a')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function jwennTeksEpiSlug(slug) {
|
||||
@@ -32,7 +38,16 @@ export async function jwennTeksEpiSlug(slug) {
|
||||
fields: ['username', 'firstname']
|
||||
},
|
||||
commentaires: {
|
||||
fields: ['contenu', 'datePublication'],
|
||||
fields: [
|
||||
'contenu',
|
||||
'datePublication',
|
||||
'origine',
|
||||
'auteurNom',
|
||||
'auteurHandle',
|
||||
'auteurAvatarUrl',
|
||||
'auteurProfilUrl',
|
||||
'remoteUrl'
|
||||
],
|
||||
populate: {
|
||||
user: {
|
||||
fields: ['username']
|
||||
@@ -54,6 +69,9 @@ export async function jwennTeksEpiSlug(slug) {
|
||||
streamVideo: {
|
||||
populate: '*'
|
||||
},
|
||||
kits: {
|
||||
populate: '*'
|
||||
},
|
||||
traductions: {
|
||||
populate: '*'
|
||||
},
|
||||
@@ -92,7 +110,7 @@ export async function jwennTeksEpiSlug(slug) {
|
||||
|
||||
export async function jwennAwtisEpiSlug(slug) {
|
||||
const query = qs.stringify({
|
||||
populate: ['paroles', 'photo', 'paroles.couverture', 'titrePhare', 'titrePhare.streamAudio', 'titrePhare.streamVideo', 'titrePhare.couverture'],
|
||||
populate: ['paroles', 'photo', 'paroles.couverture', 'titrePhare', 'titrePhare.streamAudio', 'titrePhare.streamVideo', 'titrePhare.couverture', 'rezoSosyal'],
|
||||
filters: {
|
||||
slug: {
|
||||
$eq: slug
|
||||
@@ -130,7 +148,7 @@ export async function jwennToutAwtis() {
|
||||
populate: ['photo'],
|
||||
sort: ['alias:asc'],
|
||||
pagination: {
|
||||
pageSize: 100
|
||||
pageSize: 200
|
||||
}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
@@ -143,39 +161,16 @@ export async function jwennToutAwtis() {
|
||||
export async function jwennTeks() {
|
||||
const query = qs.stringify({
|
||||
populate: {
|
||||
user: {
|
||||
fields: ['username']
|
||||
},
|
||||
userAdmin: {
|
||||
fields: ['username', 'firstname']
|
||||
},
|
||||
commentaires: {
|
||||
fields: ['contenu', 'datePublication'],
|
||||
populate: {
|
||||
user: {
|
||||
fields: ['username']
|
||||
}
|
||||
}
|
||||
},
|
||||
couverture: {
|
||||
populate: '*'
|
||||
},
|
||||
artistes: {
|
||||
fields: ['alias', 'slug']
|
||||
},
|
||||
streamAudio: {
|
||||
populate: '*'
|
||||
},
|
||||
streamVideo: {
|
||||
populate: '*'
|
||||
},
|
||||
traductions: {
|
||||
populate: '*'
|
||||
}
|
||||
},
|
||||
sort: ['titre:asc'],
|
||||
pagination: {
|
||||
pageSize: 100
|
||||
pageSize: 200
|
||||
}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
@@ -185,32 +180,37 @@ export async function jwennTeks() {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function jwennAwtisSlug() {
|
||||
const query = qs.stringify({
|
||||
sort: ['createdAt:desc'],
|
||||
pagination: {
|
||||
pageSize: 100
|
||||
}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
})
|
||||
async function jwennTouSlug(endpoint) {
|
||||
const pageSize = 200
|
||||
const slugs = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
|
||||
const {data} = await request(`/artistes?${query}`, headers)
|
||||
return data.map(({slug}) => slug)
|
||||
while (hasMore) {
|
||||
const query = qs.stringify({
|
||||
sort: ['createdAt:desc'],
|
||||
fields: ['slug'],
|
||||
pagination: {page, pageSize}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
})
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop -- pagination séquentielle, chaque page dépend du nombre de résultats de la précédente
|
||||
const {data} = await request(`/${endpoint}?${query}`, headers)
|
||||
slugs.push(...data.map(({slug}) => slug))
|
||||
hasMore = data.length === pageSize
|
||||
page += 1
|
||||
}
|
||||
|
||||
return slugs
|
||||
}
|
||||
|
||||
export async function jwennAwtisSlug() {
|
||||
return jwennTouSlug('artistes')
|
||||
}
|
||||
|
||||
export async function jwennTeksSlug() {
|
||||
const query = qs.stringify({
|
||||
sort: ['createdAt:desc'],
|
||||
pagination: {
|
||||
pageSize: 100
|
||||
}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
})
|
||||
|
||||
const {data} = await request(`/paroles?${query}`, headers)
|
||||
return data.map(({slug}) => slug)
|
||||
return jwennTouSlug('paroles')
|
||||
}
|
||||
|
||||
export async function jwennDenyeTeks() {
|
||||
@@ -233,9 +233,9 @@ export async function jwennAnVedette() {
|
||||
const query = qs.stringify({
|
||||
populate: ['artistes', 'couverture'],
|
||||
filters: {
|
||||
isNewRelease: { $eq: true }
|
||||
isNewRelease: {$eq: true}
|
||||
},
|
||||
pagination: { limit: 1 }
|
||||
pagination: {limit: 1}
|
||||
}, {
|
||||
encodeValuesOnly: true
|
||||
})
|
||||
|
||||
@@ -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})
|
||||
}))
|
||||
}
|
||||
+10
-11
@@ -11,26 +11,25 @@ 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
|
||||
}
|
||||
|
||||
module.exports = (withPWA({
|
||||
turbopack: {},
|
||||
webpack: config => {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ['@svgr/webpack', 'url-loader'],
|
||||
})
|
||||
|
||||
return config
|
||||
},
|
||||
images: {
|
||||
remotePatterns: buildRemotePatterns()
|
||||
}
|
||||
|
||||
+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,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