diff --git a/demo/app.js b/demo/app.js index 950ad2cf..5afed4b9 100644 --- a/demo/app.js +++ b/demo/app.js @@ -35,7 +35,7 @@ const App = ({ manifestURL }) => { }; return ( -
+

Ramp

An interactive, IIIF powered A/V player built with components @@ -50,7 +50,7 @@ const App = ({ manifestURL }) => {

- +
{ name='manifesturl' value={userURL} onChange={handleUserInput} - placeholder='Manifest URL' /> - + placeholder='Manifest URL' + className="ramp-demo__manifest-input" /> +
diff --git a/demo/app.scss b/demo/app.scss index 63efed65..aed06a6a 100644 --- a/demo/app.scss +++ b/demo/app.scss @@ -35,7 +35,7 @@ div.iiif-player-demo { flex: 1; } - .tab-nav label { + .tab-nav>label { display: block; box-sizing: border-box; /* tab content must clear this */ @@ -48,7 +48,7 @@ div.iiif-player-demo { color: black; } - .tab-nav label:hover { + .tab-nav>label:hover { background: #d3d3d3; border-radius: 0.5rem 0.5rem 0 0; } @@ -98,7 +98,7 @@ div.iiif-player-demo { } } -.iiif-demo { +.ramp-demo { margin: auto; width: 75%; @@ -106,7 +106,7 @@ div.iiif-player-demo { margin-bottom: 15px; } - input[type=url] { + .ramp-demo__manifest-input { width: 80%; padding: 11px; border: 1px solid #ccc; @@ -115,12 +115,12 @@ div.iiif-player-demo { font-size: medium; } - label { + .ramp-demo__manifest-input-label { padding: 12px 12px 12px 0; display: inline-block; } - input[type=submit] { + .ramp-demo__manifest-submit { background-color: #2a5459; color: white; padding: 12px 20px; @@ -130,7 +130,7 @@ div.iiif-player-demo { font-size: medium; } - input[type=submit]:hover { + .ramp-demo__manifest-submit:hover { background-color: #80a590; } diff --git a/src/components/IIIFPlayerWrapper.js b/src/components/IIIFPlayerWrapper.js index 137b18e6..1a9438e4 100644 --- a/src/components/IIIFPlayerWrapper.js +++ b/src/components/IIIFPlayerWrapper.js @@ -1,7 +1,8 @@ import React from 'react'; import { useManifestDispatch } from '../context/manifest-context'; import PropTypes from 'prop-types'; -import { parseAutoAdvance, getIsPlaylist } from '@Services/iiif-parser'; +import { parseAutoAdvance } from '@Services/iiif-parser'; +import { getAnnotationService, getIsPlaylist } from '@Services/playlist-parser'; export default function IIIFPlayerWrapper({ manifestUrl, @@ -16,7 +17,12 @@ export default function IIIFPlayerWrapper({ if (manifest) { dispatch({ manifest: manifest, type: 'updateManifest' }); } else { - fetch(manifestUrl) + let requestOptions = { + // NOTE: try thin in Avalon + //credentials: 'include', + headers: { 'Avalon-Api-Key': '' }, + }; + fetch(manifestUrl, requestOptions) .then((result) => result.json()) .then((data) => { setManifest(data); @@ -35,6 +41,9 @@ export default function IIIFPlayerWrapper({ const isPlaylist = getIsPlaylist(manifest); dispatch({ isPlaylist: isPlaylist, type: 'setIsPlaylist' }); + + const annotationService = getAnnotationService(manifest); + dispatch({ annotationService: annotationService, type: 'setAnnotationService' }); } }, [manifest]); diff --git a/src/components/MarkersDisplay/MarkerUtils/CreateMarker.js b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.js new file mode 100644 index 00000000..b73769c4 --- /dev/null +++ b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.js @@ -0,0 +1,159 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { createNewAnnotation, parseMarkerAnnotation } from '@Services/playlist-parser'; +import { validateTimeInput, timeToS, timeToHHmmss } from '@Services/utility-helpers'; +import { SaveIcon, CancelIcon } from './SVGIcons'; + +const CreateMarker = ({ newMarkerEndpoint, canvasId, handleCreate, getCurrentTime }) => { + const [isOpen, setIsOpen] = React.useState(false); + const [isValid, setIsValid] = React.useState(false); + const [saveError, setSaveError] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(''); + const [markerTime, setMarkerTime] = React.useState(); + + const handleAddMarker = () => { + const currentTime = timeToHHmmss(getCurrentTime(), true, true); + validateTime(currentTime); + setIsOpen(true); + }; + + const handleCreateSubmit = (e) => { + e.preventDefault(); + const form = e.target; + const formData = new FormData(form); + const { label, time } = Object.fromEntries(formData.entries()); + const annotation = { + type: "Annotation", + motivation: "highlighting", + body: { + type: "TextualBody", + format: "text/html", + value: label, + }, + target: `${canvasId}#t=${timeToS(time)}` + }; + + const requestOptions = { + method: 'POST', + /** NOTE: In avalon try this option */ + headers: { + 'Accept': 'application/json', + 'Avalon-Api-Key': '', + }, + body: JSON.stringify(annotation) + }; + fetch(newMarkerEndpoint, requestOptions) + .then((response) => { + if (response.status != 201) { + throw new Error(); + } else { + return response.json(); + } + }).then((json) => { + const anno = createNewAnnotation(json); + const newMarker = parseMarkerAnnotation(anno); + if (newMarker) { + handleCreate(newMarker); + } + setIsOpen(false); + }) + .catch((e) => { + console.error('Failed to create annotation; ', e); + setSaveError(true); + setErrorMessage('Marker creation failed.'); + }); + }; + + const handleCreateCancel = () => { + setIsOpen(false); + setIsValid(false); + setErrorMessage(''); + setSaveError(false); + }; + + const validateTime = (value) => { + setMarkerTime(value); + let isValid = validateTimeInput(value); + setIsValid(isValid); + }; + + return ( +
+ + {isOpen && + (
+ + + + + + + + +
+ + + + + validateTime(e.target.value)} /> + +
+ { + saveError && +

+ {errorMessage} +

+ } + + +
+
+
) + } +
+ ); +}; + +CreateMarker.propTypes = { + newMarkerEndpoint: PropTypes.string.isRequired, + canvasId: PropTypes.string, + handleCreate: PropTypes.func.isRequired, + getCurrentTime: PropTypes.func.isRequired, +}; + +export default CreateMarker; diff --git a/src/components/MarkersDisplay/MarkerUtils/CreateMarker.test.js b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.test.js new file mode 100644 index 00000000..1addfae7 --- /dev/null +++ b/src/components/MarkersDisplay/MarkerUtils/CreateMarker.test.js @@ -0,0 +1,91 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import CreateMarker from './CreateMarker'; + +describe('CreateMarker component', () => { + const handleCreateMock = jest.fn(); + const getCurrentTimeMock = jest.fn(() => { return 44.3; }); + beforeEach(() => { + render(); + }); + + test('renders successfully', () => { + expect(screen.queryByTestId('create-new-marker-button')).toBeInTheDocument(); + expect(screen.queryByTestId('create-new-marker-form')).not.toBeInTheDocument(); + }); + + test('add new marker button click opens form', () => { + fireEvent.click(screen.getByTestId('create-new-marker-button')); + expect(screen.queryByTestId('create-new-marker-form')).toBeInTheDocument(); + expect(screen.getByTestId('create-marker-title')).toBeInTheDocument(); + expect(screen.getByTestId('create-marker-timestamp')).toBeInTheDocument(); + }); + + test('form opens with empty title and current time of playhead', () => { + fireEvent.click(screen.getByTestId('create-new-marker-button')); + waitFor(() => { + expect(getCurrentTimeMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('create-marker-title')).toHaveTextContent(''); + expect(screen.getByTestId('create-marker-timestamp')).toHaveTextContent('00:00:44.300'); + expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid'); + }); + }); + + test('validates time input and enable/disable save button', () => { + fireEvent.click(screen.getByTestId('create-new-marker-button')); + fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00' } }); + expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid'); + expect(screen.getByTestId('edit-save-button')).toBeDisabled(); + + fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00' } }); + expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid'); + expect(screen.getByTestId('edit-save-button')).not.toBeDisabled(); + + fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:' } }); + expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid'); + expect(screen.getByTestId('edit-save-button')).toBeDisabled(); + + fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:32.' } }); + expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid'); + expect(screen.getByTestId('edit-save-button')).toBeDisabled(); + + fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:32.543' } }); + expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid'); + expect(screen.getByTestId('edit-save-button')).not.toBeDisabled(); + }); + + test('saves marker on save button click', async () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + status: 201, + json: jest.fn(() => { + return { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "http://example.com/marker/1", + "type": "Annotation", + "motivation": "highlighting", + "body": { + "type": "TextualBody", + "value": "Test Marker" + }, + "target": "http://example.com/manifest/canvas/1#t=44.3" + }; + }) + }); + + fireEvent.click(screen.getByTestId('create-new-marker-button')); + fireEvent.change(screen.getByTestId('create-marker-title'), { target: { value: 'Test Marker' } }); + + expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid'); + expect(screen.getByTestId('edit-save-button')).not.toBeDisabled(); + + fireEvent.click(screen.getByTestId('edit-save-button')); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(handleCreateMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/MarkersDisplay/MarkerUtils/MarkerRow.js b/src/components/MarkersDisplay/MarkerUtils/MarkerRow.js new file mode 100644 index 00000000..16f9e3e0 --- /dev/null +++ b/src/components/MarkersDisplay/MarkerUtils/MarkerRow.js @@ -0,0 +1,294 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { CancelIcon, EditIcon, DeleteIcon, SaveIcon } from './SVGIcons'; +import { validateTimeInput, timeToS } from '@Services/utility-helpers'; + +const MarkerRow = ({ + marker, + handleSubmit, + handleMarkerClick, + handleDelete, + hasAnnotationService, + isEditing, + toggleIsEditing +}) => { + const [editing, setEditing] = React.useState(false); + const [isValid, setIsValid] = React.useState(true); + const [tempMarker, setTempMarker] = React.useState(); + const [deleting, setDeleting] = React.useState(false); + const [saveError, setSaveError] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(''); + + // Remove all subscriptions on unmount + React.useEffect(() => { + return {}; + }, []); + + React.useEffect(() => { + setMarkerLabel(marker.value); + setMarkerTime(marker.timeStr); + }, [marker]); + + let markerLabelRef = React.useRef(marker.value); + const setMarkerLabel = (label) => { + markerLabelRef.current = label; + }; + + let markerOffsetRef = React.useRef(timeToS(marker.timeStr)); + let markerTimeRef = React.useRef(marker.timeStr); + const setMarkerTime = (time) => { + markerTimeRef.current = time; + markerOffsetRef.current = timeToS(time); + }; + + const handleEdit = () => { + setTempMarker({ time: markerTimeRef.current, label: markerLabelRef.current }); + setEditing(true); + toggleIsEditing(true); + }; + + // Reset old information of the marker when edit action is cancelled + const handleCancel = () => { + setMarkerTime(tempMarker.time); + setMarkerLabel(tempMarker.label); + setTempMarker({}); + resetError(); + cancelAction(); + }; + + // Submit edited information of the current marker + const handleEditSubmit = () => { + const annotation = { + type: "Annotation", + motivation: "highlighting", + body: { + type: "TextualBody", + format: "text/html", + value: markerLabelRef.current, + }, + id: marker.id, + target: `${marker.canvasId}#t=${timeToS(markerTimeRef.current)}` + }; + const requestOptions = { + method: 'PUT', + /** NOTE: In avalon try this option */ + // credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Avalon-Api-Key': '', + }, + body: JSON.stringify(annotation) + }; + fetch(marker.id, requestOptions) + .then((response) => { + if (response.status != 201) { + throw new Error(); + } else { + handleSubmit(markerLabelRef.current, markerTimeRef.current, marker.id); + resetError(); + cancelAction(); + } + }) + .catch((e) => { + console.error('Failed to update annotation; ', e); + setSaveError(true); + setErrorMessage('Marker update failed'); + }); + }; + + // Validate timestamps when typing + const validateTime = (value) => { + let isValid = validateTimeInput(value); + setIsValid(isValid); + setMarkerTime(value); + }; + + // Toggle delete confirmation + const toggleDelete = () => { + setDeleting(true); + toggleIsEditing(true); + }; + + // Submit delete action + const submitDelete = () => { + const requestOptions = { + method: 'DELETE', + /** NOTE: In avalon try this option */ + // credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Avalon-Api-Key': '', + } + }; + // API call for DELETE + fetch(marker.id, requestOptions) + .then((response) => { + if (response.status != 200) { + throw new Error(); + } else { + handleDelete(marker.id); + resetError(); + cancelAction(); + } + }) + .catch((e) => { + console.error('Failed to delete annotation; ', e); + cancelAction(); + setSaveError(true); + setErrorMessage('Marker delete failed.'); + setTimeout(() => { + resetError(); + }, 1500); + }); + }; + + const resetError = () => { + setSaveError(false); + setErrorMessage(''); + }; + + // Reset edit state when edit/delete actions are finished + const cancelAction = () => { + setDeleting(false); + setEditing(false); + toggleIsEditing(false); + }; + + if (editing) { + return ( + + + setMarkerLabel(e.target.value)} + name="label" /> + + + validateTime(e.target.value)} + name="time" /> + + +
+ { + saveError && +

+ {errorMessage} +

+ } + + +
+ + + ); + } else if (deleting) { + return ( + + + handleMarkerClick(e)} + data-offset={markerOffsetRef.current}> + {markerLabelRef.current} + + {markerTimeRef.current} + +
+

Are you sure?

+ + +
+ + + ); + } else { + return ( + + + handleMarkerClick(e)} + data-offset={markerOffsetRef.current}> + {markerLabelRef.current} + + {markerTimeRef.current} + {hasAnnotationService && + +
+ { + saveError && +

+ {errorMessage} +

+ } + + +
+ } + + ); + } +}; + +MarkerRow.propTypes = { + marker: PropTypes.object.isRequired, + handleSubmit: PropTypes.func.isRequired, + handleMarkerClick: PropTypes.func.isRequired, + handleDelete: PropTypes.func.isRequired, + hasAnnotationService: PropTypes.bool.isRequired, + isEditing: PropTypes.bool.isRequired, + toggleIsEditing: PropTypes.func.isRequired, +}; + +export default MarkerRow; diff --git a/src/components/MarkersDisplay/MarkerUtils/SVGIcons.js b/src/components/MarkersDisplay/MarkerUtils/SVGIcons.js new file mode 100644 index 00000000..9b8ec04c --- /dev/null +++ b/src/components/MarkersDisplay/MarkerUtils/SVGIcons.js @@ -0,0 +1,82 @@ +import React from 'react'; + +// SVG icons for the edit buttons +export const EditIcon = () => { + return ( + + + + ); +}; + +export const DeleteIcon = () => { + return ( + + + + + + + + + + + + ); +}; + +export const SaveIcon = () => { + return ( + + + + + + + + + + ); +}; + +export const CancelIcon = () => { + return ( + + + + cancel2 + + + + + ); +}; diff --git a/src/components/MarkersDisplay/MarkersDisplay.js b/src/components/MarkersDisplay/MarkersDisplay.js index 0a087cc9..61caba0b 100644 --- a/src/components/MarkersDisplay/MarkersDisplay.js +++ b/src/components/MarkersDisplay/MarkersDisplay.js @@ -2,114 +2,51 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useManifestDispatch, useManifestState } from '../../context/manifest-context'; import { usePlayerState } from '../../context/player-context'; -import { parsePlaylistAnnotations } from '@Services/iiif-parser'; +import { parsePlaylistAnnotations } from '@Services/playlist-parser'; +import { canvasesInManifest } from '@Services/iiif-parser'; import { timeToS } from '@Services/utility-helpers'; +import CreateMarker from './MarkerUtils/CreateMarker'; +import MarkerRow from './MarkerUtils/MarkerRow'; import './MarkersDisplay.scss'; -// SVG icons for the edit buttons -const EditIcon = () => { - return ( - - - - ); -}; - -const DeleteIcon = () => { - return ( - - - - - - - - - - - - ); -}; - -const SaveIcon = () => { - return ( - - - - - - - - - - ); -}; - -const CancelIcon = () => { - return ( - - - - cancel2 - - - - - ); -}; - const MarkersDisplay = ({ showHeading = true, headingText = 'Markers' }) => { const { manifest, canvasIndex, playlist } = useManifestState(); const { player } = usePlayerState(); const manifestDispatch = useManifestDispatch(); - const { isEditing } = playlist; + const { isEditing, hasAnnotationService, annotationServiceId } = playlist; - const [playlistMarkers, setPlaylistMarkers] = React.useState([]); const [errorMsg, setErrorMsg] = React.useState(); + const canvasIdRef = React.useRef(); + + let playlistMarkersRef = React.useRef([]); + const setPlaylistMarkers = (list) => { + playlistMarkersRef.current = list; + }; React.useEffect(() => { if (manifest) { - const { markers, error } = parsePlaylistAnnotations(manifest, canvasIndex); - setPlaylistMarkers(markers); + const playlistMarkers = parsePlaylistAnnotations(manifest); + manifestDispatch({ markers: playlistMarkers, type: 'setPlaylistMarkers' }); + canvasIdRef.current = canvasesInManifest(manifest)[canvasIndex].canvasId; + } + }, [manifest]); + + React.useEffect(() => { + if (playlist.markers?.length > 0) { + let { canvasMarkers, error } = playlist.markers.filter((m) => m.canvasIndex === canvasIndex)[0]; + setPlaylistMarkers(canvasMarkers); setErrorMsg(error); - manifestDispatch({ markers, type: 'setPlaylistMarkers' }); } - }, [manifest, canvasIndex]); - const handleSubmit = (label, time, id, canvasId) => { - /* TODO:: Update the state once the API call is successful */ - // Update markers in state for displaying in the player UI - let editedMarkers = playlistMarkers.map(m => { + if (manifest) { + canvasIdRef.current = canvasesInManifest(manifest)[canvasIndex].canvasId; + } + }, [canvasIndex, playlist.markers]); + + const handleSubmit = (label, time, id) => { + // Re-construct markers list for displaying in the player UI + let editedMarkers = playlistMarkersRef.current.map(m => { if (m.id === id) { m.value = label; m.timeStr = time; @@ -118,251 +55,93 @@ const MarkersDisplay = ({ showHeading = true, headingText = 'Markers' }) => { return m; }); setPlaylistMarkers(editedMarkers); - manifestDispatch({ markers: editedMarkers, type: 'setPlaylistMarkers' }); - - // Call the annotation service to update the marker in the back-end - const annotation = { - type: "Annotation", - motivation: "highlighting", - body: { - type: "TextualBody", - format: "text/html", - value: label, - }, - id: id, - target: `${canvasId}#t=${timeToS(time)}` - }; - const requestOptions = { - method: 'PUT', - credentials: "include", - body: JSON.stringify(annotation) - }; - // fetch(id, requestOptions) - // .then((response) => { - // console.log(response); - // /* Update state */ - // }); - // return; + manifestDispatch({ updatedMarkers: editedMarkers, type: 'setPlaylistMarkers' }); }; const handleDelete = (id) => { - /* TODO:: Udate the state once the API call is successful */ + let remainingMarkers = playlistMarkersRef.current.filter(m => m.id != id); // Update markers in state for displaying in the player UI - let remainingMarkers = playlistMarkers.filter(m => m.id != id); setPlaylistMarkers(remainingMarkers); - manifestDispatch({ markers: remainingMarkers, type: 'setPlaylistMarkers' }); - - console.log('deleting marker: ', id); - // API call for DELETE - // fetch(id, requestOptions) - // .then((response) => { - // console.log(response); - // /* Update state */ - // }); - // return; + manifestDispatch({ updatedMarkers: remainingMarkers, type: 'setPlaylistMarkers' }); }; const handleMarkerClick = (e) => { + e.preventDefault(); const currentTime = parseFloat(e.target.dataset['offset']); player.currentTime(currentTime); }; - if (playlistMarkers.length > 0) { - return ( -
- {showHeading && ( -
-

{headingText}

-
- )} - + const handleCreate = (newMarker) => { + setPlaylistMarkers([...playlistMarkersRef.current, newMarker]); + manifestDispatch({ updatedMarkers: playlistMarkersRef.current, type: 'setPlaylistMarkers' }); + }; + + const toggleIsEditing = (flag) => { + manifestDispatch({ isEditing: flag, type: 'setIsEditing' }); + }; + + /** Get the current time of the playhead */ + const getCurrentTime = () => { + if (player) { + return player.currentTime(); + } else { + return 0; + } + }; + + return ( +
+ {showHeading && ( +
+

{headingText}

+
+ )} + {hasAnnotationService && ( + + )} + {playlistMarkersRef.current.length > 0 && ( +
- + {hasAnnotationService && } - {playlistMarkers.map((marker, index) => ( + {playlistMarkersRef.current.map((marker, index) => ( + hasAnnotationService={hasAnnotationService} + isEditing={isEditing} + toggleIsEditing={toggleIsEditing} /> ))}
Name TimeActionsActions
-
- ); - } else { - return
-

{errorMsg}

-
; - } -}; - -const MarkerRow = ({ marker, handleSubmit, handleMarkerClick, handleDelete, isEditing }) => { - const manifestDispatch = useManifestDispatch(); - const [editing, setEditing] = React.useState(false); - const [markerLabel, setMarkerLabel] = React.useState(marker.value); - const [markerTime, setMarkerTime] = React.useState(marker.timeStr); - const [markerOffset, setMarkerOffset] = React.useState(marker.time); - const [isValid, setIsValid] = React.useState(true); - const [tempMarker, setTempMarker] = React.useState(); - const [deleting, setDeleting] = React.useState(false); - - const handleEdit = () => { - setTempMarker({ time: markerTime, label: markerLabel }); - setEditing(true); - manifestDispatch({ isEditing: true, type: 'setIsEditing' }); - }; - - // Reset old information of the marker when edit action is cancelled - const handleCancel = () => { - setMarkerTime(tempMarker.time); - setMarkerLabel(tempMarker.label); - setTempMarker({}); - cancelAction(); - }; - - // Submit edited information of the current marker - const handleEditSubmit = () => { - setMarkerOffset(timeToS(markerTime)); - handleSubmit(markerLabel, markerTime, marker.id, marker.canvasId); - cancelAction(); - }; - - // Validate timestamps when typing - const validateTimeInput = (value) => { - const timeRegex = /^(([0-1][0-9])|([2][0-3])):([0-5][0-9])(:[0-5][0-9](?:[.]\d{1,3})?)?$/; - setIsValid(timeRegex.test(value)); - setMarkerTime(value); - }; - - // Toggle delete confirmation - const toggleDelete = () => { - setDeleting(true); - manifestDispatch({ isEditing: true, type: 'setIsEditing' }); - }; - - // Submit delete action - const submitDelete = () => { - handleDelete(marker.id); - cancelAction(); - }; - - // Reset edit state when edit/delete actions are finished - const cancelAction = () => { - setDeleting(false); - setEditing(false); - manifestDispatch({ isEditing: false, type: 'setIsEditing' }); - }; - - if (editing) { - return ( - - - setMarkerLabel(e.target.value)} - name="label" /> - - - validateTimeInput(e.target.value)} - name="time" /> - - - - - - - ); - } else if (deleting) { - return ( - - - handleMarkerClick(e)} data-offset={markerOffset}> - {markerLabel} - - {markerTime} - -
-

Are you sure?

- - -
- - - ); - } else { - return ( - - - handleMarkerClick(e)} data-offset={markerOffset}> - {markerLabel} - - {markerTime} - - - - - - ); - } - + )} + {playlistMarkersRef.current.length == 0 && ( +
+

{errorMsg}

+
+ )} +
+ ); }; MarkersDisplay.propTypes = { diff --git a/src/components/MarkersDisplay/MarkersDisplay.scss b/src/components/MarkersDisplay/MarkersDisplay.scss index 20ae3186..f46612a2 100644 --- a/src/components/MarkersDisplay/MarkersDisplay.scss +++ b/src/components/MarkersDisplay/MarkersDisplay.scss @@ -1,10 +1,10 @@ @import '../../styles/vars'; .ramp--markers-display { - min-width: 30rem; + min-width: inherit; padding: 1rem; - .ramp--markers-display-title { + .ramp--markers-display__title { border: 0.05rem solid $primaryLight; border-radius: 0.25rem 0.25rem 0 0; margin-bottom: 1rem; @@ -20,29 +20,44 @@ table { font-family: arial, sans-serif; - border-collapse: collapse; width: 100%; + border-collapse: collapse; *:disabled { cursor: not-allowed; opacity: 0.8; } - th:nth-child(3) { + td:nth-child(3) { width: 40%; } - td, th { - border: 1px solid #dddddd; + border: 1px solid $primaryLightest; + padding: 0.5rem; + } + + td { + border: 1px solid $primaryLightest; text-align: left; - padding: 8px; + padding: 0.5rem; font-weight: normal; } + + input.ramp--markers-display__edit-marker { + width: 100%; + padding: 0.5rem 0.25rem; + display: inline-block; + border: 1px solid #ccc; + border-radius: 0.2rem; + box-sizing: border-box; + font-size: inherit; + } } - .delete-confirmation { + .marker-actions { display: flex; + justify-content: flex-end; p { margin: 0; @@ -50,24 +65,14 @@ } } - input[type=text] { - width: 100%; - padding: 0.5rem 0.25rem; - display: inline-block; - border: 1px solid #ccc; - border-radius: 0.2rem; - box-sizing: border-box; - font-size: inherit; - } - .time-invalid { outline: none; - border-color: red; - box-shadow: 0 0 10px red; + border-color: $danger; + box-shadow: 0 0 10px $danger; } - .ramp--edit-button { - background-color: #2a5459; + .ramp--markers-display__edit-button { + background-color: $primaryGreenDark; color: white; padding: 5px 10px; border: none; @@ -76,8 +81,8 @@ margin-left: 0.5rem; } - .ramp--edit-button-danger { - background-color: #f44336; + .ramp--markers-display__edit-button-danger { + background-color: $danger; color: white; padding: 5px 10px; border: none; @@ -85,9 +90,45 @@ cursor: pointer; margin-left: 0.5rem; } + + .ramp--markers-display__error-message { + color: $danger; + font-size: small; + margin: auto; + } } -.ramp--markers-empty { +.ramp--markers-display__markers-empty { font-size: medium; padding: 2em; } + +// Styling for new marker form +.ramp-markers-display__new-marker { + margin-bottom: 1rem; +} + +.ramp--markers-display__new-marker-form { + border: 1px solid $primaryLight; + padding: 0.5rem; + border-radius: 0.25rem; + margin: 1rem 0; + font-size: 0.85rem; + font-weight: bold; + + table.create-marker-form-table { + border: none; + } + + input.ramp--markers-display__create-marker { + width: 80%; + vertical-align: middle; + padding: 0.5rem 0.25rem; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 0.2rem; + box-sizing: border-box; + margin-left: 0.5rem; + font-size: inherit; + } +} diff --git a/src/components/MarkersDisplay/MarkersDisplay.test.js b/src/components/MarkersDisplay/MarkersDisplay.test.js index 6f854502..d5154c61 100644 --- a/src/components/MarkersDisplay/MarkersDisplay.test.js +++ b/src/components/MarkersDisplay/MarkersDisplay.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import MarkersDisplay from './MarkersDisplay'; import manifest from '@TestData/playlist'; import manifestWoMarkers from '@TestData/lunchroom-manners'; @@ -12,7 +12,11 @@ describe('MarkersDisplay component', () => { initialManifestState: { manifest, canvasIndex: 1, - playlist: {} + playlist: { + hasAnnotationService: true, + isEditing: false, + annotationServiceId: 'http://example.com/marker' + } }, initialPlayerState: {}, }); @@ -21,6 +25,7 @@ describe('MarkersDisplay component', () => { test('renders successfully', () => { expect(screen.queryByTestId('markers-display')).toBeInTheDocument(); + expect(screen.queryByTestId('markers-display-table')).toBeInTheDocument(); expect(screen.queryByTestId('markers-empty')).not.toBeInTheDocument(); }); @@ -40,6 +45,19 @@ describe('MarkersDisplay component', () => { let firstEditButton, secondEditButton, firstDeleteButton, secondDeleteButton; beforeEach(() => { + const MarkersDisplayWrapped = withManifestAndPlayerProvider(MarkersDisplay, { + initialManifestState: { + manifest, + canvasIndex: 1, + playlist: { + hasAnnotationService: true, + isEditing: false, + annotationServiceId: 'http://example.com/marker' + } + }, + initialPlayerState: {}, + }); + render(); firstEditButton = screen.queryAllByTestId('edit-button')[0]; secondEditButton = screen.queryAllByTestId('edit-button')[1]; firstDeleteButton = screen.queryAllByTestId('delete-button')[0]; @@ -83,6 +101,9 @@ describe('MarkersDisplay component', () => { }); test('saving updates the markers table', () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + status: 201, + }); fireEvent.click(firstEditButton); const labelInput = screen.getByTestId('edit-label'); expect(labelInput).toHaveDisplayValue('Marker 1'); @@ -90,16 +111,25 @@ describe('MarkersDisplay component', () => { fireEvent.change(labelInput, { target: { value: 'Test Marker' } }); fireEvent.click(screen.getByTestId('edit-save-button')); - expect(screen.queryByText('Test Marker')).toBeInTheDocument(); + waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Test Marker')).toBeInTheDocument(); + }); }); test('delete action removes the marker from table', () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + status: 200, + }); fireEvent.click(secondDeleteButton); expect(screen.queryByTestId('delete-confirm-button')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('delete-confirm-button')); - expect(screen.queryByText('Marker 2')).not.toBeInTheDocument(); + waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Marker 2')).not.toBeInTheDocument(); + }); }); }); }); @@ -115,11 +145,12 @@ describe('MarkersDisplay component', () => { initialPlayerState: {}, }); render(); - expect(screen.queryByTestId('markers-display')).not.toBeInTheDocument(); + expect(screen.queryByTestId('markers-display')).toBeInTheDocument(); + expect(screen.queryByTestId('markers-display-table')).not.toBeInTheDocument(); expect(screen.queryByTestId('markers-empty')).toBeInTheDocument(); - expect(screen.queryByText( - 'No markers were found in the Canvas' - )).toBeInTheDocument(); + waitFor(() => { + expect(screen.queryByText('No markers were found in the Canvas')).toBeInTheDocument(); + }); }); }); }); diff --git a/src/components/MediaPlayer/MediaPlayer.js b/src/components/MediaPlayer/MediaPlayer.js index 790df0a3..b7382f8a 100644 --- a/src/components/MediaPlayer/MediaPlayer.js +++ b/src/components/MediaPlayer/MediaPlayer.js @@ -376,7 +376,6 @@ const MediaPlayer = ({ enableFileDownload = false, enablePIP = false }) => { > { - let markersList = []; - if (playlistMarkers?.length > 0) { + if (playlist.markers?.length > 0) { + const playlistMarkers = playlist.markers + .filter((m) => m.canvasIndex === canvasIndex)[0].canvasMarkers; + let markersList = []; playlistMarkers.map((m) => { markersList.push({ time: parseFloat(m.time), text: m.value }); }); - } - if (player && player.markers && isReady) { - // Clear existing markers when updating the markers - player.markers.removeAll(); - player.markers.add(markersList); + if (player && player.markers && isReady) { + // Clear existing markers when updating the markers + player.markers.removeAll(); + player.markers.add(markersList); + } } - }, [player, isReady, playlistMarkers]); + + }, [player, isReady, playlist.markers]); /** * Switch canvas when using structure navigation / the media file ends @@ -319,7 +322,7 @@ function VideoJSPlayer({ if (!player || !currentPlayer) { return; } - if (currentNavItem !== null && isReady) { + if (currentNavItem !== null && isReady && !isPlaylist) { // Mark current time fragment if (player.markers) { if (!isPlaylist) { @@ -534,7 +537,6 @@ function VideoJSPlayer({ VideoJSPlayer.propTypes = { isVideo: PropTypes.bool, - playlistMarkers: PropTypes.array, isPlaylist: PropTypes.bool, switchPlayer: PropTypes.func, handleIsEnded: PropTypes.func, diff --git a/src/context/manifest-context.js b/src/context/manifest-context.js index 98cdc08d..d6302b71 100644 --- a/src/context/manifest-context.js +++ b/src/context/manifest-context.js @@ -18,9 +18,11 @@ const defaultState = { startTime: 0, autoAdvance: false, playlist: { - markers: [], + markers: [], // [{ canvasIndex: Number, canvasMarkers: Array, error: String }] isEditing: false, - isPlaylist: false + isPlaylist: false, + hasAnnotationService: false, + annotationServiceId: '', } }; @@ -81,13 +83,32 @@ function manifestReducer(state = defaultState, action) { }; } case 'setPlaylistMarkers': { - return { - ...state, - playlist: { - ...state.playlist, - markers: action.markers, - } - }; + // Set a new set of markers for the canvases in the Manifest + if (action.markers) { + return { + ...state, + playlist: { + ...state.playlist, + markers: action.markers, + } + }; + } + // Update the existing markers for the current canvas on CRUD ops + if (action.updatedMarkers) { + return { + ...state, + playlist: { + ...state.playlist, + markers: state.playlist.markers.map((m) => { + if (m.canvasIndex === state.canvasIndex) { + m.canvasMarkers = action.updatedMarkers; + } + return m; + }) + } + }; + + } } case 'setIsEditing': { return { @@ -113,6 +134,16 @@ function manifestReducer(state = defaultState, action) { canvasIsEmpty: action.isEmpty, }; } + case 'setAnnotationService': { + return { + ...state, + playlist: { + ...state.playlist, + annotationServiceId: action.annotationService, + hasAnnotationService: action.annotationService ? true : false, + } + }; + } default: { throw new Error(`Unhandled action type: ${action.type}`); } diff --git a/src/services/iiif-parser.js b/src/services/iiif-parser.js index 8f6eaa3f..e2641dab 100644 --- a/src/services/iiif-parser.js +++ b/src/services/iiif-parser.js @@ -523,57 +523,3 @@ export function parseAutoAdvance(manifest) { const autoAdvanceBehavior = parseManifest(manifest).getProperty("behavior")?.includes("auto-advance"); return (autoAdvanceBehavior === undefined) ? false : autoAdvanceBehavior; } - -/** - * Parses the manifest to identify whether it is a playlist manifest - * or not - * @param {Object} manifest - * @returns {Boolean} - */ -export function getIsPlaylist(manifest) { - try { - const manifestTitle = manifest.label; - let isPlaylist = getLabelValue(manifestTitle).includes('[Playlist]'); - return isPlaylist; - } catch (err) { - console.error('Cannot parse manfiest, ', err); - return false; - } -} - -/** - * Parse `highlighting` annotations with TextualBody type as markers - * @param {Object} manifest - * @param {Number} canvasIndex current canvas index - * @returns {Array} JSON object array with the following format, - * [{ id: String, time: Number, timeStr: String, canvasId: String, value: String}] - */ -export function parsePlaylistAnnotations(manifest, canvasIndex) { - const annotations = getAnnotations({ - manifest, - canvasIndex, - key: 'annotations', - motivation: 'highlighting' - }); - let markers = []; - - if (!annotations || annotations.length === 0) { - return { error: 'No markers were found in the Canvas', markers: [] }; - } else if (annotations.length > 0) { - annotations.map((a) => { - let [canvasId, time] = a.getTarget().split('#t='); - let markerBody = a.getBody(); - if (markerBody?.length > 0 && markerBody[0].getProperty('type') === 'TextualBody') { - const marker = { - id: a.id, - time: parseFloat(time), - timeStr: timeToHHmmss(parseFloat(time), true, true), - canvasId: canvasId, - value: markerBody[0].getProperty('value') ? markerBody[0].getProperty('value') : '', - }; - markers.push(marker); - } - }); - return { markers, error: '' }; - } -} diff --git a/src/services/iiif-parser.test.js b/src/services/iiif-parser.test.js index 3d437869..9d5635fc 100644 --- a/src/services/iiif-parser.test.js +++ b/src/services/iiif-parser.test.js @@ -448,52 +448,6 @@ describe('iiif-parser', () => { }); }); - describe('getIsPlaylist()', () => { - it('returns false for non-playlist manifest', () => { - const isPlaylist = iiifParser.getIsPlaylist(manifest); - expect(isPlaylist).toBeFalsy(); - }); - - it('returns true for playlist manifest', () => { - const isPlaylist = iiifParser.getIsPlaylist(playlistManifest); - expect(isPlaylist).toBeTruthy(); - }); - - it('returns false for unrecognized input', () => { - const originalError = console.error; - console.error = jest.fn(); - - const isPlaylist = iiifParser.getIsPlaylist(undefined); - expect(isPlaylist).toBeFalsy(); - expect(console.error).toHaveBeenCalledTimes(1); - - console.error = originalError; - }); - }); - - describe('parsePlaylistAnnotations()', () => { - it('returns empty array for a canvas without markers', () => { - const { markers, error } = iiifParser.parsePlaylistAnnotations(manifest, 0); - expect(markers).toHaveLength(0); - expect(error).toEqual('No markers were found in the Canvas'); - }); - - it('returns markers information for a canvas with markers', () => { - const { markers, error } = iiifParser.parsePlaylistAnnotations(playlistManifest, 1); - - expect(markers).toHaveLength(2); - expect(error).toEqual(''); - - expect(markers[0]).toEqual({ - time: 2.836, - timeStr: '00:00:02.836', - value: 'Marker 1', - id: 'http://example.com/manifests/playlist/canvas/2/marker/3', - canvasId: 'http://example.com/manifests/playlist/canvas/2' - }); - }); - }); - describe('inaccessibleItemMessage()', () => { it('returns text under placeholderCanvas', () => { const itemMessage = iiifParser.inaccessibleItemMessage(manifest, 1); diff --git a/src/services/playlist-parser.js b/src/services/playlist-parser.js new file mode 100644 index 00000000..2d5c0673 --- /dev/null +++ b/src/services/playlist-parser.js @@ -0,0 +1,110 @@ +import { parseManifest, Annotation } from "manifesto.js"; +import { getAnnotations, getLabelValue, parseAnnotations, timeToHHmmss } from "./utility-helpers"; + +export function getAnnotationService(manifest) { + const service = parseManifest(manifest).getService(); + if (service && service.getProperty('type') === 'AnnotationService0') { + return service.id; + } else { + return null; + } +} + +/** + * Parses the manifest to identify whether it is a playlist manifest + * or not + * @param {Object} manifest + * @returns {Boolean} + */ +export function getIsPlaylist(manifest) { + try { + const manifestTitle = manifest.label; + let isPlaylist = getLabelValue(manifestTitle).includes('[Playlist]'); + return isPlaylist; + } catch (err) { + console.error('Cannot parse manfiest, ', err); + return false; + } +} + +/** + * Parse `highlighting` annotations with TextualBody type as markers + * for all the Canvases in the given Manifest + * @param {Object} manifest + * @returns {Array} JSON object array with markers information for each + * Canvas in the given Manifest. + * [{ canvasIndex: Number, + * canvasMarkers: [{ + * id: String, + * time: Number, + * timeStr: String, + * canvasId: String, + * value: String + * }], + * error: String, + * }] + * + */ +export function parsePlaylistAnnotations(manifest) { + let canvases = parseManifest(manifest) + .getSequences()[0] + .getCanvases(); + let allMarkers = []; + + if (canvases) { + canvases.map((canvas, index) => { + let annotations = parseAnnotations(canvas.__jsonld['annotations'], 'highlighting'); + if (!annotations || annotations.length === 0) { + allMarkers.push({ canvasMarkers: [], canvasIndex: index, error: 'No markers were found in the Canvas' }); + } else if (annotations.length > 0) { + let canvasMarkers = []; + annotations.map((a) => { + const marker = parseMarkerAnnotation(a); + if (marker) { + canvasMarkers.push(marker); + } + }); + allMarkers.push({ canvasMarkers, canvasIndex: index, error: '' }); + } + }); + } + return allMarkers; +} + +/** + * Parse a manifesto.js Annotation object for a marker annotation into + * a JSON object with information required to display the annotation in + * the UI + * @param {Object} a manifesto.js Annotation object + * @returns {Object} a json object for a marker + * { id: String, time: Number, timeStr: String, canvasId: String, value: String} + */ +export function parseMarkerAnnotation(a) { + if (!a) { + return null; + } + let [canvasId, time] = a.getTarget().split('#t='); + let markerBody = a.getBody(); + if (markerBody?.length > 0 && markerBody[0].getProperty('type') === 'TextualBody') { + const marker = { + id: a.id, + time: parseFloat(time), + timeStr: timeToHHmmss(parseFloat(time), true, true), + canvasId: canvasId, + value: markerBody[0].getProperty('value') ? markerBody[0].getProperty('value') : '', + }; + return marker; + } else { + return null; + } +} + +/** + * Wrapper for manifesto.js Annotation constructor + * @param {Object} annotationInfo JSON object with annotation information + * @returns {Annotation} + */ +export function createNewAnnotation(annotationInfo) { + const annotation = new Annotation(annotationInfo); + return annotation; +} diff --git a/src/services/playlist-parser.test.js b/src/services/playlist-parser.test.js new file mode 100644 index 00000000..86cff323 --- /dev/null +++ b/src/services/playlist-parser.test.js @@ -0,0 +1,145 @@ +import manifest from '@TestData/transcript-annotation'; +import playlistManifest from '@TestData/playlist'; +import lunchroomManifest from '@TestData/lunchroom-manners'; +import autoAdvanceManifest from '@TestData/multiple-canvas-auto-advance'; +import * as playlistParser from './playlist-parser'; + +describe('playlist-parser', () => { + describe('getAnnotationService()', () => { + it('returns annotations service when specified', () => { + const serviceId = playlistParser.getAnnotationService(playlistManifest); + expect(serviceId).toEqual('http://example.com/manifests/playlist/marker'); + }); + + it('returns null when not specified', () => { + const serviceId = playlistParser.getAnnotationService(lunchroomManifest); + expect(serviceId).toBeNull(); + }); + + it('returns null when type is not AnnotationService0', () => { + const serviceId = playlistParser.getAnnotationService(autoAdvanceManifest); + expect(serviceId).toBeNull(); + }); + }); + + describe('getIsPlaylist()', () => { + it('returns false for non-playlist manifest', () => { + const isPlaylist = playlistParser.getIsPlaylist(manifest); + expect(isPlaylist).toBeFalsy(); + }); + + it('returns true for playlist manifest', () => { + const isPlaylist = playlistParser.getIsPlaylist(playlistManifest); + expect(isPlaylist).toBeTruthy(); + }); + + it('returns false for unrecognized input', () => { + const originalError = console.error; + console.error = jest.fn(); + + const isPlaylist = playlistParser.getIsPlaylist(undefined); + expect(isPlaylist).toBeFalsy(); + expect(console.error).toHaveBeenCalledTimes(1); + + console.error = originalError; + }); + }); + + describe('parsePlaylistAnnotations()', () => { + it('returns array of canvas hashes, with canvasMarkers being empty arrays', () => { + const canvases = playlistParser.parsePlaylistAnnotations(manifest); + expect(canvases[0].canvasMarkers).toHaveLength(0); + expect(canvases[1].canvasMarkers).toHaveLength(0); + expect(canvases[0].error).toEqual('No markers were found in the Canvas'); + expect(canvases[1].error).toEqual('No markers were found in the Canvas'); + }); + + it('returns markers information for a canvas with markers', () => { + const canvases = playlistParser.parsePlaylistAnnotations(playlistManifest); + + expect(canvases[0].canvasMarkers).toHaveLength(0); + expect(canvases[0].error).toEqual('No markers were found in the Canvas'); + expect(canvases[1].canvasMarkers).toHaveLength(2); + expect(canvases[1].error).toEqual(''); + expect(canvases[2].canvasMarkers).toHaveLength(2); + expect(canvases[2].error).toEqual(''); + + expect(canvases[1].canvasMarkers[0]).toEqual({ + time: 2.836, + timeStr: '00:00:02.836', + value: 'Marker 1', + id: 'http://example.com/manifests/playlist/canvas/2/marker/3', + canvasId: 'http://example.com/manifests/playlist/canvas/2' + }); + }); + }); + + describe('parseMarkerAnnotation()', () => { + it('returns marker json object', () => { + const annotation = { + __jsonld: { + id: "https://example.com/marker/1", + type: "Annotation", + motivation: "highlighting", + body: { + type: "TextualBody", + value: "Test Marker" + }, + target: "http://example.com/manifest/canvas/1#t=55.0" + }, + getTarget: jest.fn(() => { return annotation.__jsonld.target; }), + getBody: jest.fn(() => { + return [{ + "type": "TextualBody", + "value": "Test Marker", + getProperty: jest.fn((prop) => { + return annotation.__jsonld.body[prop]; + }), + }]; + }), + id: "https://example.com/marker/1" + }; + const marker = playlistParser.parseMarkerAnnotation(annotation); + expect(marker.value).toEqual('Test Marker'); + expect(marker.timeStr).toEqual('00:00:55.000'); + expect(marker.canvasId).toEqual('http://example.com/manifest/canvas/1'); + expect(marker.time).toEqual(55); + }); + + it('returns null for undefined', () => { + const marker = playlistParser.parseMarkerAnnotation(); + expect(marker).toBeNull(); + }); + }); + + describe('createNewAnnotation()', () => { + const annot = playlistParser.createNewAnnotation( + { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "https://example.com/marker/1", + "type": "Annotation", + "motivation": "highlighting", + "body": { + "type": "TextualBody", + "value": "Test Marker" + }, + "target": "http://example.com/manifest/canvas/1#t=55.0" + } + ); + expect(annot).toEqual({ + "__jsonld": { + "@context": "http://www.w3.org/ns/anno.jsonld", + "id": "https://example.com/marker/1", + "type": "Annotation", + "motivation": "highlighting", + "body": { + "type": "TextualBody", + "value": "Test Marker" + }, + "target": "http://example.com/manifest/canvas/1#t=55.0" + }, + "context": "http://www.w3.org/ns/anno.jsonld", + "id": "https://example.com/marker/1" + }); + }); +}); diff --git a/src/services/utility-helpers.js b/src/services/utility-helpers.js index 4105b705..ce5fb824 100644 --- a/src/services/utility-helpers.js +++ b/src/services/utility-helpers.js @@ -394,3 +394,14 @@ export function getLabelValue(label) { } return 'Label could not be parsed'; } + +/** + * Validate time input from user against the hh:mm:ss.ms format + * @param {String} time user input time string + * @returns {Boolean} + */ +export function validateTimeInput(time) { + const timeRegex = /^(([0-1][0-9])|([2][0-3])):([0-5][0-9])(:[0-5][0-9](?:[.]\d{1,3})?)?$/; + let isValid = timeRegex.test(time); + return isValid; +} diff --git a/src/styles/_vars.scss b/src/styles/_vars.scss index 8e1bdabf..98b0cd85 100644 --- a/src/styles/_vars.scss +++ b/src/styles/_vars.scss @@ -10,6 +10,8 @@ $primaryGreenLight: #cfd8d3; $primaryGreen: #80a590; $primaryGreenDark: #2A5459; +$danger: #e0101a; + $fontPrimary: 'Open Sans', sans-serif; $linkColor: $primary; diff --git a/src/test_data/multiple-canvas-auto-advance.js b/src/test_data/multiple-canvas-auto-advance.js index 82b37fad..44be79f7 100644 --- a/src/test_data/multiple-canvas-auto-advance.js +++ b/src/test_data/multiple-canvas-auto-advance.js @@ -121,6 +121,12 @@ export default { ] } ], + service: [ + { + id: 'http://example.com/manifests/playlist/auth', + type: 'AuthService0' + } + ], structures: [ { type: "Range", diff --git a/src/test_data/playlist.js b/src/test_data/playlist.js index 89f74151..a2286975 100644 --- a/src/test_data/playlist.js +++ b/src/test_data/playlist.js @@ -14,6 +14,12 @@ export default { value: { none: ["Playlist Manifest [Playlist]"] } } ], + service: [ + { + id: 'http://example.com/manifests/playlist/marker', + type: 'AnnotationService0' + } + ], items: [ { id: 'http://example.com/manifests/playlist/canvas/1',