mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-12 13:28:36 +03:00
@@ -328,6 +328,14 @@ export function toggleCover () {
|
||||
}
|
||||
}
|
||||
|
||||
export const UPDATE_SEARCH_QUERY = 'UPDATE_SEARCH_QUERY'
|
||||
export function updateSearchQuery (searchQuery) {
|
||||
return {
|
||||
type: UPDATE_SEARCH_QUERY,
|
||||
searchQuery
|
||||
}
|
||||
}
|
||||
|
||||
// ERRORS
|
||||
|
||||
export const FETCH_SOURCE_ERROR = 'FETCH_SOURCE_ERROR'
|
||||
|
||||
@@ -21,6 +21,7 @@ import TemplateCover from './TemplateCover'
|
||||
import colors from '../common/global'
|
||||
import { binarySearch, insetSourceFrom, findDescriptionInFilterTree } from '../common/utilities'
|
||||
import { isMobile } from 'react-device-detect'
|
||||
import Search from './Search.jsx'
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
constructor (props) {
|
||||
@@ -240,6 +241,7 @@ class Dashboard extends React.Component {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { actions, app, domain, ui, features } = this.props
|
||||
|
||||
@@ -326,6 +328,12 @@ class Dashboard extends React.Component {
|
||||
notifications={domain.notifications}
|
||||
onToggle={actions.markNotificationsRead}
|
||||
/>
|
||||
<Search
|
||||
narrative={app.narrative}
|
||||
queryString={app.searchQuery}
|
||||
events={domain.events}
|
||||
onSearchRowClick={this.handleSelect}
|
||||
/>
|
||||
{app.source ? (
|
||||
<MediaOverlay
|
||||
source={app.source}
|
||||
|
||||
@@ -92,6 +92,7 @@ class Map extends React.Component {
|
||||
firstLayer.addTo(map)
|
||||
|
||||
map.keyboard.disable()
|
||||
map.zoomControl.remove()
|
||||
|
||||
map.on('move zoomend viewreset moveend', () => this.alignLayers())
|
||||
map.on('zoomstart', () => { if (this.svgRef.current !== null) this.svgRef.current.classList.add('hide') })
|
||||
|
||||
76
src/components/Search.jsx
Normal file
76
src/components/Search.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import * as actions from '../actions'
|
||||
|
||||
import '../scss/search.scss'
|
||||
|
||||
import SearchRow from './SearchRow.jsx'
|
||||
|
||||
class Search extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
isFolded: true
|
||||
}
|
||||
this.onButtonClick = this.onButtonClick.bind(this)
|
||||
this.updateSearchQuery = this.updateSearchQuery.bind(this)
|
||||
}
|
||||
|
||||
onButtonClick () {
|
||||
this.setState(prevState => {
|
||||
return { isFolded: !prevState.isFolded }
|
||||
})
|
||||
}
|
||||
|
||||
updateSearchQuery (e) {
|
||||
let queryString = e.target.value
|
||||
this.props.actions.updateSearchQuery(queryString)
|
||||
}
|
||||
|
||||
render () {
|
||||
let searchResults
|
||||
|
||||
const searchAttributes = ['description', 'location', 'category', 'date']
|
||||
|
||||
if (!this.props.queryString) {
|
||||
searchResults = []
|
||||
} else {
|
||||
searchResults = this.props.events.filter(event =>
|
||||
searchAttributes.some(attribute => event[attribute].toLowerCase().includes(this.props.queryString.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={'search-outer-container' + (this.props.narrative ? ' narrative-mode ' : '')}>
|
||||
<div id='search-bar-icon-container' onClick={this.onButtonClick}>
|
||||
<i className='material-icons'>search</i>
|
||||
</div>
|
||||
<div class={'search-bar-overlay' + (this.state.isFolded ? ' folded' : '')}>
|
||||
<div class='search-input-container'>
|
||||
<input class='search-bar-input' onChange={this.updateSearchQuery} type='text' />
|
||||
<i id='close-search-overlay' className='material-icons' onClick={this.onButtonClick} >close</i>
|
||||
</div>
|
||||
<div class='search-results'>
|
||||
{searchResults.map(result => {
|
||||
return <SearchRow onSearchRowClick={this.props.onSearchRowClick} eventObj={result} query={this.props.queryString} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function mapDispatchToProps (dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(actions, dispatch)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => state,
|
||||
mapDispatchToProps
|
||||
)(Search)
|
||||
40
src/components/SearchRow.jsx
Normal file
40
src/components/SearchRow.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
|
||||
const SearchRow = ({ query, eventObj, onSearchRowClick }) => {
|
||||
const { description, location, date } = eventObj
|
||||
function getHighlightedText (text, highlight) {
|
||||
// Split text on highlight term, include term itself into parts, ignore case
|
||||
const parts = text.split(new RegExp(`(${highlight})`, 'gi'))
|
||||
return <span>{ parts.map(part => part.toLowerCase() === highlight.toLowerCase() ? <span style={{ backgroundColor: 'yellow', color: 'black' }}>{part}</span> : part) }</span>
|
||||
}
|
||||
|
||||
function getShortDescription (text, searchQuery) {
|
||||
var regexp = new RegExp(`(([^ ]* ){0,6}[a-zA-Z]*${searchQuery.toLowerCase()}[a-zA-Z]*( [^ ]*){0,5})`, 'gm')
|
||||
let parts = text.toLowerCase().match(regexp)
|
||||
for (var x = 0; x < (parts ? parts.length : 0); x++) {
|
||||
parts[x] = '...' + parts[x]
|
||||
}
|
||||
const firstLine = [text.match('(([^ ]* ){0,10})', 'm')[0]]
|
||||
return parts || firstLine
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='search-row' onClick={() => onSearchRowClick([eventObj])}>
|
||||
<div className='location-date-container'>
|
||||
<div className='date-container'>
|
||||
<i className='material-icons'>event</i>
|
||||
<p>{getHighlightedText(date, query)}</p>
|
||||
</div>
|
||||
<div className='location-container'>
|
||||
<i className='material-icons'>location_on</i>
|
||||
<p>{getHighlightedText(location, query)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p>{getShortDescription(description, query).map(match => {
|
||||
return <span>{getHighlightedText(match, query)}...<br /></span>
|
||||
})}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchRow
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
FETCH_ERROR,
|
||||
FETCH_SOURCE_ERROR,
|
||||
SET_LOADING,
|
||||
SET_NOT_LOADING
|
||||
SET_NOT_LOADING,
|
||||
UPDATE_SEARCH_QUERY
|
||||
} from '../actions'
|
||||
|
||||
function updateHighlighted (appState, action) {
|
||||
@@ -215,6 +216,13 @@ function setNotLoading (appState) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSearchQuery (appState, action) {
|
||||
return {
|
||||
...appState,
|
||||
searchQuery: action.searchQuery
|
||||
}
|
||||
}
|
||||
|
||||
function app (appState = initial.app, action) {
|
||||
switch (action.type) {
|
||||
case UPDATE_HIGHLIGHTED:
|
||||
@@ -259,6 +267,8 @@ function app (appState = initial.app, action) {
|
||||
return setLoading(appState)
|
||||
case SET_NOT_LOADING:
|
||||
return setNotLoading(appState)
|
||||
case UPDATE_SEARCH_QUERY:
|
||||
return updateSearchQuery(appState, action)
|
||||
default:
|
||||
return appState
|
||||
}
|
||||
|
||||
110
src/scss/search.scss
Normal file
110
src/scss/search.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
#search-bar-icon-container {
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
color: #a0a0a0;
|
||||
border: #a0a0a0 solid 0.1px;
|
||||
top: 10px;
|
||||
margin-left: 10px;
|
||||
height: 24px;
|
||||
padding: 10px;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar-overlay {
|
||||
background-color: black;
|
||||
height: 100vh;
|
||||
width: 400px;
|
||||
position: absolute;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.search-bar-input {
|
||||
width: 300px;
|
||||
margin: 20px;
|
||||
line-height: 40px;
|
||||
font-size: 15px;
|
||||
color: gray;
|
||||
padding-left: 15px;
|
||||
background: black;
|
||||
border: 1px solid #a0a0a0;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
#close-search-overlay {
|
||||
color: #a0a0a0;
|
||||
vertical-align: middle;
|
||||
font-size: 30px;
|
||||
transition: 0.2s ease;
|
||||
&:hover {
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.folded {
|
||||
left: -400px;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.search-outer-container {
|
||||
position: absolute;
|
||||
left: 110px;
|
||||
&.narrative-mode {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
color: black;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
background-color: #dfdfdf;
|
||||
transition: background-color 0.4s;
|
||||
border-bottom: 1px black solid;
|
||||
border-top: 1px black solid;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
&:hover {
|
||||
transition: background-color 0.4s;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
height: calc(100% - 332px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
div.location-date-container {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
div.location-date-container > div {
|
||||
width: 50%;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
div.location-date-container > div > p {
|
||||
display: inline;
|
||||
line-height: 17px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
div.location-date-container > div > i {
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
Reference in New Issue
Block a user