mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 21:08:36 +03:00
Remove design-system dependency
This commit is contained in:
232
src/components/controls/Card.js
Normal file
232
src/components/controls/Card.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useState } from "react";
|
||||
import CardText from "./atoms/Text";
|
||||
import CardTime from "./atoms/Time";
|
||||
import CardButton from "./atoms/Button";
|
||||
import CardCaret from "./atoms/Caret";
|
||||
import CardCustom from "./atoms/CustomField";
|
||||
import CardMedia from "./atoms/Media";
|
||||
|
||||
import { makeNiceDate, isEmptyString } from "../../common/utilities";
|
||||
import hash from "object-hash";
|
||||
|
||||
export const generateCardLayout = {
|
||||
basic: ({ event }) => {
|
||||
return [
|
||||
[
|
||||
{
|
||||
kind: "date",
|
||||
title: "Incident Date",
|
||||
value: event.datetime || event.date || ``,
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
title: "Location",
|
||||
value: event.location || `—`,
|
||||
},
|
||||
],
|
||||
[{ kind: "line-break", times: 0.4 }],
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
title: "Summary",
|
||||
value: event.description || ``,
|
||||
scaleFont: 1.1,
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
sourced: ({ event }) => {
|
||||
return [
|
||||
[
|
||||
{
|
||||
kind: "date",
|
||||
title: "Incident Date",
|
||||
value: event.datetime || event.date || ``,
|
||||
},
|
||||
{
|
||||
kind: "text",
|
||||
title: "Location",
|
||||
value: event.location || `—`,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
title: "Summary",
|
||||
value: event.description || ``,
|
||||
scaleFont: 1.1,
|
||||
},
|
||||
],
|
||||
...event.sources.flatMap((source, idx) => [
|
||||
[
|
||||
{
|
||||
kind: "text",
|
||||
title: `Source ${idx}`,
|
||||
value: source.description || ``,
|
||||
scaleFont: 1.1,
|
||||
},
|
||||
],
|
||||
source.paths.map((p) => ({
|
||||
kind: "media",
|
||||
title: "Media",
|
||||
value: [{ src: p, title: null }],
|
||||
})),
|
||||
]),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const Card = ({
|
||||
content = [],
|
||||
isLoading = true,
|
||||
onSelect = () => {},
|
||||
sources = [],
|
||||
isSelected = false,
|
||||
language = "en-US",
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const toggle = () => setIsOpen(!isOpen);
|
||||
|
||||
// NB: should be internationalized.
|
||||
const renderTime = (field) => (
|
||||
<CardTime
|
||||
language={language}
|
||||
timelabel={makeNiceDate(field.value)}
|
||||
{...field}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCaret = () =>
|
||||
sources.length === 0 && (
|
||||
<CardCaret toggle={() => toggle()} isOpen={isOpen} />
|
||||
);
|
||||
|
||||
const renderMedia = ({ media, idx }) => {
|
||||
return <CardMedia key={idx} src={media.src} title={media.title} />;
|
||||
};
|
||||
|
||||
function renderField(field) {
|
||||
switch (field.kind) {
|
||||
case "media":
|
||||
return (
|
||||
<div className="card-cell">
|
||||
{field.value.map((media, idx) => {
|
||||
return renderMedia({ media, idx });
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<div style={{ height: `1rem`, width: `100%` }}>
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
case "line-break":
|
||||
return (
|
||||
<div style={{ height: `${field.times || 1}rem`, width: `100%` }} />
|
||||
);
|
||||
case "item":
|
||||
// this is like a span
|
||||
return null;
|
||||
case "markdown":
|
||||
return <CardCustom {...field} />;
|
||||
case "tag":
|
||||
return (
|
||||
<div
|
||||
className="card-cell m0"
|
||||
style={{
|
||||
textTransform: `uppercase`,
|
||||
fontSize: `.8em`,
|
||||
lineHeight: `.8em`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: `flex-${field.align || `start`}`,
|
||||
}}
|
||||
>
|
||||
{field.value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "button":
|
||||
return (
|
||||
<div className="card-cell">
|
||||
{field.title && <h4>{field.title}</h4>}
|
||||
{/* <div className="card-row"> */}
|
||||
{field.value.map((t, idx) => (
|
||||
<CardButton key={`card-button-${idx}`} {...t} />
|
||||
))}
|
||||
{/* </div> */}
|
||||
</div>
|
||||
);
|
||||
case "text":
|
||||
return !isEmptyString(field.value) && <CardText {...field} />;
|
||||
case "date":
|
||||
return renderTime(field);
|
||||
case "links":
|
||||
return (
|
||||
<div className="card-cell">
|
||||
{field.title && <h4>{field.title}</h4>}
|
||||
<div className="card-row m0">
|
||||
{field.value.map(({ text, href }, idx) => (
|
||||
<a href={href} key={`card-links-url-${idx}`}>
|
||||
{text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "list":
|
||||
// Only render if some of the list's strings are non-empty
|
||||
const shouldFieldRender =
|
||||
!!field.value.length &&
|
||||
!!field.value.filter((s) => !isEmptyString(s)).length;
|
||||
return shouldFieldRender ? (
|
||||
// <div className="card-cell">
|
||||
<div>
|
||||
{field.title && <h4>{field.title}</h4>}
|
||||
<div className="card-row m0">
|
||||
{field.value.map((t, idx) => (
|
||||
<CardText key={`card-list-text-${idx}`} value={t} {...t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(row) {
|
||||
return (
|
||||
<div className="card-row" key={hash(row)}>
|
||||
{row.map((field) => (
|
||||
<span key={hash(field)}>{renderField(field)}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: render afterCaret appropriately from props
|
||||
sources = [];
|
||||
|
||||
return (
|
||||
<li
|
||||
key={hash(content)}
|
||||
className={`event-card ${isSelected ? "selected" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{content.map((row) => renderRow(row))}
|
||||
{isOpen && (
|
||||
<div className="card-bottomhalf">
|
||||
{sources.map(() => (
|
||||
<div className="card-row"></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sources.length > 0 ? renderCaret() : null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import {
|
||||
generateCardLayout,
|
||||
Card,
|
||||
} from "@forensic-architecture/design-system/dist/react";
|
||||
import { generateCardLayout, Card } from "./Card";
|
||||
|
||||
import * as selectors from "../../selectors";
|
||||
import { getFilterIdxFromColorSet } from "../../common/utilities";
|
||||
@@ -70,6 +67,7 @@ class CardStack extends React.Component {
|
||||
return events.map((event, idx) => {
|
||||
const thisRef = React.createRef();
|
||||
this.refs[idx] = thisRef;
|
||||
console.log(event);
|
||||
|
||||
const content = generateTemplate({
|
||||
event,
|
||||
|
||||
86
src/components/controls/atoms/Button.js
Normal file
86
src/components/controls/atoms/Button.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* Primary UI component for user interaction
|
||||
*/
|
||||
export const Button = ({
|
||||
primary,
|
||||
backgroundColor,
|
||||
borderRadius,
|
||||
size,
|
||||
label,
|
||||
normalCursor,
|
||||
...props
|
||||
}) => {
|
||||
const mode = primary ? "button--primary" : "button--secondary";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"button",
|
||||
`button--${size}`,
|
||||
mode,
|
||||
normalCursor ? "no-hover" : "",
|
||||
].join(" ")}
|
||||
style={{ backgroundColor: backgroundColor, borderRadius: borderRadius }}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
Button.propTypes = {
|
||||
/**
|
||||
* Is this the principal call to action on the page?
|
||||
*/
|
||||
primary: PropTypes.bool,
|
||||
/**
|
||||
* What background color to use
|
||||
*/
|
||||
backgroundColor: PropTypes.string,
|
||||
/**
|
||||
* How much rounded are they?
|
||||
*/
|
||||
borderRadius: PropTypes.string,
|
||||
/**
|
||||
* How large should the button be?
|
||||
*/
|
||||
size: PropTypes.oneOf(["small", "medium", "large"]),
|
||||
/**
|
||||
* Button contents
|
||||
*/
|
||||
label: PropTypes.string.isRequired,
|
||||
/**
|
||||
* Optional click handler
|
||||
*/
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
Button.defaultProps = {
|
||||
backgroundColor: "red",
|
||||
borderRadius: "0%",
|
||||
primary: false,
|
||||
size: "medium",
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
const CardButton = ({
|
||||
text,
|
||||
color = "#000",
|
||||
onClick = () => {},
|
||||
normalCursor,
|
||||
}) => (
|
||||
<Button
|
||||
size={"small"}
|
||||
backgroundColor={color}
|
||||
borderRadius={"12px"}
|
||||
primary={false}
|
||||
label={text}
|
||||
onClick={onClick}
|
||||
normalCursor={normalCursor}
|
||||
/>
|
||||
);
|
||||
|
||||
export default CardButton;
|
||||
15
src/components/controls/atoms/Caret.js
Normal file
15
src/components/controls/atoms/Caret.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
|
||||
const CardCaret = ({ isOpen, toggle }) => {
|
||||
let classes = isOpen ? "arrow-down" : "arrow-down folded";
|
||||
|
||||
return (
|
||||
<div className="card-toggle" onClick={toggle}>
|
||||
<p>
|
||||
<i className={classes} />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardCaret;
|
||||
12
src/components/controls/atoms/CustomField.js
Normal file
12
src/components/controls/atoms/CustomField.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import marked from "marked";
|
||||
|
||||
// TODO could this be a security vulnerability?
|
||||
const CardCustomField = ({ title, value }) => (
|
||||
<div className="card-cell">
|
||||
{title ? <h4>{title}</h4> : null}
|
||||
<div dangerouslySetInnerHTML={{ __html: marked(`${value}`) }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CardCustomField;
|
||||
61
src/components/controls/atoms/Media.js
Normal file
61
src/components/controls/atoms/Media.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { typeForPath } from "../../../common/utilities";
|
||||
|
||||
const TITLE_LENGTH = 50;
|
||||
// TODO should videos
|
||||
// - play inline
|
||||
// - appear zoomed out/in
|
||||
// - only show cover image and then lightbox when clicked
|
||||
// - show video control plane?
|
||||
// TODO landscape image doesn't fit in box properly
|
||||
const Media = ({ src, title }) => {
|
||||
const videoRef = useRef();
|
||||
const onVideoStart = useCallback(() => {
|
||||
return videoRef.current?.play();
|
||||
}, []);
|
||||
const onVideoStop = useCallback(() => {
|
||||
return videoRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
const type = typeForPath(src);
|
||||
const formattedTitle =
|
||||
title && title.length > TITLE_LENGTH
|
||||
? `${title.slice(0, TITLE_LENGTH + 1)}...`
|
||||
: title;
|
||||
|
||||
switch (type) {
|
||||
case "Video":
|
||||
return (
|
||||
<div className="card-cell media">
|
||||
{title && <h4 title={title}>{formattedTitle}</h4>}
|
||||
<video
|
||||
onMouseEnter={onVideoStart}
|
||||
onMouseLeave={onVideoStop}
|
||||
ref={videoRef}
|
||||
// controls
|
||||
// controlsList="nodownload noremoteplayback"
|
||||
disablePictureInPicture
|
||||
>
|
||||
<source src={src} />
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
case "Image":
|
||||
return (
|
||||
<div className="card-cell media">
|
||||
{title && <h4 title={title}>{formattedTitle}</h4>}
|
||||
<div className="img-wrapper">
|
||||
<img
|
||||
src={src}
|
||||
alt="an inline photograph for the event card component"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default Media;
|
||||
46
src/components/controls/atoms/Text.js
Normal file
46
src/components/controls/atoms/Text.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
const CardText = ({ title, value, hoverValue = null }) => {
|
||||
const [showHover, setShowHover] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="card-cell">
|
||||
{title ? <h4>{title}</h4> : null}
|
||||
<div
|
||||
style={{
|
||||
width: `fit-content`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseOver={() => hoverValue && setShowHover(true)}
|
||||
onMouseOut={() => hoverValue && setShowHover(false)}
|
||||
>
|
||||
{showHover ? (
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: `none`,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
<em>{hoverValue}</em>
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: `none`,
|
||||
display: `inline-block`,
|
||||
height: `1.1rem`,
|
||||
borderBottom: hoverValue && `1px rgb(235, 68, 62) dashed`,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* {!showHover && value} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardText;
|
||||
29
src/components/controls/atoms/Time.js
Normal file
29
src/components/controls/atoms/Time.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
import copy from "../../../common/data/copy.json";
|
||||
import { isNotNullNorUndefined } from "../../../common/utilities";
|
||||
|
||||
const CardTime = ({ title = "Timestamp", timelabel, language, precision }) => {
|
||||
const unknownLang = copy[language].cardstack.unknown_time;
|
||||
|
||||
if (isNotNullNorUndefined(timelabel)) {
|
||||
return (
|
||||
<div className="card-cell">
|
||||
{/* <i className="material-icons left">today</i> */}
|
||||
<h4>{title}</h4>
|
||||
{timelabel}
|
||||
{precision && precision !== "" ? ` - ${precision}` : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="card-cell">
|
||||
{/* <i className="material-icons left">today</i> */}
|
||||
<h4>{title}</h4>
|
||||
{unknownLang}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default CardTime;
|
||||
@@ -34,14 +34,14 @@ class TimelineAxis extends React.Component {
|
||||
if (this.props.scaleX) {
|
||||
this.x0 = d3
|
||||
.axisBottom(this.props.scaleX)
|
||||
.ticks(10)
|
||||
.ticks(5)
|
||||
.tickPadding(0)
|
||||
.tickSize(contentHeight - TEXT_HEIGHT - marginTop)
|
||||
.tickFormat(d3.timeFormat(fstFmt));
|
||||
|
||||
this.x1 = d3
|
||||
.axisBottom(this.props.scaleX)
|
||||
.ticks(10)
|
||||
.ticks(5)
|
||||
.tickPadding(marginTop)
|
||||
.tickSize(0)
|
||||
.tickFormat(d3.timeFormat(sndFmt));
|
||||
|
||||
Reference in New Issue
Block a user