mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-12 13:28:36 +03:00
Merge pull request #66 from forensic-architecture/topic/source-md
Add support for markdown files in sources
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
41
src/components/Md.jsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user