Skip to content

Commit 936c22a

Browse files
gvelez17claude
andcommitted
Add video playback to validation dialog and graph legend
ValidationDetailsDialog: - Video plays at top of dialog when attestation has video - Cleaner, minimal layout with responsive sizing - Supports both media array and legacy mediaUrl GraphLegend: - Collapsible legend in bottom-left of graph view - Shows edge type colors (Validated, Rated, Impact, Same as) - Small info button expands to show legend Types: - Add MediaItem interface and media array to Validation type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 168dc34 commit 936c22a

File tree

4 files changed

+179
-162
lines changed

4 files changed

+179
-162
lines changed

src/components/Certificate/ValidationDetailsDialog.tsx

Lines changed: 86 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,199 +1,123 @@
11
import React from 'react'
2-
import { Dialog, DialogContent, IconButton, Box, Typography, Link as MuiLink } from '@mui/material'
2+
import { Dialog, DialogContent, IconButton, Box, Typography, Link as MuiLink, Chip } from '@mui/material'
33
import CloseIcon from '@mui/icons-material/Close'
44
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
5+
import VideocamIcon from '@mui/icons-material/Videocam'
56
import { ValidationDetailsDialogProps } from '../../types/certificate'
67
import { useTheme, useMediaQuery } from '@mui/material'
78

