feat: simplifie la vue timeline

This commit is contained in:
2026-01-24 21:34:02 +04:00
parent be45cc1cc0
commit a184665ed1
3 changed files with 169 additions and 342 deletions
+6 -4
View File
@@ -249,8 +249,10 @@ export default function ListVersions({
<Box> <Box>
<Box sx={{ <Box sx={{
display: 'flex', display: 'flex',
flexDirection: {xs: 'column', sm: 'row'},
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: {xs: 'stretch', sm: 'center'},
gap: 1,
mb: 2 mb: 2
}} }}
> >
@@ -271,13 +273,14 @@ export default function ListVersions({
size='small' size='small'
value={viewMode} value={viewMode}
onChange={handleViewModeChange} onChange={handleViewModeChange}
sx={{alignSelf: {xs: 'center', sm: 'auto'}}}
> >
<ToggleButton value='table' aria-label='vue tableau'> <ToggleButton value='table' aria-label='vue tableau'>
<ViewListIcon sx={{mr: 1}} /> <ViewListIcon fontSize='small' sx={{mr: 0.5}} />
Table Table
</ToggleButton> </ToggleButton>
<ToggleButton value='timeline' aria-label='vue chronologique'> <ToggleButton value='timeline' aria-label='vue chronologique'>
<TimelineIcon sx={{mr: 1}} /> <TimelineIcon fontSize='small' sx={{mr: 0.5}} />
Timeline Timeline
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
@@ -308,7 +311,6 @@ export default function ListVersions({
</Paper> </Paper>
) : ( ) : (
<VersionTimeline <VersionTimeline
collection={collection}
data={filteredData} data={filteredData}
accessToken={accessToken} accessToken={accessToken}
userId={userId} userId={userId}
+2 -3
View File
@@ -35,7 +35,8 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
const createdAt = new Date(versionData.date_created) const createdAt = new Date(versionData.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isVoteDisabled = createdAt < threeDaysAgo const isExpired = createdAt < threeDaysAgo
const isVoteDisabled = isExpired || outdated
return ( return (
<Box sx={{padding: 3}}> <Box sx={{padding: 3}}>
@@ -106,7 +107,6 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
@{versionData.user_created?.split('-')[0] || 'Système'} @{versionData.user_created?.split('-')[0] || 'Système'}
</Typography> </Typography>
</Box> </Box>
{!outdated && (
<Box sx={{ <Box sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1
}} }}
@@ -130,7 +130,6 @@ export default function VersionComparison({versionData, versionCompare, voteRefr
onVoteResult={handleVoteResult} onVoteResult={handleVoteResult}
/> />
</Box> </Box>
)}
</Paper> </Paper>
</Grid> </Grid>
</Grid> </Grid>
+122 -296
View File
@@ -1,118 +1,36 @@
import {useRef, useState, useEffect} from 'react' import {useRef, useState, useEffect} from 'react'
import {useTheme} from '@mui/material/styles'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import Card from '@mui/material/Card' import IconButton from '@mui/material/IconButton'
import CardContent from '@mui/material/CardContent' import Collapse from '@mui/material/Collapse'
import CardActions from '@mui/material/CardActions'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Avatar from '@mui/material/Avatar'
import Divider from '@mui/material/Divider'
import Timeline from '@mui/lab/Timeline'
import TimelineItem from '@mui/lab/TimelineItem'
import TimelineSeparator from '@mui/lab/TimelineSeparator'
import TimelineConnector from '@mui/lab/TimelineConnector'
import TimelineContent from '@mui/lab/TimelineContent'
import TimelineDot from '@mui/lab/TimelineDot'
import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent'
import AccessTimeIcon from '@mui/icons-material/AccessTime'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import ErrorIcon from '@mui/icons-material/Error'
import EditIcon from '@mui/icons-material/Edit'
import Snackbar from '@mui/material/Snackbar' import Snackbar from '@mui/material/Snackbar'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import CompareArrowsIcon from '@mui/icons-material/CompareArrows'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import SessionExpired from '../session/session-expired.js' import SessionExpired from '../session/session-expired.js'
import MarkdownRenderer from '../markdown-renderer/index.js'
import VersionDialog from './version-dialog.js' import VersionDialog from './version-dialog.js'
import VoteButtons from './vote-buttons.js' import VoteButtons from './vote-buttons.js'
import CopyButton from './copy-button.js' import CopyButton from './copy-button.js'
import ShareButton from './share-button.js'
import ExportPdfButton from './export-pdf-button.js'
import PrintButton from './print-button.js'
import {formatDate} from '@/lib/format.js' import {formatDate} from '@/lib/format.js'
import {compareVersion} from '@/lib/directus.js' import {compareVersion} from '@/lib/directus.js'
function getVersionStatus(version, index, totalVersions, data) { function getStatusColor(isOutdated, index) {
// Logic to determine version status based on position and data if (isOutdated) {
// Find which version is the "main" (published) by checking if it matches current content return '#D32F2F'
// This would require the current item content to be passed
// For now, we assume the most recent is current unless it's been promoted
// Check if this is the initial version
if (index === totalVersions - 1) {
return 'initial' // First version
} }
// If there's a more recent version after this one, this is outdated if (index === 0) {
// unless this IS the main version (would need item content to determine) return '#1976D2'
if (index > 0) {
return 'outdated' // Older versions are outdated
} }
// Most recent version is current (being edited/proposed) return '#9E9E9E'
return 'current'
} }
function getStatusConfig(status) { function VersionItem({
switch (status) {
case 'current': {
return {
color: '#1976D2',
bgColor: '#E3F2FD',
icon: <EditIcon />,
label: 'En cours',
chipColor: 'primary'
}
}
case 'published': {
return {
color: '#2E7D32',
bgColor: '#E8F5E9',
icon: <CheckCircleIcon />,
label: 'Publié',
chipColor: 'success'
}
}
case 'archived': {
return {
color: '#757575',
bgColor: '#F5F5F5',
icon: <AccessTimeIcon />,
label: 'Archivé',
chipColor: 'default'
}
}
case 'outdated': {
return {
color: '#D32F2F',
bgColor: '#F9E8E8',
icon: <ErrorIcon />,
label: 'Obsolète',
chipColor: 'error'
}
}
default: {
return {
color: '#757575',
bgColor: '#F5F5F5',
icon: <AccessTimeIcon />,
label: 'Archivé',
chipColor: 'default'
}
}
}
}
function VersionCard({
version, version,
index, index,
totalVersions,
accessToken, accessToken,
userId, userId,
countdownRef, countdownRef,
@@ -122,13 +40,11 @@ function VersionCard({
setVersionCompare, setVersionCompare,
onVoteResult onVoteResult
}) { }) {
const theme = useTheme()
const [versionStatus, setVersionStatus] = useState(null)
const [isOutdated, setIsOutdated] = useState(false) const [isOutdated, setIsOutdated] = useState(false)
const [expanded, setExpanded] = useState(false)
// Fetch real status from API
useEffect(() => { useEffect(() => {
async function fetchVersionStatus() { async function fetchStatus() {
try { try {
const comparisonData = await compareVersion({ const comparisonData = await compareVersion({
accessToken, accessToken,
@@ -140,41 +56,23 @@ function VersionCard({
}) })
if (comparisonData) { if (comparisonData) {
// Store outdated flag for vote disabling
setIsOutdated(comparisonData.outdated) setIsOutdated(comparisonData.outdated)
// Determine status based on API response
let status
if (comparisonData.outdated) {
status = 'outdated'
} else if (index === totalVersions - 1) {
status = 'initial'
} else {
status = 'current'
} }
setVersionStatus(status) } catch {
} setIsOutdated(false)
} catch (error) {
// Fallback to position-based status on error
setVersionStatus(getVersionStatus(version, index, totalVersions, null))
} }
} }
fetchVersionStatus() fetchStatus()
}, [version.id, index, totalVersions, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen]) }, [version.id, accessToken, userId, countdownRef, setError, setIsErrorAlertOpen])
const status = versionStatus || getVersionStatus(version, index, totalVersions, null) const statusColor = getStatusColor(isOutdated, index)
const statusConfig = getStatusConfig(status)
const userDisplayName = version.user_created?.split('-')[0] || 'Système'
// Check if voting is disabled (after 3 days OR if outdated)
const createdAt = new Date(version.date_created) const createdAt = new Date(version.date_created)
const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000)) const threeDaysAgo = new Date(Date.now() - (3 * 24 * 60 * 60 * 1000))
const isExpired = createdAt < threeDaysAgo const isVoteDisabled = createdAt < threeDaysAgo || isOutdated
const isVoteDisabled = isExpired || isOutdated
const handleCompareClick = async () => { const handleCompare = async () => {
const comparisonData = await compareVersion({ const comparisonData = await compareVersion({
accessToken, accessToken,
userId, userId,
@@ -190,162 +88,121 @@ function VersionCard({
} }
} }
// Create content preview preserving markdown structure
const createContentPreview = content => {
if (!content) {
return 'Contenu non disponible'
}
// If content is short enough, return as is
if (content.length <= 150) {
return content
}
// Find a good breaking point (end of sentence, paragraph, or word)
const preview = content.slice(0, 150)
const lastSentence = Math.max(preview.lastIndexOf('.'), preview.lastIndexOf('!'), preview.lastIndexOf('?'))
const lastParagraph = preview.lastIndexOf('\n\n')
const lastWord = preview.lastIndexOf(' ')
// Choose the best breaking point
let breakPoint = 150
if (lastSentence > 100) {
breakPoint = lastSentence + 1
} else if (lastParagraph > 80) {
breakPoint = lastParagraph
} else if (lastWord > 100) {
breakPoint = lastWord
}
return content.slice(0, breakPoint) + (content.length > breakPoint ? '...' : '')
}
const contentPreview = createContentPreview(version?.delta?.contenu)
return ( return (
<Card <Box
sx={{ sx={{
borderLeft: `4px solid ${statusConfig.color}`,
backgroundColor: statusConfig.bgColor,
mb: 2,
transition: 'all 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: 3
}
}}
>
<CardContent>
<Box sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', gap: 1.5,
alignItems: 'flex-start', py: 1.5,
mb: 2 borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {borderBottom: 'none'}
}}
>
{/* Status indicator */}
<Box sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 0.5}}>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: statusColor,
flexShrink: 0
}}
/>
<Box
sx={{
width: 2,
flex: 1,
bgcolor: 'divider',
mt: 0.5,
display: index === 0 ? 'none' : 'block'
}}
/>
</Box>
{/* Content */}
<Box sx={{flex: 1, minWidth: 0}}>
{/* Header row */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1
}}
>
<Box sx={{minWidth: 0, flex: 1}}>
<Typography
variant='body2'
sx={{
fontWeight: 600,
color: statusColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}} }}
> >
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<Avatar sx={{bgcolor: statusConfig.color, width: 32, height: 32}}>
{statusConfig.icon}
</Avatar>
<Box>
<Typography variant='h6' sx={{fontWeight: 'bold', color: statusConfig.color}}>
{version.name} {version.name}
</Typography> </Typography>
<Typography variant='caption' color='text.secondary'> <Typography variant='caption' color='text.secondary'>
par @{userDisplayName} {formatDate(version.date_created, 'dd/MM/yy HH:mm')}
</Typography> </Typography>
</Box> </Box>
</Box>
<Box sx={{display: 'flex', gap: 1}}>
<Chip
label={statusConfig.label}
color={statusConfig.chipColor}
size='small'
variant='outlined'
/>
{isExpired && !isOutdated && (
<Chip
label='Vote fermé'
color='error'
size='small'
variant='outlined'
/>
)}
</Box>
</Box>
<Box sx={{mb: 2, '& > div': {fontSize: '0.875rem', fontStyle: 'italic'}}}> {/* Actions */}
<MarkdownRenderer <Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0}}>
content={contentPreview}
color={theme.palette.text.secondary}
fallbackComponent={({children, ...props}) => (
<Typography
variant='body2'
color='text.secondary'
sx={{fontStyle: 'italic'}}
{...props}
>
{children}
</Typography>
)}
/>
</Box>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'PPpp')}
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<CopyButton
content={version.delta?.contenu || version.name || ''}
label='Copier le contenu de cette version'
hasSnackbarVisible={false}
/>
<ShareButton
versionId={version.id}
versionName={version.name}
hasSnackbarVisible={false}
/>
<ExportPdfButton
versionData={version}
isOutdated={isOutdated}
size='small'
variant='text'
/>
<PrintButton
versionData={version}
isOutdated={isOutdated}
size='small'
variant='text'
/>
<VoteButtons <VoteButtons
hasCountsVisible hasCountsVisible
versionId={version.id} versionId={version.id}
isDisabled={isVoteDisabled} isDisabled={isVoteDisabled}
onVoteResult={onVoteResult} onVoteResult={onVoteResult}
/> />
<IconButton size='small' onClick={handleCompare} title='Comparer'>
<CompareArrowsIcon fontSize='small' />
</IconButton>
<IconButton size='small' onClick={() => setExpanded(!expanded)}>
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
</IconButton>
</Box> </Box>
</Box> </Box>
</CardContent>
<Divider /> {/* Expanded content */}
<CardActions sx={{justifyContent: 'flex-end'}}> <Collapse in={expanded}>
<Button <Box sx={{mt: 1.5, display: 'flex', flexDirection: 'column', gap: 1}}>
size='small' {/* Preview */}
variant='outlined' {version.delta?.contenu && (
color='primary' <Typography
onClick={handleCompareClick} variant='caption'
color='text.secondary'
sx={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
fontStyle: 'italic'
}}
> >
Comparer {version.delta.contenu}
</Button> </Typography>
</CardActions> )}
</Card>
{/* Actions row */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap'}}>
<CopyButton
content={version.delta?.contenu || version.name || ''}
label='Copier'
hasSnackbarVisible={false}
/>
</Box>
</Box>
</Collapse>
</Box>
</Box>
) )
} }
export default function VersionTimeline({ export default function VersionTimeline({
collection,
data, data,
accessToken, accessToken,
userId, userId,
@@ -367,45 +224,14 @@ export default function VersionTimeline({
}) })
} }
const handleCloseSnackbar = () => {
setSnackbar(prev => ({...prev, open: false}))
}
return ( return (
<> <>
<Box> <Box sx={{maxWidth: 500, mx: 'auto'}}>
<Typography variant='h5' textAlign='center' sx={{mb: 3, fontWeight: 'bold'}}>
Historique des versions - {collection}
</Typography>
<Timeline position='right'>
{data.map((version, index) => ( {data.map((version, index) => (
<TimelineItem key={version.id}> <VersionItem
<TimelineOppositeContent sx={{flex: 0.3, pr: 2}}> key={version.id}
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'dd/MM/yyyy')}
</Typography>
<br />
<Typography variant='caption' color='text.secondary'>
{formatDate(version.date_created, 'HH:mm')}
</Typography>
</TimelineOppositeContent>
<TimelineSeparator>
<TimelineDot
color={index === 0 ? 'primary' : 'grey'}
variant={index === 0 ? 'filled' : 'outlined'}
>
{index === 0 ? <EditIcon /> : <AccessTimeIcon />}
</TimelineDot>
{index < data.length - 1 && <TimelineConnector />}
</TimelineSeparator>
<TimelineContent sx={{flex: 1}}>
<VersionCard
version={version} version={version}
index={index} index={index}
totalVersions={data.length}
accessToken={accessToken} accessToken={accessToken}
userId={userId} userId={userId}
countdownRef={countdownRef} countdownRef={countdownRef}
@@ -415,10 +241,7 @@ export default function VersionTimeline({
setVersionCompare={setVersionCompare} setVersionCompare={setVersionCompare}
onVoteResult={handleVoteResult} onVoteResult={handleVoteResult}
/> />
</TimelineContent>
</TimelineItem>
))} ))}
</Timeline>
</Box> </Box>
{isOpenComparison && ( {isOpenComparison && (
@@ -440,9 +263,14 @@ export default function VersionTimeline({
open={snackbar.open} open={snackbar.open}
autoHideDuration={6000} autoHideDuration={6000}
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}} anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
onClose={handleCloseSnackbar} onClose={() => setSnackbar(prev => ({...prev, open: false}))}
>
<Alert
variant='filled'
severity={snackbar.severity}
sx={{width: '100%'}}
onClose={() => setSnackbar(prev => ({...prev, open: false}))}
> >
<Alert variant='filled' severity={snackbar.severity} sx={{width: '100%'}} onClose={handleCloseSnackbar}>
{snackbar.message} {snackbar.message}
</Alert> </Alert>
</Snackbar> </Snackbar>
@@ -451,7 +279,6 @@ export default function VersionTimeline({
} }
VersionTimeline.propTypes = { VersionTimeline.propTypes = {
collection: PropTypes.oneOf(['titres', 'articles']).isRequired,
data: PropTypes.array.isRequired, data: PropTypes.array.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
@@ -459,10 +286,9 @@ VersionTimeline.propTypes = {
setIsErrorAlertOpen: PropTypes.func.isRequired setIsErrorAlertOpen: PropTypes.func.isRequired
} }
VersionCard.propTypes = { VersionItem.propTypes = {
version: PropTypes.object.isRequired, version: PropTypes.object.isRequired,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
totalVersions: PropTypes.number.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
countdownRef: PropTypes.object.isRequired, countdownRef: PropTypes.object.isRequired,