Merge pull request #66 from forensic-architecture/topic/source-md

Add support for markdown files in sources
This commit is contained in:
Lachlan Kermode
2019-01-04 15:04:49 +01:00
committed by GitHub
11 changed files with 239 additions and 106 deletions

View File

@@ -16,6 +16,7 @@
"es6-promise": "^4.1.1",
"joi": "^14.0.1",
"leaflet": "^1.0.3",
"marked": "^0.6.0",
"normalizr": "^3.2.3",
"object-hash": "^1.3.0",
"react": "^16.6.3",

View File

@@ -154,6 +154,16 @@ function mapDispatchToProps(dispatch) {
};
}
function injectSource(id) {
return state => ({
...state,
app: {
...state.app,
source: state.domain.sources[id]
}
})
}
export default connect(
state => state,
mapDispatchToProps,

41
src/components/Md.jsx Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react'
import PropTypes from 'prop-types'
import marked from 'marked'
class Md extends React.Component {
constructor(props) {
super(props)
this.state = { md: null, error: null }
}
componentDidMount() {
fetch(this.props.path)
.then(resp => resp.text())
.then(text => {
this.setState({ md: marked(text) })
})
.catch(err => {
this.setState({ error: true })
})
}
render() {
if (this.state.md && !this.state.error) {
return (
<div dangerouslySetInnerHTML={{ __html: this.state.md }} />
)
} else if (this.state.error) {
return this.props.unloader || <div>Error: couldn't load source</div>
} else {
return this.props.loader
}
}
}
Md.propTypes = {
loader: PropTypes.func,
unloader: PropTypes.func,
path: PropTypes.string.isRequired
}
export default Md

View File

@@ -1,122 +1,159 @@
import React from 'react'
import Img from 'react-image'
import { Player } from 'video-react'
import Md from './Md.jsx'
import Spinner from './presentational/Spinner'
import NoSource from './presentational/NoSource'
// TODO: move render functions into presentational components
class SourceOverlay extends React.Component {
constructor(props) {
super(props)
this.renderVideo = this.renderVideo.bind(this)
this.renderPhoto = this.renderPhoto.bind(this)
this.renderPhotobook = this.renderPhotobook.bind(this)
this.renderTestimony = this.renderTestimony.bind(this)
function SourceOverlay ({ source, onCancel }) {
function renderImage(path) {
return (
<div className='source-image-container'>
<Img
className='source-image'
src={path}
loader={<Spinner />}
unloader={<NoSource failedUrls={source.paths} />}
/>
</div>
)
}
renderVideo() {
function renderVideo(path) {
// NB: assume only one video
return (
<div className="media-player">
<Player
className='source-video'
playsInline
src={this.props.source.paths[0]}
src={path}
/>
</div>
)
}
renderPhoto() {
function renderText(path) {
return (
<div className='source-image-container'>
<Img
className='source-image'
src={this.props.source.paths}
<div className='source-text-container'>
<Md
path={path}
loader={<Spinner />}
unloader={<NoSource failedUrls={this.props.source.paths} />}
unloader={renderError()}
/>
</div>
)
}
renderPhotobook() {
return (
<div className='source-image-container'>
{this.props.source.paths.map((url, idx) => (
<Img
key={idx}
className='source-image'
src={url}
loader={<Spinner />}
unloader={<NoSource failedUrls={[this.props.source.path]} />}
/>
))}
</div>
)
}
renderError() {
function renderError() {
return (
<NoSource failedUrls={["NOT ALL SOURCES AVAILABLE IN APPLICATION YET"]} />
)
}
renderTestimony() {
function renderNoSupport(ext) {
return (
<NoSource failedUrls={[`Application does not support extension: ${ext}`]} />
)
}
function toMedia(path) {
let type;
switch (true) {
case /\.(png|jpg)$/.test(path):
type = 'Image'; break
case /\.(mp4)$/.test(path):
type = 'Video'; break
case /\.(md)$/.test(path):
type = 'Text'; break
default:
type = 'Unknown'; break
}
return { type, path }
}
function getTypeCounts(media) {
let counts = { Image: 0, Video: 0, Text: 0 }
media.forEach(m => {
counts[m.type] += 1
})
return counts
}
function _renderPath(media) {
const { path, type } = media
switch (type) {
case 'Image':
return renderImage(path)
case 'Video':
return renderVideo(path)
case 'Text':
return renderText(path)
default:
return renderNoSupport(path.split('.')[1])
}
}
function _renderCounts(counts) {
const strFor = type =>
counts[type] > 0 ?
`${counts[type]} ${type.toLowerCase()}${counts[type] > 1 ? 's': ''}`
: ''
const img = strFor('Image')
const vid = strFor('Video')
const txt = strFor('Text')
return (
<div>
<a href={`${this.props.source.path}.docx`}>Download Testimony</a>
{img ? img : ''}
{vid ? `, ${vid}`: ''}
{txt ? `, ${txt}`: ''}
</div>
)
}
_renderSwitch() {
switch(this.props.source.type) {
case 'Video':
return this.renderVideo()
case 'Photo':
return this.renderPhoto()
case 'Photobook':
return this.renderPhotobook()
case 'Eyewitness Testimony':
return this.renderTestimony()
default:
return this.renderError()
}
function _renderContent(media) {
return (
<React.Fragment>
{media.map(_renderPath)}
</React.Fragment>
)
}
render() {
if (typeof(this.props.source) !== 'object') {
return this.renderError()
}
const {id, url, title, date, type, affil_1, affil_2} = this.props.source
return (
<div className="mo-overlay">
<div className="mo-container">
<div className="mo-header">
<div className="mo-header-close" onClick={this.props.onCancel}>
<button className="side-menu-burg is-active"><span></span></button>
</div>
<div className="mo-header-text">{this.props.source.id}</div>
if (typeof(source) !== 'object') {
return renderError()
}
const {id, url, title, paths, date, type, desc} = source
const media = paths.map(toMedia)
const counts = getTypeCounts(media)
return (
<div className="mo-overlay">
<div className="mo-container">
<div className="mo-header">
<div className="mo-header-close" onClick={onCancel}>
<button className="side-menu-burg is-active"><span></span></button>
</div>
<div className="mo-media-container">
{this._renderSwitch()}
</div>
<div className="mo-meta-container">
<div className="mo-box">
{id ? <div><b>{id}</b></div> : null}
{title? <div><b>{title}</b></div> : null}
<hr/>
{type ? <div>Type: <span className="indent">{type}</span></div> : null}
{date ? <div>Date:<span className="indent">{date}</span></div> : null}
<hr/>
{url ? <div><a href={url} target="_blank">Link to original URL</a></div> : null}
</div>
<div className="mo-header-text">{source.title}</div>
</div>
<div className="mo-media-container">
{_renderContent(media)}
</div>
<div className="mo-meta-container">
<div className="mo-box">
{title? <div><b>{title}</b></div> : null}
<div>{_renderCounts(counts)}</div>
{type ? <div>{type}</div> : null}
{date ? <div>Date:<span className="indent">{date}</span></div> : null}
{url ? <div><a href={url} target="_blank">Link to original URL</a></div> : null}
<hr />
{desc ? <div>{desc}</div> : null}
</div>
</div>
</div>
)
}
</div>
)
}
export default SourceOverlay

View File

@@ -35,15 +35,20 @@ const CardSource = ({ source, isLoading, onClickHandler }) => {
)
}
const isImgUrl = /\.(jpg|png)$/
let thumbnail = source.thumbnail
if (!thumbnail || thumbnail === '') {
if (!thumbnail || thumbnail === '' || !thumbnail.match(isImgUrl)) {
// default to first image in paths, null if no images
const imgs = source.paths.filter(p => p.match(/\.(jpg|png)$/))
const imgs = source.paths.filter(p => p.match(isImgUrl))
thumbnail = imgs.length > 0 ? imgs[0] : null
}
console.log(!!thumbnail)
console.log(thumbnail)
const fallbackIcon = (
<i className="material-icons source-icon">
{renderIconText(source.type)}
</i>
)
return (
<div className="card-source">
@@ -52,12 +57,15 @@ const CardSource = ({ source, isLoading, onClickHandler }) => {
: (
<div className="source-row" onClick={() => onClickHandler(source)}>
{!!thumbnail ? (
<img className="source-icon" src={thumbnail} width={30} height={30} />
) : (
<i className="material-icons source-icon">
{renderIconText(source.type)}
</i>
)}
<Img
className="source-icon"
src={thumbnail}
loader={<Spinner small />}
unloader={fallbackIcon}
width={30}
height={30}
/>
) : fallbackIcon}
<p>{source.id}</p>
</div>
)}

View File

@@ -1,8 +1,8 @@
import React from 'react';
const Spinner = () => {
const Spinner = ({ small }) => {
return (
<div className="spinner">
<div className={`spinner ${small ? 'small' : ''}`}>
<div className="double-bounce-overlay"></div>
<div className="double-bounce"></div>
</div>

View File

@@ -72,3 +72,18 @@ export function formatterWithYear(datetime) {
export function formatter(datetime) {
return d3.timeFormat("%d %b, %H:%M")(datetime);
}
/**
* Debugging function: put in place of a mapStateToProps function to
* view that source modal by default
*/
function injectSource(id) {
return state => ({
...state,
app: {
...state.app,
source: state.domain.sources[id]
}
})
}

View File

@@ -2,11 +2,12 @@ import Joi from 'joi';
const sourceSchema = Joi.object().keys({
id: Joi.string().required(),
title: Joi.string().allow(''),
thumbnail: Joi.string().allow(''),
paths: Joi.array().required(),
type: Joi.string().allow(''),
affil_1: Joi.string().allow(''),
affil_2: Joi.string().allow(''),
// affil_1: Joi.string().allow(''),
// affil_2: Joi.string().allow(''),
url: Joi.string().allow(''),
desc: Joi.string().allow(''),
parent: Joi.string().allow(''),

View File

@@ -42,6 +42,12 @@ https://github.com/tobiasahlin/SpinKit/blob/master/LICENSE
position: relative;
margin: 10px auto;
&.small {
width: 15px;
height: 15px;
margin: 5px 20px 5px 10px;
}
}
.double-bounce, .double-bounce-overlay {

View File

@@ -48,34 +48,46 @@ $header-inset: 10px;
.mo-container {
background-color: rgba(239, 239, 239, 0.9);
max-width: $panel-width;
min-width: $panel-width;
max-height: $panel-height;
min-height: $panel-height;
// max-width: $panel-width;
// min-width: $panel-width;
// max-height: $panel-height;
// min-height: $panel-height;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
height: 80vh;
max-width: 90vw;
box-shadow: 0 19px 19px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22);
.mo-media-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
}
.mo-media-container {
// padding-top: 3*$header-inset;
font-family: "Lato", Helvetica, sans-serif;
// max-height: $vimeo-height;
min-width: 100%;
max-height: 500px;
max-height: 60vh;
overflow-y: auto;
.media-player {
width: 100%;
max-width: $vimeo-width;
}
.media-content {
display: flex;
flex-direction: column;
}
// NB: topcushion seems to be necessary with certain overflows..
&.topcushion {
padding-top: 150px;
}
}
.mo-meta-container {
@@ -144,16 +156,17 @@ $header-inset: 10px;
}
}
.source-image-container {
padding: 0 25px;
overflow-y: scroll;
height: 100%;
.source-image-container, .source-text-container {
padding: 0 10em;
display: flex;
justify-content: center;
align-items: center;
}
.source-image, .source-video {
max-width: calc(100% - 20px);
max-height: 350px !important;
height: 100%;
// height: 100%;
padding: 10px;
}

View File

@@ -3962,7 +3962,6 @@ lodash.tail@^4.1.1:
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.3, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.2.1, lodash@~4.17.10:
version "4.17.11"
@@ -4036,6 +4035,10 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
marked@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/marked/-/marked-0.6.0.tgz#a18d01cfdcf8d15c3c455b71c8329e5e0f01faa1"
matcher@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
@@ -5244,7 +5247,6 @@ redux@^3.6.0:
redux@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5"
integrity sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg==
dependencies:
loose-envify "^1.4.0"
symbol-observable "^1.2.0"
@@ -6403,7 +6405,6 @@ verror@1.10.0:
video-react@^0.13.1:
version "0.13.1"
resolved "https://registry.yarnpkg.com/video-react/-/video-react-0.13.1.tgz#5d0dc68748f9b12e118beea1998d6ae5f6cbd6ba"
integrity sha512-AeGSpddfHv0UxeJztWUALYEjCdzXM1QdtQ5GD1VUd3vxcgwgIfB7EzFKcewRevSHHK8TDmjNksbvbWRobF/QeA==
dependencies:
classnames "^2.2.3"
lodash.throttle "^4.1.1"