89
const ValidationDetailsDialog: React.FC<ValidationDetailsDialogProps> = ({ open, onClose, validation }) => {
910
const theme = useTheme()
10-
const isXs = useMediaQuery(theme.breakpoints.down('sm'))
11+
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
1112

1213
if (!validation) return null
1314

15+
// Get video from media array or legacy mediaUrl
16+
const videoUrl = validation.media?.find(m => m.type === 'video')?.url || validation.mediaUrl
17+
const hasVideo = !!videoUrl
18+
1419
return (
1520
<Dialog
1621
open={open}
1722
onClose={onClose}
18-
maxWidth={false}
23+
maxWidth='sm'
24+
fullWidth
1925
sx={{
2026
'& .MuiDialog-paper': {
21-
width: { xs: '95%', sm: '90%', md: '85%' },
22-
maxWidth: '800px',
23-
maxHeight: { xs: '95vh', sm: '90vh' },
24-
borderRadius: { xs: '8px', sm: '10px', md: '12px' },
25-
backgroundColor: '#FFFFFF',
26-
overflowY: 'auto'
27+
borderRadius: 3,
28+
maxHeight: '90vh'
2729
}
2830
}}
2931
>
30-
<DialogContent sx={{ p: 0, position: 'relative' }}>
32+
<DialogContent sx={{ p: 0 }}>
33+
{/* Close button */}
3134
<IconButton
3235
onClick={onClose}
3336
sx={{
3437
position: 'absolute',
35-
right: { xs: 6, sm: 8 },
36-
top: { xs: 6, sm: 8 },
37-
color: '#212529',
38+
right: 8,
39+
top: 8,
3840
zIndex: 1,
39-
padding: { xs: '4px', sm: '8px' }
41+
bgcolor: 'background.paper',
42+
'&:hover': { bgcolor: 'action.hover' }
4043
}}
41-
size={isXs ? 'small' : 'medium'}
44+
size='small'
4245
>
43-
<CloseIcon fontSize={isXs ? 'small' : 'medium'} />
46+
<CloseIcon fontSize='small' />
4447
</IconButton>
45-
<Box sx={{ padding: { xs: 2, sm: 2.5, md: 3 } }}>
46-
<Box sx={{ marginBottom: { xs: 2, sm: 2.5, md: 3 } }}>
47-
{validation.subject && (
48-
<Box
49-
sx={{
50-
fontSize: { xs: '16px', sm: '18px', md: '20px' },
51-
fontWeight: 500,
52-
color: '#2D6A4F',
53-
marginBottom: { xs: 1.5, sm: 2 },
54-
display: 'flex',
55-
alignItems: 'center',
56-
gap: '8px',
57-
flexWrap: 'wrap'
58-
}}
59-
>
60-
<MuiLink
61-
href={validation.sourceURI}
62-
target='_blank'
63-
sx={{
64-
display: 'flex',
65-
alignItems: 'center',
66-
gap: '4px',
67-
color: 'inherit',
68-
textDecoration: 'none',
69-
'&:hover': {
70-
textDecoration: 'underline'
71-
},
72-
wordBreak: 'break-word'
73-
}}
74-
>
75-
{validation.sourceURI}
76-
<OpenInNewIcon sx={{ fontSize: { xs: 16, sm: 18, md: 20 } }} />
77-
</MuiLink>
78-
</Box>
79-
)}
80-
<Typography
81-
sx={{
82-
fontSize: { xs: '18px', sm: '20px', md: '24px' },
83-
fontWeight: 500,
84-
color: '#2D6A4F',
85-
marginBottom: { xs: 1.5, sm: 2 }
86-
}}
87-
>
88-
{validation.issuer_name}
48+
49+
{/* Video section */}
50+
{hasVideo && (
51+
<Box sx={{ bgcolor: 'black', width: '100%' }}>
52+
<video
53+
controls
54+
style={{ width: '100%', maxHeight: isMobile ? 200 : 300, display: 'block' }}
55+
src={videoUrl}
56+
/>
57+
</Box>
58+
)}
59+
60+
{/* Content */}
61+
<Box sx={{ p: 3 }}>
62+
{/* Issuer name */}
63+
<Typography variant='h6' fontWeight={600} gutterBottom>
64+
{validation.issuer_name}
65+
</Typography>
66+
67+
{/* How known chip */}
68+
{validation.howKnown && (
69+
<Chip
70+
label={validation.howKnown.replace(/_/g, ' ').toLowerCase()}
71+
size='small'
72+
variant='outlined'
73+
sx={{ mb: 2, textTransform: 'capitalize' }}
74+
/>
75+
)}
76+
77+
{/* Statement */}
78+
{validation.statement && (
79+
<Typography variant='body1' color='text.secondary' sx={{ mb: 2, lineHeight: 1.6 }}>
80+
"{validation.statement}"
8981
</Typography>
90-
<Typography
91-
sx={{
92-
fontSize: { xs: '14px', sm: '15px', md: '16px' },
93-
color: '#212529',
94-
marginBottom: { xs: 1.5, sm: 2 },
95-
lineHeight: 1.6
96-
}}
97-
>
98-
{validation.statement}
82+
)}
83+
84+
{/* Date */}
85+
{validation.effectiveDate && (
86+
<Typography variant='body2' color='text.secondary' sx={{ mb: 2 }}>
87+
{new Date(validation.effectiveDate).toLocaleDateString('en-US', {
88+
year: 'numeric',
89+
month: 'long',
90+
day: 'numeric'
91+
})}
9992
</Typography>
100-
<Typography
93+
)}
94+
95+
{/* Source link */}
96+
{validation.sourceURI && (
97+
<MuiLink
98+
href={validation.sourceURI}
99+
target='_blank'
100+
rel='noopener noreferrer'
101101
sx={{
102-
fontSize: { xs: '12px', sm: '13px', md: '14px' },
103-
color: '#495057',
104-
display: 'flex',
102+
display: 'inline-flex',
105103
alignItems: 'center',
106-
gap: '8px'
104+
gap: 0.5,
105+
fontSize: '0.875rem',
106+
color: 'primary.main'
107107
}}
108108
>
109-
{validation.effectiveDate &&
110-
new Date(validation.effectiveDate).toLocaleDateString('en-US', {
111-
year: 'numeric',
112-
month: 'long',
113-
day: 'numeric'
114-
})}
115-
</Typography>
116-
</Box>
117-
<Box sx={{ marginTop: { xs: 2, sm: 2.5, md: 3 } }}>
118-
{validation.howKnown && (
119-
<Box sx={{ marginBottom: { xs: 1.5, sm: 2 } }}>
120-
<Typography
121-
sx={{
122-
fontWeight: 500,
123-
color: '#495057',
124-
minWidth: { xs: '120px', sm: '140px', md: '150px' },
125-
display: { xs: 'block', sm: 'inline-block' },
126-
fontSize: { xs: '13px', sm: '14px' }
127-
}}
128-
>
129-
How Known:
130-
</Typography>
131-
<Typography
132-
sx={{
133-
color: '#212529',
134-
fontSize: { xs: '13px', sm: '14px' }
135-
}}
136-
>
137-
{validation.howKnown.replace(/_/g, ' ')}
138-
</Typography>
139-
</Box>
140-
)}
141-
{validation.sourceURI && (
142-
<Box sx={{ marginBottom: { xs: 1.5, sm: 2 } }}>
143-
<Typography
144-
sx={{
145-
fontWeight: 500,
146-
color: '#495057',
147-
minWidth: { xs: '120px', sm: '140px', md: '150px' },
148-
display: { xs: 'block', sm: 'inline-block' },
149-
fontSize: { xs: '13px', sm: '14px' }
150-
}}
151-
>
152-
Source:
153-
</Typography>
154-
<Typography>
155-
<MuiLink
156-
href={validation.sourceURI}
157-
target='_blank'
158-
sx={{
159-
color: '#2D6A4F',
160-
textDecoration: 'none',
161-
fontSize: { xs: '13px', sm: '14px' },
162-
'&:hover': {
163-
textDecoration: 'underline'
164-
},
165-
wordBreak: 'break-word'
166-
}}
167-
>
168-
{validation.sourceURI}
169-
</MuiLink>
170-
</Typography>
171-
</Box>
172-
)}
173-
{validation.confidence !== undefined && (
174-
<Box sx={{ marginBottom: { xs: 1.5, sm: 2 } }}>
175-
<Typography
176-
sx={{
177-
fontWeight: 500,
178-
color: '#495057',
179-
minWidth: { xs: '120px', sm: '140px', md: '150px' },
180-
display: { xs: 'block', sm: 'inline-block' },
181-
fontSize: { xs: '13px', sm: '14px' }
182-
}}
183-
>
184-
Confidence:
185-
</Typography>
186-
<Typography
187-
sx={{
188-
color: '#212529',
189-
fontSize: { xs: '13px', sm: '14px' }
190-
}}
191-
>
192-
{validation.confidence}
193-
</Typography>
194-
</Box>
195-
)}
196-
</Box>
109+
View source
110+
<OpenInNewIcon sx={{ fontSize: 16 }} />
111+
</MuiLink>
112+
)}
113+
114+
{/* Video indicator if video exists but is above */}
115+
{hasVideo && (
116+
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
117+
<VideocamIcon fontSize='small' />
118+
<Typography variant='body2'>Video testimony</Typography>
119+
</Box>
120+
)}
197121
</Box>
198122
</DialogContent>
199123
</Dialog>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React, { useState } from 'react'
2+
import { Box, Typography, IconButton, Collapse, useTheme } from '@mui/material'
3+
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
4+
import CloseIcon from '@mui/icons-material/Close'
5+
import { edgeColors } from '../../theme/colors'
6+
7+
const legendItems = [
8+
{ label: 'Validated', color: edgeColors.validated },
9+
{ label: 'Rated', color: edgeColors.rated },
10+
{ label: 'Impact', color: edgeColors.impact },
11+
{ label: 'Same as', color: edgeColors.same_as }
12+
]
13+
14+
const GraphLegend: React.FC = () => {
15+
const [open, setOpen] = useState(false)
16+
const theme = useTheme()
17+
18+
return (
19+
<Box
20+
sx={{
21+
position: 'fixed',
22+
bottom: 80,
23+
left: 16,
24+
zIndex: 1000
25+
}}
26+
>
27+
{/* Toggle button */}
28+
{!open && (
29+
<IconButton
30+
onClick={() => setOpen(true)}
31+
size='small'
32+
sx={{
33+
bgcolor: theme.palette.background.paper,
34+
boxShadow: 2,
35+
'&:hover': { bgcolor: theme.palette.action.hover }
36+
}}
37+
>
38+
<InfoOutlinedIcon fontSize='small' />
39+
</IconButton>
40+
)}
41+
42+
{/* Legend panel */}
43+
<Collapse in={open}>
44+
<Box
45+
sx={{
46+
bgcolor: theme.palette.background.paper,
47+
borderRadius: 2,
48+
boxShadow: 3,
49+
p: 1.5,
50+
minWidth: 120
51+
}}
52+
>
53+
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
54+
<Typography variant='caption' fontWeight={600} color='text.secondary'>
55+
Edge Types
56+
</Typography>
57+
<IconButton size='small' onClick={() => setOpen(false)} sx={{ p: 0.25 }}>
58+
<CloseIcon sx={{ fontSize: 14 }} />
59+
</IconButton>
60+
</Box>
61+
62+
{legendItems.map(item => (
63+
<Box key={item.label} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.25 }}>
64+
<Box
65+
sx={{
66+
width: 16,
67+
height: 3,
68+
bgcolor: item.color,
69+
borderRadius: 1
70+
}}
71+
/>
72+
<Typography variant='caption' color='text.secondary'>
73+
{item.label}
74+
</Typography>
75+
</Box>
76+
))}
77+
</Box>
78+
</Collapse>
79+
</Box>
80+
)
81+
}
82+
83+
export default GraphLegend

src/containers/Explore/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import cytoscapeNodeHtmlLabel from 'cytoscape-node-html-label'
1313
import './CustomNodeStyles.css'
1414
import GraphDetailModal from '../../components/GraphDetailModal'
1515
import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong'
16+
import GraphLegend from './GraphLegend'
1617

1718
// Toggle SAME_AS node merging (set to false to see all nodes separately)
1819
const MERGE_SAME_AS_NODES = true
@@ -497,6 +498,7 @@ const Explore = (homeProps: IHomeProps) => {
497498
/>
498499
</Box>
499500
<GraphinfButton />
501+
<GraphLegend />
500502
<Tooltip title='Fit to Screen' placement='left'>
501503
<Fab
502504
color='primary'

0 commit comments

Comments
 (0)