mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-13 05:48:36 +03:00
Cleaning technical debt (#192)
* abstract Space component to switch out Map * basic viewing possible * restructure components dir * all jsx --> js * App.jsx --> App.js * comment out 3d for now
This commit is contained in:
19
src/components/atoms/Checkbox.js
Normal file
19
src/components/atoms/Checkbox.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
const Checkbox = ({ label, isActive, onClickCheckbox, color }) => {
|
||||
const styles = {
|
||||
background: isActive ? color : "none",
|
||||
border: `1px solid ${color}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isActive ? "item active" : "item"}>
|
||||
<span style={{ color: color }}>{label}</span>
|
||||
<button onClick={onClickCheckbox}>
|
||||
<div className="checkbox" style={styles} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
43
src/components/atoms/CoeventIcon.js
Normal file
43
src/components/atoms/CoeventIcon.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
|
||||
const CoeventIcon = ({ isEnabled, toggleMapViews }) => {
|
||||
return (
|
||||
<button onClick={() => toggleMapViews("coevents")}>
|
||||
<svg
|
||||
className="coevents"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="30px"
|
||||
height="20px"
|
||||
viewBox="0 0 30 20"
|
||||
enableBackground="new 0 0 30 20"
|
||||
>
|
||||
<polygon
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
points="19.178,20 10.823,20 10.473,14.081
|
||||
10,13.396 10,6.084 20,6.084 20,13.396 19.445,14.021 "
|
||||
/>
|
||||
<rect
|
||||
className="no-fill"
|
||||
x="11.4"
|
||||
y="7.867"
|
||||
width="7.2"
|
||||
height="3.35"
|
||||
/>
|
||||
<line
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="10"
|
||||
x1="12.125"
|
||||
y1="1"
|
||||
x2="12.125"
|
||||
y2="5.35"
|
||||
/>
|
||||
<rect x="11.4" y="4.271" width="1.496" height="1.079" />
|
||||
<rect x="17.104" y="4.271" width="1.496" height="1.079" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoeventIcon;
|
||||
53
src/components/atoms/ColoredMarkers.js
Normal file
53
src/components/atoms/ColoredMarkers.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { getCoordinatesForPercent } from "../../common/utilities";
|
||||
|
||||
function ColoredMarkers({ radius, colorPercentMap, styles, className }) {
|
||||
let cumulativeAngleSweep = 0;
|
||||
const colors = Object.keys(colorPercentMap);
|
||||
|
||||
return (
|
||||
<>
|
||||
{colors.map((color, idx) => {
|
||||
const colorPercent = colorPercentMap[color];
|
||||
|
||||
const [startX, startY] = getCoordinatesForPercent(
|
||||
radius,
|
||||
cumulativeAngleSweep
|
||||
);
|
||||
|
||||
cumulativeAngleSweep += colorPercent;
|
||||
|
||||
const [endX, endY] = getCoordinatesForPercent(
|
||||
radius,
|
||||
cumulativeAngleSweep
|
||||
);
|
||||
// if the slices are less than 2, take the long arc
|
||||
const largeArcFlag = colors.length === 1 || colorPercent > 0.5 ? 1 : 0;
|
||||
|
||||
// create an array and join it just for code readability
|
||||
const arc = [
|
||||
`M ${startX} ${startY}`, // Move
|
||||
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc
|
||||
"L 0 0 ", // Line
|
||||
`L ${startX} ${startY} Z`, // Line
|
||||
].join(" ");
|
||||
|
||||
const extraStyles = {
|
||||
...styles,
|
||||
fill: color,
|
||||
};
|
||||
|
||||
return (
|
||||
<path
|
||||
class={className}
|
||||
id={`arc_${idx}`}
|
||||
d={arc}
|
||||
style={extraStyles}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColoredMarkers;
|
||||
96
src/components/atoms/Content.js
Normal file
96
src/components/atoms/Content.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import { Player } from "video-react";
|
||||
import Img from "react-image";
|
||||
import Md from "./Md";
|
||||
import Spinner from "../atoms/Spinner";
|
||||
import NoSource from "../atoms/NoSource";
|
||||
|
||||
const Content = ({ media, viewIdx, translations, switchLanguage, langIdx }) => {
|
||||
const el = document.querySelector(".source-media-gallery");
|
||||
const shiftW = el ? el.getBoundingClientRect().width : 0;
|
||||
|
||||
function renderMedia(media) {
|
||||
const { path, type, poster } = media;
|
||||
switch (type) {
|
||||
case "Image":
|
||||
return (
|
||||
<div className="source-image-container">
|
||||
<Img
|
||||
className="source-image"
|
||||
src={path}
|
||||
loader={
|
||||
<div className="source-image-loader">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
unloader={<NoSource failedUrls={[path]} />}
|
||||
onClick={() => window.open(path, "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "Video":
|
||||
return (
|
||||
<div className="media-player">
|
||||
<div className="banner-trans right-overlay">
|
||||
{translations
|
||||
? translations.map((trans, idx) =>
|
||||
langIdx !== idx + 1 ? (
|
||||
<div
|
||||
className="trans-button"
|
||||
onClick={() => switchLanguage(idx + 1)}
|
||||
>
|
||||
{trans.code}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="trans-button"
|
||||
onClick={() => switchLanguage(0)}
|
||||
>
|
||||
EN
|
||||
</div>
|
||||
)
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<Player
|
||||
poster={poster}
|
||||
className="source-video"
|
||||
playsInline
|
||||
src={path}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "Text":
|
||||
return (
|
||||
<div className="source-text-container">
|
||||
<Md
|
||||
path={path}
|
||||
loader={<Spinner />}
|
||||
unloader={() => this.renderError()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "Document":
|
||||
return <iframe title={path} className="source-document" src={path} />;
|
||||
default:
|
||||
return (
|
||||
<NoSource
|
||||
failedUrls={[
|
||||
`Application does not support extension: ${path.split(".")[1]}`,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="source-media-gallery"
|
||||
style={{ transform: `translate(${viewIdx * -shiftW}px)` }}
|
||||
>
|
||||
{media.map((m) => renderMedia(m))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Content;
|
||||
32
src/components/atoms/Controls.js
Normal file
32
src/components/atoms/Controls.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
|
||||
const OverlayControls = ({ viewIdx, paths, onShiftHandler }) => {
|
||||
const backArrow =
|
||||
viewIdx !== 0 ? (
|
||||
<div className="back" onClick={() => onShiftHandler(-1)}>
|
||||
<div className="centerer">
|
||||
<i className="material-icons">arrow_left</i>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
const forwardArrow =
|
||||
viewIdx < paths.length - 1 ? (
|
||||
<div className="next" onClick={() => onShiftHandler(1)}>
|
||||
<div className="centerer">
|
||||
<i className="material-icons">arrow_right</i>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (paths.length > 1) {
|
||||
return (
|
||||
<div className="media-gallery-controls">
|
||||
{backArrow}
|
||||
{forwardArrow}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="media-gallery-controls" />;
|
||||
};
|
||||
|
||||
export default OverlayControls;
|
||||
16
src/components/atoms/CoverIcon.js
Normal file
16
src/components/atoms/CoverIcon.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {
|
||||
let classes = isActive ? "action-button enabled" : "action-button";
|
||||
if (isDisabled) {
|
||||
classes = "action-button disabled";
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={classes} onClick={onClickHandler}>
|
||||
<i class="material-icons">home</i>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoverIcon;
|
||||
16
src/components/atoms/InfoIcon.js
Normal file
16
src/components/atoms/InfoIcon.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
const CoverIcon = ({ isActive, isDisabled, onClickHandler }) => {
|
||||
let classes = isActive ? "action-button enabled" : "action-button";
|
||||
if (isDisabled) {
|
||||
classes = "action-button disabled";
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={classes} onClick={onClickHandler}>
|
||||
<i className="material-icons">info</i>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoverIcon;
|
||||
23
src/components/atoms/Loading.js
Normal file
23
src/components/atoms/Loading.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import copy from "../../common/data/copy.json";
|
||||
|
||||
const LoadingOverlay = ({ isLoading, language }) => {
|
||||
let classes = "loading-overlay";
|
||||
classes += !isLoading ? " hidden" : "";
|
||||
|
||||
return (
|
||||
<div id="loading-overlay" className={classes}>
|
||||
<div className="loading-wrapper">
|
||||
<span id="loading-text" className="text">
|
||||
{copy[language].loading}
|
||||
</span>
|
||||
<div className="spinner">
|
||||
<div className="double-bounce1" />
|
||||
<div className="double-bounce2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
48
src/components/atoms/Md.js
Normal file
48
src/components/atoms/Md.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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) => {
|
||||
if (text.length <= 0) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
this.setState({ md: marked(text) });
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ error: true });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.md && !this.state.error) {
|
||||
return (
|
||||
<div
|
||||
className="md-container"
|
||||
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.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default Md;
|
||||
158
src/components/atoms/Media.js
Normal file
158
src/components/atoms/Media.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from "react";
|
||||
import marked from "marked";
|
||||
import Content from "./Content";
|
||||
import Controls from "./Controls";
|
||||
import { selectTypeFromPathWithPoster } from "../../common/utilities";
|
||||
|
||||
/*
|
||||
* Inside the SourceOverlay, both the currently displaying media and language
|
||||
* can be changed by the user. These are both managed in this component's React
|
||||
* state.
|
||||
*/
|
||||
class SourceOverlay extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { mediaIdx: 0, langIdx: 0 };
|
||||
this.onShiftGallery = this.onShiftGallery.bind(this);
|
||||
}
|
||||
|
||||
getTypeCounts(media) {
|
||||
return media.reduce(
|
||||
(acc, vl) => {
|
||||
acc[vl.type] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ Image: 0, Video: 0, Text: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
onShiftGallery(shift) {
|
||||
// no more left
|
||||
if (this.state.mediaIdx === 0 && shift === -1) return;
|
||||
// no more right
|
||||
if (
|
||||
this.state.mediaIdx === this.props.source.paths.length - 1 &&
|
||||
shift === 1
|
||||
)
|
||||
return;
|
||||
this.setState({ mediaIdx: this.state.mediaIdx + shift });
|
||||
}
|
||||
|
||||
switchLanguage(idx) {
|
||||
this.setState({ langIdx: idx });
|
||||
}
|
||||
|
||||
renderContent(source) {
|
||||
const { url, title, paths, date, type, poster, description } = source;
|
||||
const shortenedTitle = title.substring(0, 100);
|
||||
return (
|
||||
<>
|
||||
<div className="mo-banner">
|
||||
<div className="mo-banner-close" onClick={this.props.onCancel}>
|
||||
<i className="material-icons">close</i>
|
||||
</div>
|
||||
|
||||
<h3 className="mo-banner-content">{shortenedTitle}</h3>
|
||||
</div>
|
||||
<div className="mo-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mo-media-container">
|
||||
<Content
|
||||
switchLanguage={(lang) => this.switchLanguage(lang)}
|
||||
translations={this.props.translations}
|
||||
langIdx={this.state.langIdx}
|
||||
media={paths.map((p) => selectTypeFromPathWithPoster(p, poster))}
|
||||
viewIdx={this.state.mediaIdx}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mo-footer">
|
||||
<Controls
|
||||
paths={paths}
|
||||
viewIdx={this.state.mediaIdx}
|
||||
onShiftHandler={this.onShiftGallery}
|
||||
/>
|
||||
|
||||
<div className="mo-meta-container">
|
||||
{description ? (
|
||||
<div className="mo-box-desc">
|
||||
<div
|
||||
className="md-container"
|
||||
dangerouslySetInnerHTML={{ __html: marked(description) }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type || date || url ? (
|
||||
<div className="mo-box">
|
||||
<div>
|
||||
{type ? <h4>Evidence type</h4> : null}
|
||||
{type ? (
|
||||
<p>
|
||||
<i className="material-icons left">perm_media</i>
|
||||
{type}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
{date ? <h4>Date Published</h4> : null}
|
||||
{date ? (
|
||||
<p>
|
||||
<i className="material-icons left">today</i>
|
||||
{date}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
{url ? <h4>Link</h4> : null}
|
||||
{url ? (
|
||||
<span>
|
||||
<i className="material-icons left">link</i>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
Link to original URL
|
||||
</a>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderIntlContent() {
|
||||
const { langIdx } = this.state;
|
||||
const { translations, source } = this.props;
|
||||
let translated = null;
|
||||
if (translations && translations.length && langIdx > 0) {
|
||||
translated = translations[langIdx - 1];
|
||||
}
|
||||
if (translated) {
|
||||
translated = {
|
||||
...translated,
|
||||
poster: source.poster,
|
||||
// NOTE: this is to allow a slightly nicer syntax when using the Media
|
||||
// overlay in cover videos.
|
||||
paths: translated.file ? [translated.file] : translated.paths,
|
||||
};
|
||||
}
|
||||
|
||||
return this.renderContent(langIdx === 0 ? source : translated);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (typeof this.props.source !== "object") {
|
||||
return this.renderError();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mo-overlay ${this.props.opaque ? "opaque" : ""}`}>
|
||||
{this.renderIntlContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SourceOverlay;
|
||||
19
src/components/atoms/NoSource.js
Normal file
19
src/components/atoms/NoSource.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
const NoSource = ({ failedUrls }) => {
|
||||
return (
|
||||
<div className="no-source-container">
|
||||
<div className="no-source-row">
|
||||
<p>
|
||||
<i className="material-icons no-source-icon">error</i>
|
||||
</p>
|
||||
<p>
|
||||
No media found, as the original media has not yet been uploaded to the
|
||||
platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoSource;
|
||||
40
src/components/atoms/Popup.js
Normal file
40
src/components/atoms/Popup.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import marked from "marked";
|
||||
|
||||
const fontSize = window.innerWidth > 1000 ? 14 : 18;
|
||||
|
||||
const Popup = ({
|
||||
content = [],
|
||||
styles = {},
|
||||
isOpen = true,
|
||||
onClose,
|
||||
title,
|
||||
theme = "light",
|
||||
isMobile = false,
|
||||
children,
|
||||
}) => (
|
||||
<div>
|
||||
<div
|
||||
className={`infopopup ${isOpen ? "" : "hidden"} ${
|
||||
theme === "dark" ? "dark" : "light"
|
||||
} ${isMobile ? "mobile" : ""}`}
|
||||
style={{ ...styles, fontSize }}
|
||||
>
|
||||
<div className="legend-header">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="side-menu-burg over-white is-active"
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{content.map((t, idx) => (
|
||||
<div key={idx} dangerouslySetInnerHTML={{ __html: marked(t) }} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Popup;
|
||||
23
src/components/atoms/RefreshIcon.js
Normal file
23
src/components/atoms/RefreshIcon.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
export default ({ isActive, isDisabled, onClickHandler }) => {
|
||||
return (
|
||||
<svg
|
||||
className="reset"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="25px"
|
||||
height="25px"
|
||||
viewBox="7.5 7.5 25 25"
|
||||
enableBackground="new 7.5 7.5 25 25"
|
||||
>
|
||||
<path
|
||||
stroke-width="2"
|
||||
stroke-miterlimit="10"
|
||||
d="M28.822,16.386c1.354,3.219,0.898,7.064-1.5,9.924
|
||||
c-3.419,4.073-9.49,4.604-13.562,1.186c-4.073-3.417-4.604-9.49-1.187-13.562c1.987-2.368,4.874-3.54,7.74-3.433"
|
||||
/>
|
||||
<polygon points="26.137,12.748 27.621,19.464 28.9,16.741 31.898,16.503" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
21
src/components/atoms/RouteIcon.js
Normal file
21
src/components/atoms/RouteIcon.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
const RouteIcon = ({ isEnabled, toggleMapViews }) => {
|
||||
return (
|
||||
<button onClick={() => toggleMapViews("routes")}>
|
||||
<svg
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="30px"
|
||||
height="20px"
|
||||
viewBox="0 0 30 20"
|
||||
enableBackground="new 0 0 30 20"
|
||||
>
|
||||
<path d="M0.806,13.646h7.619c2.762,0,3-0.238,3-3v-0.414c0-2.762,0.301-3,3.246-3h14.523" />
|
||||
<polyline points="16.671,9.228 19.103,7.233 16.671,5.237 " />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouteIcon;
|
||||
16
src/components/atoms/SitesIcon.js
Normal file
16
src/components/atoms/SitesIcon.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
const SitesIcon = ({ isActive, isDisabled, onClickHandler }) => {
|
||||
let classes = isActive ? "action-button enabled" : "action-button";
|
||||
if (isDisabled) {
|
||||
classes = "action-button disabled";
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={classes} onClick={onClickHandler}>
|
||||
<i class="material-icons">location_on</i>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SitesIcon;
|
||||
12
src/components/atoms/Spinner.js
Normal file
12
src/components/atoms/Spinner.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
const Spinner = ({ small }) => {
|
||||
return (
|
||||
<div className={`spinner ${small ? "small" : ""}`}>
|
||||
<div className="double-bounce-overlay" />
|
||||
<div className="double-bounce" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
9
src/components/atoms/StaticPage.js
Normal file
9
src/components/atoms/StaticPage.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
const StaticPage = ({ showing, children }) => (
|
||||
<div className={`cover-container ${showing ? "showing" : ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default StaticPage;
|
||||
Reference in New Issue
Block a user