Author SHA1 Message Date
cedric ce19fa3b5d fix: remplacer le libellé de date par une icône universelle
Déploiement FRONT BETA / check (push) Successful in 2m10s
Déploiement FRONT PROD / check (push) Successful in 2m5s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 19s
2026-07-05 20:54:14 +04:00
cedric 33514bf633 feat: généraliser le formatage de date adaptatif à la locale 2026-07-05 20:47:52 +04:00
cedric 35c39a97f3 feat: adapter la date des commentaires à la locale du navigateur
Déploiement FRONT PROD / deploy (push) Successful in 20s
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
2026-07-05 20:39:50 +04:00
cedric 72b86fdc5c feat: mettre en valeur les mentions et afficher l'heure des commentaires
Déploiement FRONT PROD / deploy (push) Successful in 19s
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m9s
Déploiement FRONT BETA / deploy (push) Successful in 21s
2026-07-05 20:17:35 +04:00
cedric 53287f9c80 feat: remplacer les onglets de langue par des puces adaptatives
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-05 20:09:29 +04:00
cedric 8979970a90 feat: refonte visuelle des commentaires (cartes, compteur, date)
Déploiement FRONT BETA / check (push) Successful in 2m18s
Déploiement FRONT PROD / check (push) Successful in 2m7s
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 19s
2026-07-05 19:50:54 +04:00
cedric 75c5979478 feat: ajouter le logo Mastodon au badge des commentaires fédérés
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m16s
Déploiement FRONT BETA / deploy (push) Successful in 23s
Déploiement FRONT PROD / deploy (push) Successful in 22s
2026-07-05 19:35:40 +04:00
cedric 5778d865e2 docs: documenter les commentaires fédérés (bokante)
Déploiement FRONT BETA / check (push) Successful in 2m7s
Déploiement FRONT PROD / check (push) Successful in 2m11s
Déploiement FRONT BETA / deploy (push) Successful in 20s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-05 02:07:03 +04:00
cedric 0ac18faf5c Merge pull request 'Commentaires fédérés via Mastodon (bokante.o-k-i.net) — frontend' (#5) from feat/comment-fedi into master
Déploiement FRONT BETA / deploy (push) Successful in 21s
Déploiement FRONT PROD / deploy (push) Successful in 29s
Déploiement FRONT BETA / check (push) Successful in 2m5s
Déploiement FRONT PROD / check (push) Successful in 2m12s
Reviewed-on: #5
2026-07-04 21:56:48 +00:00
6 changed files with 133 additions and 70 deletions
+18
View File
@@ -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)
@@ -34,4 +34,18 @@ describe('Komante', () => {
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()
})
})
+1 -4
View File
@@ -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}>
+57 -7
View File
@@ -2,10 +2,12 @@
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') {
@@ -15,6 +17,29 @@ function formatAuteur(commentaire) {
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
@@ -23,27 +48,51 @@ export default function Komante({commentaires}) {
return (
<Box sx={{maxWidth: 700, margin: '2em auto 0'}}>
<Typography gutterBottom variant='h6'>
Komantè
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, mb: 2}}>
<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}}>
<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}
{estFedere && (
<Chip label='Mastodon' size='small' sx={{ml: 1}} />
)}
</Typography>
<Typography variant='body2'>{commentaire.contenu}</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>
@@ -53,6 +102,7 @@ export default function Komante({commentaires}) {
</Box>
)
})}
</Stack>
</Box>
)
}
+11 -5
View File
@@ -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,6 +11,7 @@ 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/get-alias'
@@ -37,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 => {
@@ -103,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>
+14 -36
View File
@@ -3,8 +3,7 @@
import {useEffect, useState} from 'react'
import PropTypes from 'prop-types'
import Box from '@mui/material/Box'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Chip from '@mui/material/Chip'
import Typography from '@mui/material/Typography'
import Tooltip from '@mui/material/Tooltip'
import {useMediaQuery} from '@mui/material'
@@ -13,8 +12,6 @@ import slugify from 'slugify'
import {styled} from '@mui/material/styles'
import ExplicitIcon from '@mui/icons-material/Explicit'
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
import Image from 'next/image'
@@ -121,6 +118,7 @@ export default function Teks({parole}) {
const coverFmt = formatKuveti(parole.couverture)
const [tab, setTab] = useState(0)
const [, setCurrentTrack] = useCurrentTrack()
const options = ['Transcription', ...langArray.map(({title}) => title)]
useEffect(() => {
const isBrowser = () => typeof window !== 'undefined'
@@ -230,39 +228,19 @@ export default function Teks({parole}) {
)}
</Box>
<Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}>
<Tabs
scrollButtons
allowScrollButtonsMobile
value={tab}
variant='scrollable'
centered={langArray.length === 0}
slots={{
startScrollButtonIcon: ArrowBackIosNewIcon,
endScrollButtonIcon: ArrowForwardIosIcon
}}
sx={{
borderBottom: 1,
borderColor: 'divider',
mb: 2,
'& .MuiTabs-scrollButtons': {
width: 36,
height: 36,
borderRadius: '50%',
color: 'primary.main',
backgroundColor: 'action.hover',
mx: 0.5,
'&.Mui-disabled': {
opacity: 0
}
}
}}
onChange={(event, value) => setTab(value)}
>
<Tab label='Transcription' />
{langArray.map(({title}) => (
<Tab key={title} label={title} />
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1, borderBottom: 1, borderColor: 'divider', mb: 2, pb: 2}}>
{options.map((label, index) => (
<Chip
key={label}
clickable
color='primary'
label={label}
sx={{fontWeight: tab === index ? 600 : 400}}
variant={tab === index ? 'filled' : 'outlined'}
onClick={() => setTab(index)}
/>
))}
</Tabs>
</Box>
{tab === 0 && (
<>
{parole.langueSource && parole.langueSource !== 'ka' && (