|
| 1 | +import React from 'react'; |
| 2 | +import { |
| 3 | + Alert, |
| 4 | + Box, |
| 5 | + Button, |
| 6 | + Card, |
| 7 | + CardContent, |
| 8 | + Chip, |
| 9 | + CircularProgress, |
| 10 | + Divider, |
| 11 | + IconButton, |
| 12 | + LinearProgress, |
| 13 | + Link, |
| 14 | + Skeleton, |
| 15 | + Stack, |
| 16 | + Tooltip, |
| 17 | + Typography, |
| 18 | +} from '@mui/material'; |
| 19 | +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; |
| 20 | +import RefreshIcon from '@mui/icons-material/Refresh'; |
| 21 | +import ScheduleIcon from '@mui/icons-material/Schedule'; |
| 22 | +import CloseIcon from '@mui/icons-material/Close'; |
| 23 | +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; |
| 24 | +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; |
| 25 | +import { |
| 26 | + PluginUpdateBatchProgress, |
| 27 | + PluginUpdateFeedItem, |
| 28 | + PluginUpdateFeedStatus, |
| 29 | +} from '../hooks/usePluginUpdateFeed'; |
| 30 | + |
| 31 | +interface PluginUpdatesPanelProps { |
| 32 | + updates: PluginUpdateFeedItem[]; |
| 33 | + status: PluginUpdateFeedStatus; |
| 34 | + error: string | null; |
| 35 | + lastChecked: string | null; |
| 36 | + isUpdatingAll: boolean; |
| 37 | + batchProgress: PluginUpdateBatchProgress; |
| 38 | + onUpdate: (pluginId: string) => void | Promise<void>; |
| 39 | + onUpdateAll: () => void | Promise<void>; |
| 40 | + onRefresh: () => void | Promise<void>; |
| 41 | + onDismiss: (pluginId: string) => void; |
| 42 | + onRetry: () => void | Promise<void>; |
| 43 | +} |
| 44 | + |
| 45 | +const formatTimestamp = (timestamp: string | null): string => { |
| 46 | + if (!timestamp) { |
| 47 | + return 'Never checked'; |
| 48 | + } |
| 49 | + |
| 50 | + const date = new Date(timestamp); |
| 51 | + if (Number.isNaN(date.getTime())) { |
| 52 | + return 'Last check unknown'; |
| 53 | + } |
| 54 | + |
| 55 | + return `Last checked ${date.toLocaleString()}`; |
| 56 | +}; |
| 57 | + |
| 58 | +const formatPluginLabel = (plugin: PluginUpdateFeedItem): string => { |
| 59 | + if (plugin.pluginName && plugin.pluginName.trim()) { |
| 60 | + return plugin.pluginName; |
| 61 | + } |
| 62 | + |
| 63 | + const slug = plugin.pluginId.split('_').slice(1).join('_') || plugin.pluginId; |
| 64 | + const spaced = slug |
| 65 | + .replace(/[_-]+/g, ' ') |
| 66 | + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') |
| 67 | + .replace(/\s+/g, ' ') |
| 68 | + .trim(); |
| 69 | + |
| 70 | + if (!spaced) { |
| 71 | + return plugin.pluginId; |
| 72 | + } |
| 73 | + |
| 74 | + return spaced |
| 75 | + .split(' ') |
| 76 | + .map(part => (part ? part[0].toUpperCase() + part.slice(1) : part)) |
| 77 | + .join(' '); |
| 78 | +}; |
| 79 | + |
| 80 | +const PluginUpdatesPanel: React.FC<PluginUpdatesPanelProps> = ({ |
| 81 | + updates, |
| 82 | + status, |
| 83 | + error, |
| 84 | + lastChecked, |
| 85 | + isUpdatingAll, |
| 86 | + batchProgress, |
| 87 | + onUpdate, |
| 88 | + onUpdateAll, |
| 89 | + onRefresh, |
| 90 | + onDismiss, |
| 91 | + onRetry, |
| 92 | +}) => { |
| 93 | + const showSkeleton = status === 'loading' && updates.length === 0 && !lastChecked; |
| 94 | + const showEmpty = status === 'empty'; |
| 95 | + const showError = status === 'error'; |
| 96 | + |
| 97 | + const lastCheckedLabel = formatTimestamp(lastChecked); |
| 98 | + const progressValue = batchProgress.total > 0 && batchProgress.processed <= batchProgress.total |
| 99 | + ? Math.round((batchProgress.processed / batchProgress.total) * 100) |
| 100 | + : 0; |
| 101 | + |
| 102 | + const renderHeaderActions = () => ( |
| 103 | + <Stack direction="row" spacing={1} alignItems="center"> |
| 104 | + <Tooltip title="Refresh now"> |
| 105 | + <span> |
| 106 | + <IconButton |
| 107 | + color="primary" |
| 108 | + onClick={() => onRefresh()} |
| 109 | + disabled={status === 'loading'} |
| 110 | + size="small" |
| 111 | + > |
| 112 | + <RefreshIcon fontSize="small" /> |
| 113 | + </IconButton> |
| 114 | + </span> |
| 115 | + </Tooltip> |
| 116 | + <Button |
| 117 | + variant="contained" |
| 118 | + onClick={() => onUpdateAll()} |
| 119 | + disabled={updates.length === 0 || isUpdatingAll} |
| 120 | + startIcon={ |
| 121 | + isUpdatingAll ? <CircularProgress size={16} color="inherit" /> : <SystemUpdateAltIcon fontSize="small" /> |
| 122 | + } |
| 123 | + > |
| 124 | + {isUpdatingAll ? 'Updating...' : 'Update All'} |
| 125 | + </Button> |
| 126 | + </Stack> |
| 127 | + ); |
| 128 | + |
| 129 | + const renderProgress = () => { |
| 130 | + if (!isUpdatingAll && batchProgress.processed === 0) { |
| 131 | + return null; |
| 132 | + } |
| 133 | + |
| 134 | + return ( |
| 135 | + <Box mt={2}> |
| 136 | + <LinearProgress |
| 137 | + variant={batchProgress.total > 0 ? 'determinate' : 'indeterminate'} |
| 138 | + value={batchProgress.total > 0 ? progressValue : undefined} |
| 139 | + /> |
| 140 | + {batchProgress.total > 0 && ( |
| 141 | + <Typography variant="caption" color="text.secondary" display="block" mt={1}> |
| 142 | + {`Processed ${batchProgress.processed} of ${batchProgress.total} (Success: ${batchProgress.succeeded}, Failed: ${batchProgress.failed})`} |
| 143 | + </Typography> |
| 144 | + )} |
| 145 | + </Box> |
| 146 | + ); |
| 147 | + }; |
| 148 | + |
| 149 | + const renderError = () => { |
| 150 | + if (!showError) { |
| 151 | + return null; |
| 152 | + } |
| 153 | + |
| 154 | + return ( |
| 155 | + <Box mt={2}> |
| 156 | + <Alert |
| 157 | + severity="error" |
| 158 | + icon={<ErrorOutlineIcon fontSize="small" />} |
| 159 | + action={ |
| 160 | + <Button color="inherit" size="small" onClick={() => onRetry()}> |
| 161 | + Retry |
| 162 | + </Button> |
| 163 | + } |
| 164 | + > |
| 165 | + {error || 'We hit a snag while checking for plugin updates.'} |
| 166 | + </Alert> |
| 167 | + </Box> |
| 168 | + ); |
| 169 | + }; |
| 170 | + |
| 171 | + const renderEmpty = () => { |
| 172 | + if (!showEmpty) { |
| 173 | + return null; |
| 174 | + } |
| 175 | + |
| 176 | + return ( |
| 177 | + <Box mt={2}> |
| 178 | + <Alert severity="info" icon={<InfoOutlinedIcon fontSize="small" />}>No plugins need updates right now.</Alert> |
| 179 | + </Box> |
| 180 | + ); |
| 181 | + }; |
| 182 | + |
| 183 | + const renderSkeleton = () => { |
| 184 | + if (!showSkeleton) { |
| 185 | + return null; |
| 186 | + } |
| 187 | + |
| 188 | + return ( |
| 189 | + <Stack spacing={2} mt={2}> |
| 190 | + {Array.from({ length: 3 }).map((_, index) => ( |
| 191 | + <Box key={`plugin-update-skeleton-${index}`}> |
| 192 | + <Skeleton variant="text" width="30%" height={28} /> |
| 193 | + <Skeleton variant="text" width="45%" height={24} /> |
| 194 | + <Skeleton variant="rectangular" width="100%" height={40} sx={{ mt: 1 }} /> |
| 195 | + </Box> |
| 196 | + ))} |
| 197 | + </Stack> |
| 198 | + ); |
| 199 | + }; |
| 200 | + |
| 201 | + const renderUpdates = () => { |
| 202 | + if (showSkeleton || showEmpty) { |
| 203 | + return null; |
| 204 | + } |
| 205 | + |
| 206 | + return ( |
| 207 | + <Stack |
| 208 | + mt={2} |
| 209 | + spacing={2} |
| 210 | + divider={<Divider flexItem sx={{ borderColor: 'divider', opacity: 0.5 }} />} |
| 211 | + > |
| 212 | + {updates.map(update => { |
| 213 | + const isProcessing = update.status === 'updating'; |
| 214 | + |
| 215 | + return ( |
| 216 | + <Box |
| 217 | + key={update.pluginId} |
| 218 | + sx={{ |
| 219 | + display: 'flex', |
| 220 | + flexDirection: { xs: 'column', md: 'row' }, |
| 221 | + alignItems: { xs: 'flex-start', md: 'center' }, |
| 222 | + justifyContent: 'space-between', |
| 223 | + gap: 2, |
| 224 | + }} |
| 225 | + > |
| 226 | + <Box sx={{ flexGrow: 1, minWidth: 0 }}> |
| 227 | + <Typography variant="subtitle1" sx={{ wordBreak: 'break-word' }}> |
| 228 | + {formatPluginLabel(update)} |
| 229 | + </Typography> |
| 230 | + <Stack direction="row" spacing={1} alignItems="center" mt={0.5} flexWrap="wrap"> |
| 231 | + <Chip size="small" label={`Current ${update.currentVersion || 'n/a'}`} variant="outlined" /> |
| 232 | + <Chip size="small" label={`Latest ${update.latestVersion || 'n/a'}`} color="primary" /> |
| 233 | + <Stack direction="row" spacing={0.5} alignItems="center"> |
| 234 | + <ScheduleIcon sx={{ fontSize: 16, color: 'text.secondary' }} /> |
| 235 | + <Typography variant="caption" color="text.secondary"> |
| 236 | + {update.lastChecked ? new Date(update.lastChecked).toLocaleString() : 'Not yet checked'} |
| 237 | + </Typography> |
| 238 | + </Stack> |
| 239 | + </Stack> |
| 240 | + {update.repoUrl && ( |
| 241 | + <Typography variant="caption" color="text.secondary" display="block" mt={0.5}> |
| 242 | + <Link href={update.repoUrl} target="_blank" rel="noopener"> |
| 243 | + {update.repoUrl} |
| 244 | + </Link> |
| 245 | + </Typography> |
| 246 | + )} |
| 247 | + {update.status === 'error' && update.error && ( |
| 248 | + <Alert severity="warning" sx={{ mt: 1 }}> |
| 249 | + {update.error} |
| 250 | + </Alert> |
| 251 | + )} |
| 252 | + </Box> |
| 253 | + <Stack direction="row" spacing={1} alignItems="center"> |
| 254 | + <Button |
| 255 | + variant="contained" |
| 256 | + color="primary" |
| 257 | + onClick={() => onUpdate(update.pluginId)} |
| 258 | + disabled={isProcessing || isUpdatingAll} |
| 259 | + startIcon={ |
| 260 | + isProcessing ? <CircularProgress size={16} color="inherit" /> : <SystemUpdateAltIcon fontSize="small" /> |
| 261 | + } |
| 262 | + > |
| 263 | + {isProcessing ? 'Updating...' : 'Update'} |
| 264 | + </Button> |
| 265 | + <Button |
| 266 | + variant="text" |
| 267 | + color="inherit" |
| 268 | + onClick={() => onDismiss(update.pluginId)} |
| 269 | + startIcon={<CloseIcon fontSize="small" />} |
| 270 | + > |
| 271 | + Later |
| 272 | + </Button> |
| 273 | + </Stack> |
| 274 | + </Box> |
| 275 | + ); |
| 276 | + })} |
| 277 | + </Stack> |
| 278 | + ); |
| 279 | + }; |
| 280 | + |
| 281 | + return ( |
| 282 | + <Card> |
| 283 | + <CardContent sx={{ p: 3 }}> |
| 284 | + <Stack |
| 285 | + direction={{ xs: 'column', sm: 'row' }} |
| 286 | + spacing={2} |
| 287 | + alignItems={{ xs: 'flex-start', sm: 'center' }} |
| 288 | + justifyContent="space-between" |
| 289 | + > |
| 290 | + <Box> |
| 291 | + <Typography variant="h6">Plugin updates</Typography> |
| 292 | + <Stack direction="row" spacing={0.75} alignItems="center" mt={0.5}> |
| 293 | + <ScheduleIcon sx={{ fontSize: 16, color: 'text.secondary' }} /> |
| 294 | + <Typography variant="caption" color="text.secondary"> |
| 295 | + {lastCheckedLabel} |
| 296 | + </Typography> |
| 297 | + </Stack> |
| 298 | + </Box> |
| 299 | + {renderHeaderActions()} |
| 300 | + </Stack> |
| 301 | + |
| 302 | + {renderProgress()} |
| 303 | + {renderError()} |
| 304 | + {renderSkeleton()} |
| 305 | + {renderEmpty()} |
| 306 | + {renderUpdates()} |
| 307 | + </CardContent> |
| 308 | + </Card> |
| 309 | + ); |
| 310 | +}; |
| 311 | + |
| 312 | +export default PluginUpdatesPanel; |
| 313 | + |
| 314 | + |
| 315 | + |
0 commit comments