14 Commits
Author SHA1 Message Date
cedric 4cc5c405bb feat: add ayisyen
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 31s
Déploiement FRONT PROD / deploy (push) Successful in 49s
2026-08-11 07:31:20 +04:00
cedric c8841a253f fix: rendre le proxy MIZIK plus robuste et tolérer l'ancien nommage des credentials
Déploiement FRONT BETA / check (push) Successful in 2m18s
Déploiement FRONT PROD / check (push) Successful in 2m8s
Déploiement FRONT BETA / deploy (push) Successful in 25s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-15 23:04:19 +04:00
cedric 4e6341c5d5 refactor: renommer middleware.js en proxy.js pour Next.js 16
Déploiement FRONT BETA / check (push) Successful in 2m8s
Déploiement FRONT PROD / check (push) Successful in 2m15s
Déploiement FRONT BETA / deploy (push) Successful in 24s
Déploiement FRONT PROD / deploy (push) Successful in 23s
2026-07-08 00:29:26 +04:00
cedric b5a3b278ba feat: ajouter les en-têtes de sécurité pour atteindre 100/100 sur MDN Observatory 2026-07-08 00:27:57 +04:00
cedric 99daeb014c feat: styliser le lien vers le fil BOKANTE
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m15s
Déploiement FRONT BETA / deploy (push) Successful in 24s
Déploiement FRONT PROD / deploy (push) Successful in 24s
2026-07-07 16:13:57 +04:00
cedric f5a4014e41 feat: ajouter un sélecteur d'instance Mastodon et expliciter BOKANTE 2026-07-07 16:11:46 +04:00
cedric d66c679fd1 revert: ne plus présélectionner selon la langue du navigateur
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-06 02:34:08 +04:00
cedric cab293d62e Merge pull request 'Sélecteur de langue : regroupement par continent, Afrique en tête' (#6) from feat/africa into master
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / deploy (push) Successful in 22s
Déploiement FRONT PROD / deploy (push) Successful in 19s
Reviewed-on: #6
2026-07-05 22:20:46 +00:00
cedric 5936de59ff fix: mettre en valeur l'option Transcription dans le select
Déploiement FRONT BETA / check (push) Successful in 2m7s
Vérification PR / check (pull_request) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Vérification PR / deploy-beta (pull_request) Successful in 19s
2026-07-06 02:01:44 +04:00
cedric 6804e3626f feat: remplacer les puces par un select groupé par continent
Déploiement FRONT BETA / check (push) Successful in 2m14s
Vérification PR / check (pull_request) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Vérification PR / deploy-beta (pull_request) Successful in 20s
2026-07-06 00:18:34 +04:00
cedric 461b063cff feat: regrouper les langues par continent, Afrique en tête 2026-07-06 00:10:34 +04:00
cedric 6601b00545 fix: ne plus exposer le jeton API au navigateur (recherche d'artiste)
Déploiement FRONT PROD / check (push) Successful in 2m13s
Déploiement FRONT PROD / deploy (push) Successful in 20s
Déploiement FRONT BETA / check (push) Successful in 2m6s
Déploiement FRONT BETA / deploy (push) Successful in 20s
2026-07-05 23:16:19 +04:00
cedric f88b0184ea feat: sélectionner la traduction par défaut selon la langue du navigateur
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 20s
2026-07-05 22:33:09 +04:00
cedric 43964ec91d feat: afficher les langues asiatiques avant les langues européennes 2026-07-05 22:28:57 +04:00
18 changed files with 457 additions and 75 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ API_URL=http://localhost:1337/api
NEXT_PUBLIC_API_URL=$API_URL NEXT_PUBLIC_API_URL=$API_URL
NEXT_PUBLIC_API_URL_ROOT=http://localhost:1337 NEXT_PUBLIC_API_URL_ROOT=http://localhost:1337
SITE_URL=http://localhost:3001 SITE_URL=http://localhost:3001
NEXT_PUBLIC_READ_TOKEN= API_READ_TOKEN=
# IDENTITÉ DU SITE (branding) # IDENTITÉ DU SITE (branding)
NEXT_PUBLIC_SITE_NAME=PAWÒL-NU. Paroles et traductions. NEXT_PUBLIC_SITE_NAME=PAWÒL-NU. Paroles et traductions.
+23
View File
@@ -0,0 +1,23 @@
import {describe, it, expect, vi, afterEach} from 'vitest'
import {jwennToutAwtis} from '../../../../lib/oki-api'
import {GET} from '../route'
vi.mock('../../../../lib/oki-api', () => ({
jwennToutAwtis: vi.fn()
}))
describe('GET /api/awtis', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renvoie les données de jwennToutAwtis en JSON, sans exposer de jeton', async () => {
jwennToutAwtis.mockResolvedValue({data: [{id: 1, alias: 'Foo'}], meta: {}})
const response = await GET()
const body = await response.json()
expect(body).toEqual({data: [{id: 1, alias: 'Foo'}], meta: {}})
expect(JSON.stringify(body)).not.toMatch(/bearer|token/i)
})
})
+6
View File
@@ -0,0 +1,6 @@
import {jwennToutAwtis} from '../../../lib/oki-api'
export async function GET() {
const data = await jwennToutAwtis()
return Response.json(data)
}
@@ -4,6 +4,7 @@ import {GET} from '../route'
function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) { function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) {
return { return {
status, status,
ok: status >= 200 && status < 300,
body, body,
headers: { headers: {
get: key => headers[key.toLowerCase()] ?? null get: key => headers[key.toLowerCase()] ?? null
@@ -52,4 +53,16 @@ describe('GET /api/mizik-stream/[id]', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('audio/mpeg') expect(response.headers.get('content-type')).toBe('audio/mpeg')
}) })
it('renvoie une erreur JSON si le serveur audio répond en erreur', async () => {
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 401, headers: {'content-type': 'application/json'}, body: '{"error": "unauthorized"}'}))
const request = new Request('http://localhost/api/mizik-stream/7')
const response = await GET(request, {params: Promise.resolve({id: '7'})})
expect(response.status).toBe(401)
expect(response.headers.get('content-type')).toContain('application/json')
const json = await response.json()
expect(json.error).toContain('401')
})
}) })
+10 -2
View File
@@ -1,6 +1,8 @@
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com' const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
const MIZIK_API_USER = process.env.MIZIK_API_USER || 'user' // Les variables publiques sont conservées en fallback pendant la migration
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || 'password' // des déploiements qui utiliseraient encore lancien nommage.
const MIZIK_API_USER = process.env.MIZIK_API_USER || process.env.NEXT_PUBLIC_MIZIK_API_USER || 'user'
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || process.env.NEXT_PUBLIC_MIZIK_API_PASSWORD || 'password'
const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'] const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges']
@@ -14,6 +16,12 @@ export async function GET(request, props) {
headers: range ? {range} : {} headers: range ? {range} : {}
}) })
if (!upstreamResponse.ok) {
const message = `Le serveur audio a répondu ${upstreamResponse.status}`
console.error(message, upstreamUrl.replace(/p=[^&]+/, 'p=***'))
return Response.json({error: message}, {status: upstreamResponse.status})
}
const headers = new Headers() const headers = new Headers()
for (const key of FORWARDED_HEADERS) { for (const key of FORWARDED_HEADERS) {
const value = upstreamResponse.headers.get(key) const value = upstreamResponse.headers.get(key)
+4
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation' import {notFound} from 'next/navigation'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
@@ -67,6 +68,8 @@ export default async function AwtisPajSlug(props) {
const params = await props.params const params = await props.params
const {slug} = params const {slug} = params
const anAwtis = await jwennAwtis(slug) const anAwtis = await jwennAwtis(slug)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {photo} = anAwtis const {photo} = anAwtis
const kuvetiFormat = formatKuveti(photo) const kuvetiFormat = formatKuveti(photo)
@@ -91,6 +94,7 @@ export default async function AwtisPajSlug(props) {
<section> <section>
<script <script
type='application/ld+json' type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/> />
</section> </section>
+6 -3
View File
@@ -7,10 +7,10 @@ import {useServerInsertedHTML} from 'next/navigation'
import {CacheProvider as DefaultCacheProvider} from '@emotion/react' import {CacheProvider as DefaultCacheProvider} from '@emotion/react'
export default function NextAppDirEmotionCacheProvider(props) { export default function NextAppDirEmotionCacheProvider(props) {
const {options, CacheProvider = DefaultCacheProvider, children} = props const {options, CacheProvider = DefaultCacheProvider, children, nonce} = props
const [registry] = React.useState(() => { const [registry] = React.useState(() => {
const cache = createCache(options) const cache = createCache({...options, nonce})
cache.compat = true cache.compat = true
const prevInsert = cache.insert const prevInsert = cache.insert
let inserted = [] let inserted = []
@@ -65,12 +65,14 @@ export default function NextAppDirEmotionCacheProvider(props) {
<style <style
key={name} key={name}
dangerouslySetInnerHTML={{__html: style}} dangerouslySetInnerHTML={{__html: style}}
nonce={nonce}
data-emotion={`${registry.cache.key}-global ${name}`} data-emotion={`${registry.cache.key}-global ${name}`}
/> />
))} ))}
{styles !== '' && ( {styles !== '' && (
<style <style
dangerouslySetInnerHTML={{__html: styles}} dangerouslySetInnerHTML={{__html: styles}}
nonce={nonce}
data-emotion={dataEmotionAttribute} data-emotion={dataEmotionAttribute}
/> />
)} )}
@@ -84,5 +86,6 @@ export default function NextAppDirEmotionCacheProvider(props) {
NextAppDirEmotionCacheProvider.propTypes = { NextAppDirEmotionCacheProvider.propTypes = {
options: PropTypes.object.isRequired, options: PropTypes.object.isRequired,
CacheProvider: PropTypes.func, CacheProvider: PropTypes.func,
children: PropTypes.node.isRequired children: PropTypes.node.isRequired,
nonce: PropTypes.string
} }
+8 -3
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import PlausibleProvider from 'next-plausible' import PlausibleProvider from 'next-plausible'
import TopLoader from '../components/top-loader' import TopLoader from '../components/top-loader'
import Navigasyon from '../components/navigasyon' import Navigasyon from '../components/navigasyon'
@@ -61,16 +62,20 @@ const jsonLd = {
} }
export default async function RootLayout({children}) { export default async function RootLayout({children}) {
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const inner = ( const inner = (
<> <>
<TopLoader color='#ffeb3b' /> <TopLoader color='#ffeb3b' nonce={nonce} />
<ThemeRegistry> <ThemeRegistry nonce={nonce}>
<Navigasyon /> <Navigasyon />
{children} {children}
</ThemeRegistry> </ThemeRegistry>
<section> <section>
<script <script
type='application/ld+json' type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/> />
</section> </section>
@@ -81,7 +86,7 @@ export default async function RootLayout({children}) {
<html suppressHydrationWarning lang='fr'> <html suppressHydrationWarning lang='fr'>
<body> <body>
{plausibleUrl {plausibleUrl
? <PlausibleProvider src={plausibleUrl}>{inner}</PlausibleProvider> ? <PlausibleProvider src={plausibleUrl} scriptProps={{nonce}}>{inner}</PlausibleProvider>
: inner} : inner}
</body> </body>
</html> </html>
+4
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation' import {notFound} from 'next/navigation'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
@@ -71,6 +72,8 @@ export default async function AnPawolPaj(props) {
const {slug} = params const {slug} = params
const anTeks = await jwennAnTeks(slug) const anTeks = await jwennAnTeks(slug)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {couverture} = anTeks const {couverture} = anTeks
const teksKuvetiFormat = formatKuveti(couverture) const teksKuvetiFormat = formatKuveti(couverture)
@@ -126,6 +129,7 @@ export default async function AnPawolPaj(props) {
<section> <section>
<script <script
type='application/ld+json' type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/> />
</section> </section>
+5 -4
View File
@@ -64,12 +64,12 @@ const theme = createTheme({
}) })
export default function ThemeRegistry(props) { export default function ThemeRegistry(props) {
const {children} = props const {children, nonce} = props
return ( return (
<> <>
<InitColorSchemeScript attribute='class' /> <InitColorSchemeScript attribute='class' nonce={nonce} />
<NextAppDirEmotionCacheProvider options={{key: 'mui'}}> <NextAppDirEmotionCacheProvider options={{key: 'mui'}} nonce={nonce}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<ChanjeTem /> <ChanjeTem />
@@ -81,5 +81,6 @@ export default function ThemeRegistry(props) {
} }
ThemeRegistry.propTypes = { ThemeRegistry.propTypes = {
children: PropTypes.node.isRequired children: PropTypes.node.isRequired,
nonce: PropTypes.string
} }
+1 -2
View File
@@ -7,7 +7,6 @@ import Autocomplete from '@mui/material/Autocomplete'
import Avatar from '@mui/material/Avatar' import Avatar from '@mui/material/Avatar'
import Container from '@mui/material/Container' import Container from '@mui/material/Container'
import {jwennToutAwtis} from '../../lib/oki-api'
import {formatKuveti} from '../../lib/kuveti' import {formatKuveti} from '../../lib/kuveti'
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337' const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
@@ -27,7 +26,7 @@ export default function ChecheAwtis() {
(async () => { (async () => {
try { try {
const {data} = await jwennToutAwtis() const {data} = await fetch('/api/awtis').then(response => response.json())
const filteredData = data.map(artiste => { const filteredData = data.map(artiste => {
const firstLetter = artiste.alias[0].toUpperCase() const firstLetter = artiste.alias[0].toUpperCase()
@@ -15,19 +15,36 @@ describe('ReponsMastodon', () => {
expect(container).toBeEmptyDOMElement() expect(container).toBeEmptyDOMElement()
}) })
it('affiche un lien direct vers le fil bokante', () => { it('affiche un lien direct vers le fil BOKANTE', () => {
render(<ReponsMastodon bokanteStatusId='112233' />) render(<ReponsMastodon bokanteStatusId='112233' />)
const lien = screen.getByRole('link', {name: 'Mastodon'}) const lien = screen.getByRole('link', {name: 'BOKANTE (Mastodon)'})
expect(lien).toHaveAttribute('href', 'https://bokante.o-k-i.net/@pawol_nu/112233') 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 () => { it('redirige vers BOKANTE par défaut quand on continue', async () => {
window.open = vi.fn() window.open = vi.fn()
const user = userEvent.setup() const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />) render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre via mastodon/i})) await user.click(screen.getByRole('button', {name: /répondre sur 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://bokante.o-k-i.net/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank',
'noopener,noreferrer'
)
})
it('permet de choisir une autre instance dans le select', async () => {
window.open = vi.fn()
const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.click(screen.getByLabelText(/instance mastodon/i))
await user.click(screen.getByRole('option', {name: /autre instance/i}))
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social')
await user.click(screen.getByRole('button', {name: /continuer/i})) await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith( expect(window.open).toHaveBeenCalledWith(
@@ -37,17 +54,19 @@ describe('ReponsMastodon', () => {
) )
}) })
it('accepte aussi juste un nom d\'instance sans @', async () => { it('accepte un identifiant complet pour une autre instance', async () => {
window.open = vi.fn() window.open = vi.fn()
const user = userEvent.setup() const user = userEvent.setup()
render(<ReponsMastodon bokanteStatusId='112233' />) render(<ReponsMastodon bokanteStatusId='112233' />)
await user.click(screen.getByRole('button', {name: /répondre via mastodon/i})) await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social') await user.click(screen.getByLabelText(/instance mastodon/i))
await user.click(screen.getByRole('option', {name: /autre instance/i}))
await user.type(screen.getByLabelText(/@vous@votre-instance/i), '@quelqun@mastodon.social')
await user.click(screen.getByRole('button', {name: /continuer/i})) await user.click(screen.getByRole('button', {name: /continuer/i}))
expect(window.open).toHaveBeenCalledWith( expect(window.open).toHaveBeenCalledWith(
expect.stringContaining('https://mastodon.social/authorize_interaction'), 'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
'_blank', '_blank',
'noopener,noreferrer' 'noopener,noreferrer'
) )
+77 -14
View File
@@ -10,11 +10,24 @@ import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Link from 'next/link' import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import Select from '@mui/material/Select'
import MenuItem from '@mui/material/MenuItem'
import Link from '@mui/material/Link'
import {Mastodon} from '@icons-pack/react-simple-icons'
const BOKANTE_URL = process.env.NEXT_PUBLIC_BOKANTE_URL || 'https://bokante.o-k-i.net' const BOKANTE_URL = process.env.NEXT_PUBLIC_BOKANTE_URL || 'https://bokante.o-k-i.net'
const BOKANTE_ACCOUNT = process.env.NEXT_PUBLIC_BOKANTE_ACCOUNT || 'pawol_nu' const BOKANTE_ACCOUNT = process.env.NEXT_PUBLIC_BOKANTE_ACCOUNT || 'pawol_nu'
const INSTANCES_SUGGEREES = [
{domain: 'bokante.o-k-i.net', label: 'BOKANTE - notre instance'},
{domain: 'mastodon.social', label: 'mastodon.social'},
{domain: 'piaille.fr', label: 'piaille.fr'},
]
const AUTRE_INSTANCE = 'autre'
function extraireInstance(saisie) { function extraireInstance(saisie) {
const valeur = saisie.trim().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/$/, '') const valeur = saisie.trim().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/$/, '')
return valeur.includes('@') ? valeur.split('@').pop() : valeur return valeur.includes('@') ? valeur.split('@').pop() : valeur
@@ -22,7 +35,8 @@ function extraireInstance(saisie) {
export default function ReponsMastodon({bokanteStatusId}) { export default function ReponsMastodon({bokanteStatusId}) {
const [ouvert, setOuvert] = useState(false) const [ouvert, setOuvert] = useState(false)
const [saisie, setSaisie] = useState('') const [instance, setInstance] = useState(INSTANCES_SUGGEREES[0].domain)
const [autreInstance, setAutreInstance] = useState('')
if (!bokanteStatusId) { if (!bokanteStatusId) {
return null return null
@@ -31,43 +45,92 @@ export default function ReponsMastodon({bokanteStatusId}) {
const tootUrl = `${BOKANTE_URL}/@${BOKANTE_ACCOUNT}/${bokanteStatusId}` const tootUrl = `${BOKANTE_URL}/@${BOKANTE_ACCOUNT}/${bokanteStatusId}`
const repondre = () => { const repondre = () => {
const instance = extraireInstance(saisie) const domaine = instance === AUTRE_INSTANCE ? extraireInstance(autreInstance) : instance
if (!instance) { if (!domaine) {
return return
} }
window.open(`https://${instance}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer') window.open(`https://${domaine}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer')
setOuvert(false) setOuvert(false)
setAutreInstance('')
} }
return ( return (
<Box sx={{maxWidth: 700, margin: '1em auto 0', textAlign: 'center'}}> <Box sx={{maxWidth: 700, margin: '1em auto 0', textAlign: 'center'}}>
<Typography variant='body2' sx={{mb: 1}}> <Typography variant='body2' sx={{mb: 1}}>
Rejoignez la discussion sur{' '} Rejoignez la discussion sur le Fediverse via{' '}
<Link href={tootUrl} target='_blank' rel='noopener noreferrer'>Mastodon</Link> <Link
href={tootUrl}
target='_blank'
rel='noopener noreferrer'
underline='hover'
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.25,
borderRadius: 1,
bgcolor: 'action.hover',
color: 'primary.main',
fontWeight: 500,
textDecoration: 'none',
'&:hover': {
bgcolor: 'action.selected'
}
}}
>
BOKANTE (Mastodon)
</Link>
</Typography> </Typography>
<Button variant='outlined' size='small' onClick={() => setOuvert(true)}> <Button
Répondre via Mastodon variant='outlined'
size='small'
startIcon={<Mastodon size={16} color='#6364FF' title='' />}
onClick={() => setOuvert(true)}
>
Répondre sur Mastodon
</Button> </Button>
<Dialog open={ouvert} onClose={() => setOuvert(false)}> <Dialog open={ouvert} onClose={() => setOuvert(false)}>
<DialogTitle>Répondre depuis votre compte Mastodon</DialogTitle> <DialogTitle>Répondre depuis Mastodon</DialogTitle>
<DialogContent> <DialogContent>
<Typography variant='body2' sx={{mb: 2}}> <Typography variant='body2' sx={{mb: 2}}>
Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre Choisissez linstance Mastodon sur laquelle vous avez un compte.
instance, vous serez redirigé·e vers votre compte pour répondre. BOKANTE est linstance de OKI : vous pouvez aussi utiliser
nimporte quelle autre instance du Fediverse.
</Typography> </Typography>
<FormControl fullWidth sx={{mb: 2}}>
<InputLabel id='instance-mastodon-label'>Instance Mastodon</InputLabel>
<Select
labelId='instance-mastodon-label'
id='instance-mastodon'
value={instance}
label='Instance Mastodon'
onChange={event => setInstance(event.target.value)}
>
{INSTANCES_SUGGEREES.map(({domain, label}) => (
<MenuItem key={domain} value={domain}>
{label}
</MenuItem>
))}
<MenuItem value={AUTRE_INSTANCE}>Autre instance</MenuItem>
</Select>
</FormControl>
{instance === AUTRE_INSTANCE && (
<TextField <TextField
autoFocus autoFocus
fullWidth fullWidth
label='@vous@votre-instance' label='@vous@votre-instance'
value={saisie} helperText='Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre instance, vous serez redirigé·e vers votre compte pour répondre.'
onChange={event => setSaisie(event.target.value)} value={autreInstance}
onChange={event => setAutreInstance(event.target.value)}
onKeyDown={event => { onKeyDown={event => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
repondre() repondre()
} }
}} }}
/> />
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setOuvert(false)}>Annuler</Button> <Button onClick={() => setOuvert(false)}>Annuler</Button>
+91 -23
View File
@@ -3,7 +3,11 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip' 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 Typography from '@mui/material/Typography'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
import {useMediaQuery} from '@mui/material' import {useMediaQuery} from '@mui/material'
@@ -12,6 +16,9 @@ import slugify from 'slugify'
import {styled} from '@mui/material/styles' import {styled} from '@mui/material/styles'
import ExplicitIcon from '@mui/icons-material/Explicit' import ExplicitIcon from '@mui/icons-material/Explicit'
import 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 Image from 'next/image'
@@ -88,15 +95,35 @@ const LANG_NAMES = {
pt: 'Português', ja: '日本語', ko: '한국어', pt: 'Português', ja: '日本語', ko: '한국어',
} }
const CONTINENTS = ['Afrique', 'Asie', 'Europe']
const TRAD_FIELDS = [ const TRAD_FIELDS = [
{field: 'anglais', title: 'English'}, // Afrique — langues vedettes en premier
{field: 'francais', title: 'Français'}, {field: 'ayisyen', title: 'Ayisyen 🇭🇹', continent: 'Afrique', vedette: true},
{field: 'espagnol', title: 'Español'}, {field: 'yoruba', title: 'Yorùbá', continent: 'Afrique', vedette: true},
{field: 'allemand', title: 'Deutsch'}, {field: 'lingala', title: 'Lingála', continent: 'Afrique', vedette: true},
{field: 'italien', title: 'Italiano'}, {field: 'wolof', title: 'Wolof', continent: 'Afrique', vedette: true},
{field: 'portugais', title: 'Português'}, {field: 'swahili', title: 'Kiswahili', continent: 'Afrique', vedette: true},
{field: 'japonais', title: '日本語'}, {field: 'hausa', title: 'Hausa', continent: 'Afrique', vedette: true},
{field: 'coreen', title: '한국어'}, {field: 'arabe', title: 'العربية', continent: 'Afrique'},
{field: 'oromo', title: 'Oromoo', continent: 'Afrique'},
{field: 'igbo', title: 'Igbo', continent: 'Afrique'},
{field: 'zoulou', title: 'isiZulu', continent: 'Afrique'},
{field: 'malgache', title: 'Malagasy', continent: 'Afrique'},
{field: 'xhosa', title: 'isiXhosa', continent: 'Afrique'},
{field: 'tswana', title: 'Setswana', continent: 'Afrique'},
{field: 'tsonga', title: 'Xitsonga', continent: 'Afrique'},
{field: 'sesotho', title: 'Sesotho', continent: 'Afrique'},
// Asie
{field: 'japonais', title: '日本語', continent: 'Asie'},
{field: 'coreen', title: '한국어', continent: 'Asie'},
// Europe
{field: 'anglais', title: 'English', continent: 'Europe'},
{field: 'francais', title: 'Français', continent: 'Europe'},
{field: 'espagnol', title: 'Español', continent: 'Europe'},
{field: 'allemand', title: 'Deutsch', continent: 'Europe'},
{field: 'italien', title: 'Italiano', continent: 'Europe'},
{field: 'portugais', title: 'Português', continent: 'Europe'},
] ]
const langToArray = parole => { const langToArray = parole => {
@@ -106,7 +133,7 @@ const langToArray = parole => {
return TRAD_FIELDS return TRAD_FIELDS
.filter(({field}) => parole.traductions[field]) .filter(({field}) => parole.traductions[field])
.map(({field, title}) => ({title, lang: parole.traductions[field]})) .map(({field, title, continent, vedette}) => ({field, title, continent, vedette, lang: parole.traductions[field]}))
} }
const ExplicitTooltip = Tooltip const ExplicitTooltip = Tooltip
@@ -118,7 +145,6 @@ export default function Teks({parole}) {
const coverFmt = formatKuveti(parole.couverture) const coverFmt = formatKuveti(parole.couverture)
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
const [, setCurrentTrack] = useCurrentTrack() const [, setCurrentTrack] = useCurrentTrack()
const options = ['Transcription', ...langArray.map(({title}) => title)]
useEffect(() => { useEffect(() => {
const isBrowser = () => typeof window !== 'undefined' const isBrowser = () => typeof window !== 'undefined'
@@ -228,18 +254,60 @@ export default function Teks({parole}) {
)} )}
</Box> </Box>
<Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}> <Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}>
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1, borderBottom: 1, borderColor: 'divider', mb: 2, pb: 2}}> <Box sx={{display: 'flex', justifyContent: 'center', borderBottom: 1, borderColor: 'divider', mb: 2, pb: 2}}>
{options.map((label, index) => ( <FormControl size='small' sx={{minWidth: 240, width: isMobile ? '100%' : 'auto'}}>
<Chip <Select
key={label} value={tab}
clickable MenuProps={{PaperProps: {sx: {maxHeight: 420}}}}
color='primary' renderValue={value => {
label={label} const label = value === 0 ? 'Transcription' : langArray[value - 1]?.title
sx={{fontWeight: tab === index ? 600 : 400}} const Icon = value === 0 ? ArticleIcon : TranslateIcon
variant={tab === index ? 'filled' : 'outlined'} return (
onClick={() => setTab(index)} <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> </Box>
{tab === 0 && ( {tab === 0 && (
<> <>
+7 -2
View File
@@ -3,8 +3,13 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import NextTopLoader from 'nextjs-toploader' import NextTopLoader from 'nextjs-toploader'
export default function TopLoader({color}) { export default function TopLoader({color, nonce}) {
return <NextTopLoader color={color} /> return <NextTopLoader color={color} nonce={nonce} />
}
TopLoader.propTypes = {
color: PropTypes.string.isRequired,
nonce: PropTypes.string
} }
TopLoader.propTypes = { TopLoader.propTypes = {
+1 -1
View File
@@ -2,7 +2,7 @@ import qs from 'qs'
const OKI_API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337' const OKI_API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
const AWTIS_POU_CHAK_PAJ = process.env.NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ || 6 const AWTIS_POU_CHAK_PAJ = process.env.NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ || 6
const readToken = process.env.NEXT_PUBLIC_READ_TOKEN || 'read-token' const readToken = process.env.API_READ_TOKEN || 'read-token'
const headers = { const headers = {
next: {revalidate: 60}, next: {revalidate: 60},
+41 -1
View File
@@ -28,9 +28,49 @@ function buildRemotePatterns() {
return patterns return patterns
} }
async function headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Cross-Origin-Resource-Policy',
value: 'same-site'
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin'
},
{
key: 'Permissions-Policy',
value: 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
}
]
}
]
}
module.exports = (withPWA({ module.exports = (withPWA({
turbopack: {}, turbopack: {},
poweredByHeader: false,
images: { images: {
remotePatterns: buildRemotePatterns() remotePatterns: buildRemotePatterns()
} },
headers
})) }))
+121
View File
@@ -0,0 +1,121 @@
/* eslint-disable @next/next/no-server-import-in-page */
import {NextResponse} from 'next/server'
const nonceCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function generateNonce() {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
let nonce = ''
for (const byte of array) {
nonce += nonceCharset[byte % 64]
}
return nonce
}
function extractOrigin(url) {
if (!url) {
return ''
}
try {
return new URL(url).origin
} catch {
return ''
}
}
function extractHostSource(raw) {
if (!raw) {
return ''
}
const [hostname, port] = raw.split(':')
if (!hostname) {
return ''
}
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `http://${hostname}${port ? `:${port}` : ''}`
}
return `https://${hostname}${port ? `:${port}` : ''}`
}
function buildCsp(nonce) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_URL_ROOT || ''
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL || ''
const mizikUrl = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || ''
const imageDomainsRaw = process.env.NEXT_PUBLIC_DOMAINS_IMAGE || ''
const apiOrigin = extractOrigin(apiUrl)
const plausibleOrigin = extractOrigin(plausibleUrl)
const mizikOrigin = extractOrigin(mizikUrl)
const imageOrigins = imageDomainsRaw
.split(' ')
.filter(Boolean)
.map(entry => extractHostSource(entry))
.filter(Boolean)
const imageOriginsSet = new Set([apiOrigin, ...imageOrigins])
const imageOriginsUnique = [...imageOriginsSet].filter(Boolean)
const connectSrc = ['\'self\'', apiOrigin, plausibleOrigin, mizikOrigin].filter(Boolean)
const imgSrc = ['\'self\'', 'data:', 'blob:', ...imageOriginsUnique].filter(Boolean)
const mediaSrc = ['\'self\'', apiOrigin, mizikOrigin].filter(Boolean)
// Frame-src is intentionally broad: the application embeds user-supplied
// PeerTube instances in addition to known platforms (Tidal, Deezer, Spotify,
// Soundcloud, Apple Music). Restricting this to a fixed list would break
// user-generated content.
const frameSrc = ['\'self\'', 'https:']
const directives = [
'default-src \'self\'',
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src-elem 'self' 'nonce-${nonce}'`,
'style-src-attr \'unsafe-inline\'',
`img-src ${imgSrc.join(' ')}`,
'font-src \'self\'',
`connect-src ${connectSrc.join(' ')}`,
`frame-src ${frameSrc.join(' ')}`,
`media-src ${mediaSrc.join(' ')}`,
'manifest-src \'self\'',
'object-src \'none\'',
'base-uri \'self\'',
'form-action \'self\'',
'frame-ancestors \'self\'',
'upgrade-insecure-requests'
]
return directives.join('; ')
}
export function proxy(request) {
const nonce = generateNonce()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
const response = NextResponse.next({
request: {
headers: requestHeaders
}
})
response.headers.set('Content-Security-Policy', buildCsp(nonce))
return response
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{type: 'header', key: 'next-router-prefetch'},
{type: 'header', key: 'purpose', value: 'prefetch'}
]
}
]
}