summaryrefslogtreecommitdiff
path: root/scraper/client/search
diff options
context:
space:
mode:
Diffstat (limited to 'scraper/client/search')
-rw-r--r--scraper/client/search/browse.component.js77
-rw-r--r--scraper/client/search/index.js17
-rw-r--r--scraper/client/search/panicButton.component.js49
-rw-r--r--scraper/client/search/search.actions.js162
-rw-r--r--scraper/client/search/search.container.js101
-rw-r--r--scraper/client/search/search.css230
-rw-r--r--scraper/client/search/search.menu.js96
-rw-r--r--scraper/client/search/search.meta.js68
-rw-r--r--scraper/client/search/search.query.js227
-rw-r--r--scraper/client/search/search.reducer.js84
-rw-r--r--scraper/client/search/search.results.js49
-rw-r--r--scraper/client/search/search.safety.js17
12 files changed, 1177 insertions, 0 deletions
diff --git a/scraper/client/search/browse.component.js b/scraper/client/search/browse.component.js
new file mode 100644
index 00000000..e9ddb04e
--- /dev/null
+++ b/scraper/client/search/browse.component.js
@@ -0,0 +1,77 @@
+import React, { Component } from 'react'
+import { Link, withRouter } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import { Loader, Keyframes, Video } from '../common'
+// import { Coco } from '../metadata'
+import * as searchActions from './search.actions'
+import * as metadataActions from '../metadata/metadata.actions'
+import SearchMeta from './search.meta'
+
+class Browse extends Component {
+ componentDidMount() {
+ this.browse()
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.match.params !== this.props.match.params) {
+ this.browse()
+ }
+ }
+
+ browse() {
+ const { hash } = this.props.match.params
+ if (hash) {
+ this.props.searchActions.browse(hash)
+ }
+ if (hash) {
+ this.props.metadataActions.fetchMetadata(hash)
+ }
+ }
+
+ render() {
+ const { browse, options } = this.props
+ console.log('browse', browse)
+
+ if (!browse || browse.reset || browse.loading) {
+ return <div className="browseComponent column"><h3>Loading keyframes...</h3><Loader /></div>
+ }
+ return (
+ <div className="browseComponent column">
+ <h3>Video Preview</h3>
+ <Video size={'md'} />
+ <SearchMeta query={browse} sugarcube />
+ <div className='row buttons'>
+ <Link
+ to={'/metadata/' + browse.hash}
+ className='btn'
+ >
+ View Full Metadata
+ </Link>
+ </div>
+ <h3>Keyframes</h3>
+ <Keyframes
+ frames={browse.frames}
+ showHash
+ showTimestamp
+ showSearchButton
+ showSaveButton
+ />
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ browse: state.search.browse,
+ options: state.search.options,
+ metadata: state.metadata,
+})
+
+const mapDispatchToProps = dispatch => ({
+ searchActions: bindActionCreators({ ...searchActions }, dispatch),
+ metadataActions: bindActionCreators({ ...metadataActions }, dispatch),
+})
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Browse))
diff --git a/scraper/client/search/index.js b/scraper/client/search/index.js
new file mode 100644
index 00000000..99c2b74b
--- /dev/null
+++ b/scraper/client/search/index.js
@@ -0,0 +1,17 @@
+import Menu from './search.menu'
+import Container from './search.container'
+import Meta from './search.meta'
+import Query from './search.query'
+import Results from './search.results'
+import Browse from './browse.component'
+
+import './search.css'
+
+export {
+ Menu,
+ Container,
+ Meta,
+ Query,
+ Results,
+ Browse,
+}
diff --git a/scraper/client/search/panicButton.component.js b/scraper/client/search/panicButton.component.js
new file mode 100644
index 00000000..a12c817b
--- /dev/null
+++ b/scraper/client/search/panicButton.component.js
@@ -0,0 +1,49 @@
+import React, { Component } from 'react'
+import { withRouter } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import * as actions from './search.actions'
+
+class PanicButton extends Component {
+ constructor() {
+ super()
+ this.keydown = this.keydown.bind(this)
+ }
+
+ componentDidMount() {
+ document.addEventListener('keydown', this.keydown)
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.keydown)
+ }
+
+ keydown(e) {
+ if (e.keyCode === 27) {
+ this.panic()
+ }
+ }
+
+ panic() {
+ this.props.actions.panic()
+ this.props.history.push('/search/')
+ }
+
+ render() {
+ return (
+ <button className='btn panic' onClick={() => this.panic()}>
+ <span>⚠</span> Panic
+ </button>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ panic: actions.panic }, dispatch)
+})
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(PanicButton))
diff --git a/scraper/client/search/search.actions.js b/scraper/client/search/search.actions.js
new file mode 100644
index 00000000..95cea433
--- /dev/null
+++ b/scraper/client/search/search.actions.js
@@ -0,0 +1,162 @@
+// import fetchJsonp from 'fetch-jsonp'
+import * as types from '../types'
+// import { hashPath } from '../util'
+import { store, history } from '../store'
+import { post, pad, verify, preloadImage } from '../util'
+import querystring from 'query-string'
+
+// urls
+
+const url = {
+ upload: () => process.env.API_HOST + '/search/api/upload',
+ search: () => process.env.API_HOST + '/search/api/fetch',
+ searchByVerifiedFrame: (verified, hash, frame) => process.env.API_HOST + '/search/api/search/' + verified + '/' + hash + '/' + pad(frame, 6),
+ searchByFrame: (hash, frame) => process.env.API_HOST + '/search/api/search/' + hash + '/' + pad(frame, 6),
+ browse: hash => process.env.API_HOST + '/search/api/list/' + hash,
+ random: () => process.env.API_HOST + '/search/api/random',
+ check: () => process.env.API_HOST + '/api/images/import/search',
+}
+export const publicUrl = {
+ browse: hash => '/search/browse/' + hash,
+ searchByVerifiedFrame: (verified, hash, frame) => '/search/keyframe/' + verify(verified) + '/' + hash + '/' + pad(frame, 6),
+ searchByFrame: (hash, frame) => '/search/keyframe/' + hash + '/' + pad(frame, 6),
+ review: () => '/search/review/'
+}
+
+// standard loading events
+
+const loading = (tag, offset) => ({
+ type: types.search.loading,
+ tag,
+ offset
+})
+const loaded = (tag, data, offset = 0) => ({
+ type: types.search.loaded,
+ tag,
+ data,
+ offset
+})
+const error = (tag, err) => ({
+ type: types.search.error,
+ tag,
+ err
+})
+
+// search UI functions
+
+export const panic = () => dispatch => {
+ history.push('/search/')
+ dispatch({ type: types.search.panic })
+}
+export const updateOptions = opt => dispatch => {
+ dispatch({ type: types.search.update_options, opt })
+}
+
+// API functions
+
+export const upload = (file, query) => dispatch => {
+ const { options } = store.getState().search
+ const tag = 'query'
+ const fd = new FormData()
+ fd.append('query_img', file)
+ fd.append('limit', options.perPage)
+ if (!query) {
+ dispatch(loading(tag))
+ }
+ post(url.upload(), fd)
+ .then(data => {
+ if (query) {
+ const { timing } = data.query
+ data.query = {
+ ...query,
+ timing,
+ }
+ let qs = {}
+ if (data.query.crop) {
+ let { x, y, w, h } = data.query.crop
+ qs.crop = [x, y, w, h].map(n => parseInt(n, 10)).join(',')
+ }
+ if (query.url && !query.hash) {
+ qs.url = query.url
+ }
+ // history.push(window.location.pathname + '#' + querystring.stringify(qs))
+ // window.location.hash = querystring.stringify(qs)
+ } else if (data.query.url && !window.location.search.match(data.query.url)) {
+ history.push('/search/?url=' + data.query.url)
+ }
+ dispatch(loaded(tag, data))
+ })
+ .catch(err => dispatch(error(tag, err)))
+}
+export const searchByVerifiedFrame = (verified, hash, frame, offset = 0) => dispatch => {
+ const { options } = store.getState().search
+ const tag = 'query'
+ dispatch(loading(tag, offset))
+ const qs = querystring.stringify({ limit: options.perPage, offset })
+ preloadImage({ verified, hash, frame })
+ fetch(url.searchByVerifiedFrame(verified, hash, frame) + '?' + qs, {
+ method: 'GET',
+ mode: 'cors',
+ })
+ .then(data => data.json())
+ .then(data => dispatch(loaded(tag, data, offset)))
+ .catch(err => dispatch(error(tag, err)))
+}
+export const searchByFrame = (hash, frame, offset = 0) => dispatch => {
+ const { options } = store.getState().search
+ const tag = 'query'
+ dispatch(loading(tag, offset))
+ const qs = querystring.stringify({ limit: options.perPage, offset })
+ preloadImage({ verified: false, hash, frame })
+ fetch(url.searchByFrame(hash, frame) + '?' + qs, {
+ method: 'GET',
+ mode: 'cors',
+ })
+ .then(data => data.json())
+ .then(data => dispatch(loaded(tag, data, offset)))
+ .catch(err => dispatch(error(tag, err)))
+}
+export const search = (uri, offset = 0) => dispatch => {
+ const { options } = store.getState().search
+ const tag = 'query'
+ dispatch(loading(tag, offset))
+ const qs = querystring.stringify({ url: uri, limit: options.perPage, offset })
+ if (uri.indexOf('static') === 0) {
+ preloadImage({ uri })
+ }
+ fetch(url.search(uri) + '?' + qs, {
+ method: 'GET',
+ mode: 'cors',
+ })
+ .then(data => data.json())
+ .then(data => dispatch(loaded(tag, data, offset)))
+ .catch(err => dispatch(error(tag, err)))
+}
+export const browse = hash => dispatch => {
+ const tag = 'browse'
+ dispatch(loading(tag))
+ fetch(url[tag](hash), {
+ method: 'GET',
+ mode: 'cors',
+ })
+ .then(data => data.json())
+ .then(data => dispatch(loaded(tag, data)))
+ .catch(err => dispatch(error(tag, err)))
+}
+export const random = () => dispatch => {
+ const { options } = store.getState().search
+ const qs = querystring.stringify({ limit: options.perPage })
+ const tag = 'query'
+ dispatch(loading(tag))
+ fetch(url.random() + '?' + qs, {
+ method: 'GET',
+ mode: 'cors',
+ })
+ .then(data => data.json())
+ .then(data => {
+ dispatch(loaded(tag, data))
+ history.push(publicUrl.searchByVerifiedFrame(data.query.verified, data.query.hash, data.query.frame))
+ // window.history.pushState(null, 'VSearch: Results', publicUrl.searchByVerifiedFrame(data.query.verified, data.query.hash, data.query.frame))
+ })
+ .catch(err => dispatch(error(tag, err)))
+}
diff --git a/scraper/client/search/search.container.js b/scraper/client/search/search.container.js
new file mode 100644
index 00000000..965d7c8e
--- /dev/null
+++ b/scraper/client/search/search.container.js
@@ -0,0 +1,101 @@
+import React, { Component } from 'react'
+import { withRouter } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import * as querystring from 'querystring'
+
+import * as searchActions from './search.actions'
+import * as metadataActions from '../metadata/metadata.actions'
+
+import SearchQuery from './search.query'
+import SearchResults from './search.results'
+import SearchSafety from './search.safety'
+
+class SearchContainer extends Component {
+ componentDidMount() {
+ const qs = querystring.parse(this.props.location.search.substr(1))
+ if (qs && qs.url) {
+ this.props.searchActions.search(qs.url)
+ } else {
+ this.searchByHash()
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.match.params !== this.props.match.params && JSON.stringify(this.props.match.params) !== JSON.stringify(prevProps.match.params)) {
+ this.searchByHash()
+ }
+ // const qsOld = querystring.parse(prevProps.location.search.substr(1))
+ // const qsNew = querystring.parse(this.props.location.search.substr(1))
+ // if (qsOld && qsNew && qsNew.url && qsNew.url !== qsOld.url) {
+ // this.props.actions.search(qsNew.url)
+ // }
+ }
+
+ searchByHash(offset = 0) {
+ const { verified, hash, frame } = this.props.match.params
+ if (verified && hash && frame) {
+ this.props.searchActions.searchByVerifiedFrame(verified, hash, frame, offset)
+ } else if (hash && frame) {
+ this.props.searchActions.searchByFrame(hash, frame, offset)
+ }
+ if (hash && !offset) {
+ this.props.metadataActions.fetchMetadata(hash)
+ }
+ }
+
+ searchByOffset() {
+ const offset = this.props.query.results.length
+ const qs = querystring.parse(this.props.location.search.substr(1))
+ if (qs && qs.url) {
+ this.props.searchActions.search(qs.url, offset)
+ }
+ else {
+ this.searchByHash(offset)
+ }
+ }
+
+ render() {
+ const { query, results, loadingMore } = this.props.query
+ const options = this.props.options
+ // console.log('search container', query, results, loadingMore)
+ let showLoadMore = true
+ if (!query || query.reset || query.loading || !results || !results.length) {
+ showLoadMore = false
+ }
+ let isWide = (results && results.length > Math.min(options.perPage, 30))
+ let isMoreLoaded = (results && results.length > options.perPage)
+ return (
+ <div className='searchContainer'>
+ <SearchQuery />
+ <SearchResults />
+ {showLoadMore
+ ? !loadingMore
+ ? <button
+ onClick={() => this.searchByOffset()}
+ className={isWide ? 'btn loadMore wide' : 'btn loadMore'}
+ >
+ Load more
+ </button>
+ : <div className='loadingMore'>{'Loading more results...'}</div>
+ : <div>
+ </div>
+ }
+ {!isMoreLoaded && <SearchSafety />}
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ query: state.search.query,
+ options: state.search.options,
+ metadata: state.metadata,
+})
+
+const mapDispatchToProps = dispatch => ({
+ searchActions: bindActionCreators({ ...searchActions }, dispatch),
+ metadataActions: bindActionCreators({ ...metadataActions }, dispatch),
+})
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SearchContainer))
diff --git a/scraper/client/search/search.css b/scraper/client/search/search.css
new file mode 100644
index 00000000..280e53fe
--- /dev/null
+++ b/scraper/client/search/search.css
@@ -0,0 +1,230 @@
+.btn span {
+ font-size: large;
+}
+.row {
+ display: flex;
+ flex-direction: row;
+}
+.column {
+ display: flex;
+ flex-direction: column;
+}
+
+.searchContainer h3 {
+ padding: 0;
+ margin-top: 0;
+ margin-bottom: 5px;
+ margin-left: 3px;
+}
+.searchContainer h4 {
+ margin-left: 0;
+ width: 100%;
+}
+.searchContainer .subtitle {
+ display: block;
+ margin-left: 3px;
+ margin-bottom: 10px;
+}
+.searchForm {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ padding: 20px;
+ background: #eee;
+}
+.searchForm .row {
+ align-items: center;
+}
+
+.searchMeta {
+ display: flex;
+ flex-direction: column;
+ font-size: 14px;
+ line-height: 18px;
+ padding: 0;
+}
+.searchMeta span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: calc(100vw - 23px - 640px - 30px);
+}
+
+.keyframe .thumbnail {
+ position: relative;
+ cursor: pointer;
+}
+.keyframe .searchButtons {
+ position: absolute;
+ bottom: 0; left: 0;
+ padding: 0 5px 15px 5px;
+ width: 100%;
+ text-align: center;
+ opacity: 0;
+ transition: all 0.2s;
+}
+.desktop .keyframe .thumbnail:hover .searchButtons,
+.mobile .keyframe .searchButtons {
+ opacity: 1;
+}
+.keyframe .searchButtons .btn {
+ margin-right: 0;
+ height: auto;
+ padding: 0.15rem 0.3rem;
+}
+.keyframe a {
+ text-decoration: none;
+}
+
+.body > div.searchForm {
+ padding-bottom: 20px;
+}
+.upload {
+ position: relative;
+ cursor: pointer;
+}
+.upload .btn {
+ pointer-events: none;
+ cursor: pointer;
+}
+.upload input {
+ position: absolute;
+ top: 0; left: 0;
+ width: 100%; height: 100%;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.reviewSaved,
+.browseComponent,
+.searchQuery {
+ margin: 0px 10px;
+ padding: 13px;
+}
+.searchQuery img {
+ cursor: crosshair;
+ user-select: none;
+ max-width: 640px;
+ max-height: 480px;
+}
+.searchContainer .searchQuery h3 {
+ margin-left: 0;
+ margin-bottom: 10px;
+}
+
+.searchBox {
+ min-width: 640px;
+ margin: 0 10px 0 0;
+ background-color: #eee;
+ position: relative;
+}
+.searchBox img {
+ display: block;
+}
+.searchBox .box {
+ position: absolute;
+ cursor: crosshair;
+ border: 1px solid #11f;
+ background-color: rgba(16,16,255,0.1);
+}
+
+.searchResults {
+ margin: 0 20px 20px 20px;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+}
+.searchResultsHeading {
+ width: 100%;
+}
+.searchOptions .row {
+ font-size: 12px;
+ margin-left: 10px;
+}
+.searchOptions input {
+ font-size: 12px;
+ margin-right: 5px;
+ font-family: Helvetica, sans-serif;
+}
+.searchOptions input[type=text],
+.searchOptions input[type=number] {
+ width: 30px;
+ text-align: right;
+}
+.keyframeGroup {
+ max-width: 650px;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ align-content: flex-start;
+ justify-content: flex-start;
+}
+.keyframeGroup h4 a {
+ color: #888;
+ text-decoration: none
+}
+.keyframeGroup h4 a:hover {
+ text-decoration: underline
+}
+
+/* load more button that gets bigger */
+
+.loadMore {
+ width: 400px;
+ margin: 20px;
+ height: 40px;
+ transition: all;
+}
+.loadMore.wide {
+ width: calc(100% - 40px);
+ margin: 20px;
+ height: 100px;
+}
+.loadingMore {
+ margin: 20px 20px 200px 20px;
+}
+
+/* health and safety warning */
+
+.safety div {
+ display: inline-block;
+ margin: 20px 20px;
+ padding: 10px;
+ background: #fff8e8;
+ color: #111;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+ font-size: 13px;
+ line-height: 1.4;
+}
+.safety ul {
+ margin: 0;
+ padding: 0 21px;
+}
+.safety li {
+ padding: 1px 0 0 0;
+}
+.safety h4 {
+ margin-top: 5px;
+}
+
+/* browser section */
+
+.browseComponent h3 {
+ margin-bottom: 10px;
+}
+.browseComponent .buttons {
+ margin-top: 10px;
+}
+
+/* disable twiddle button on input[type=number] */
+
+input::-webkit-outer-spin-button,
+input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+input[type='number'] {
+ -moz-appearance:textfield;
+}
diff --git a/scraper/client/search/search.menu.js b/scraper/client/search/search.menu.js
new file mode 100644
index 00000000..f5f9423e
--- /dev/null
+++ b/scraper/client/search/search.menu.js
@@ -0,0 +1,96 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+
+import * as actions from './search.actions'
+import PanicButton from './panicButton.component'
+
+class SearchMenu extends Component {
+ upload(e) {
+ const files = e.dataTransfer ? e.dataTransfer.files : e.target.files
+ let i
+ let file
+ for (i = 0; i < files.length; i++) {
+ file = files[i]
+ if (file && file.type.match('image.*')) break
+ }
+ if (!file) return
+ this.props.actions.upload(file)
+ }
+
+ random() {
+ this.props.actions.random()
+ }
+
+ render() {
+ const { savedCount, options } = this.props
+ return (
+ <div className="searchForm row">
+ <div className='row'>
+ <div className='upload'>
+ <button className='btn'><span>⤴</span> Search by Upload</button>
+ <input
+ type="file"
+ name="img"
+ accept="image/*"
+ onChange={this.upload.bind(this)}
+ required
+ />
+ </div>
+ <button className='btn random' onClick={this.random.bind(this)}><span>♘</span> Random</button>
+ <PanicButton />
+ <Link to={actions.publicUrl.review()}>
+ <button className='btn btn-primary'><span>⇪</span>{
+ ' ' + savedCount + ' Saved Image' + (savedCount === 1 ? '' : 's')
+ }</button>
+ </Link>
+ </div>
+
+ <div className='row searchOptions'>
+ <select
+ className='form-select'
+ onChange={e => this.props.actions.updateOptions({ thumbnailSize: e.target.value })}
+ value={options.thumbnailSize}
+ >
+ <option value='th'>Thumbnail</option>
+ <option value='sm'>Small</option>
+ <option value='md'>Medium</option>
+ <option value='lg'>Large</option>
+ </select>
+ <label className='row'>
+ <input
+ type='checkbox'
+ checked={options.groupByHash}
+ onChange={e => this.props.actions.updateOptions({ groupByHash: e.target.checked })}
+ />
+ {' Group by hash'}
+ </label>
+ <label className='row'>
+ <input
+ type='number'
+ value={options.perPage}
+ className='perPage'
+ min={1}
+ max={100}
+ onChange={e => this.props.actions.updateOptions({ perPage: e.target.value })}
+ onBlur={() => window.location.reload()}
+ />
+ {' per page'}
+ </label>
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ options: state.search.options,
+ savedCount: state.review.count,
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ ...actions }, dispatch)
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchMenu)
diff --git a/scraper/client/search/search.meta.js b/scraper/client/search/search.meta.js
new file mode 100644
index 00000000..b4eaeaad
--- /dev/null
+++ b/scraper/client/search/search.meta.js
@@ -0,0 +1,68 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import { format } from 'date-fns'
+
+import { timestamp } from '../util'
+import * as searchActions from './search.actions'
+
+class SearchMeta extends Component {
+ render() {
+ const { query, metadata, sugarcube } = this.props
+ if (!query || !metadata || !metadata.mediainfo || metadata.metadata === 'loading') {
+ return <div className='searchMeta'></div>
+ }
+ const sugarcubeId = metadata.mediainfo.sugarcube_id
+ const { video } = metadata.mediainfo.metadata.mediainfo
+ const { x, y, w, h } = query.crop || {}
+ return (
+ <div className='searchMeta'>
+ {'verified' in query &&
+ <span className={query.verified ? 'verified' : 'unverified'}>
+ {query.verified ? 'verified' : 'unverified'}
+ </span>
+ }
+ {query.hash &&
+ <span>
+ {'sha256: '}
+ <Link className="sha256" to={searchActions.publicUrl.browse(query.hash)}>{query.hash}</Link>
+ </span>
+ }
+ {query.frame &&
+ <span>
+ {'Frame: '}
+ {timestamp(query.frame, video.frame_rate)}
+ {' / '}
+ {timestamp(video.duration / 1000, 1)}
+ </span>
+ }
+ {query.crop &&
+ <span>
+ {'Crop: '}{parseInt(w, 10) + 'x' + parseInt(h, 10) + ' @ (' + parseInt(x, 10) + ', ' + parseInt(y, 10) + ')'}
+ </span>
+ }
+ {!!(video && video.encoded_date) &&
+ <span>
+ {'Date: '}{format(new Date(video.encoded_date), "DD-MMM-YYYY")}
+ </span>
+ }
+ {!!(sugarcube && sugarcubeId) &&
+ <span>
+ sugarcube: {sugarcubeId}
+ </span>
+ }
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ metadata: state.metadata,
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ ...searchActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchMeta)
diff --git a/scraper/client/search/search.query.js b/scraper/client/search/search.query.js
new file mode 100644
index 00000000..276f1943
--- /dev/null
+++ b/scraper/client/search/search.query.js
@@ -0,0 +1,227 @@
+import React, { Component } from 'react'
+import { Link } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import toBlob from 'data-uri-to-blob'
+
+import { clamp, px } from '../util'
+import { Loader } from '../common'
+
+import * as searchActions from './search.actions'
+import SearchMeta from './search.meta'
+
+const defaultState = {
+ dragging: false,
+ draggingBox: false,
+ bounds: null,
+ mouseX: 0,
+ mouseY: 0,
+ box: {
+ x: 0,
+ y: 0,
+ w: 0,
+ h: 0,
+ }
+}
+
+class SearchQuery extends Component {
+ state = {
+ ...defaultState
+ }
+
+ constructor() {
+ super()
+ this.handleMouseDown = this.handleMouseDown.bind(this)
+ this.handleMouseDownOnBox = this.handleMouseDownOnBox.bind(this)
+ this.handleMouseMove = this.handleMouseMove.bind(this)
+ this.handleMouseUp = this.handleMouseUp.bind(this)
+ }
+
+ componentDidMount() {
+ document.body.addEventListener('mousemove', this.handleMouseMove)
+ document.body.addEventListener('mouseup', this.handleMouseUp)
+ }
+
+ componentDidUpdate(prevProps) {
+ // console.log(this.props.query.query, !prevProps.query.query)
+ if (this.state.bounds && (!this.props.query.query || !prevProps.query.query || this.props.query.query.url !== prevProps.query.query.url)) {
+ this.setState({ ...defaultState })
+ }
+ }
+
+ componentWillUnmount() {
+ document.body.removeEventListener('mousemove', this.handleMouseMove)
+ document.body.removeEventListener('mouseup', this.handleMouseUp)
+ }
+
+ handleMouseDown(e) {
+ e.preventDefault()
+ const bounds = this.imgRef.getBoundingClientRect()
+ const mouseX = e.pageX
+ const mouseY = e.pageY
+ const x = mouseX - bounds.left
+ const y = mouseY - bounds.top
+ const w = 1
+ const h = 1
+ this.setState({
+ dragging: true,
+ bounds,
+ mouseX,
+ mouseY,
+ box: {
+ x, y, w, h,
+ }
+ })
+ }
+
+ handleMouseDownOnBox(e) {
+ const bounds = this.imgRef.getBoundingClientRect()
+ const mouseX = e.pageX
+ const mouseY = e.pageY
+ this.setState({
+ draggingBox: true,
+ bounds,
+ mouseX,
+ mouseY,
+ initialBox: {
+ ...this.state.box
+ },
+ box: {
+ ...this.state.box
+ }
+ })
+ }
+
+ handleMouseMove(e) {
+ const {
+ dragging, draggingBox,
+ bounds, mouseX, mouseY, initialBox, box
+ } = this.state
+ if (dragging) {
+ e.preventDefault()
+ let { x, y } = box
+ let w = clamp(e.pageX - mouseX, 0, bounds.width - x)
+ let h = clamp(e.pageY - mouseY, 0, bounds.height - y)
+ this.setState({
+ box: {
+ x, y, w, h,
+ }
+ })
+ } else if (draggingBox) {
+ e.preventDefault()
+ let { x, y, w, h } = initialBox
+ let dx = (e.pageX - mouseX)
+ let dy = (e.pageY - mouseY)
+ this.setState({
+ box: {
+ x: clamp(x + dx, 0, bounds.width - w),
+ y: clamp(y + dy, 0, bounds.height - h),
+ w,
+ h,
+ }
+ })
+ }
+ }
+
+ handleMouseUp(e) {
+ const { actions } = this.props
+ const { dragging, draggingBox, bounds, box } = this.state
+ if (!dragging && !draggingBox) return
+ e.preventDefault()
+ const { x, y, w, h } = box
+ // console.log(x, y, w, h)
+ const img = this.imgRef
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ const ratio = img.naturalWidth / bounds.width
+ canvas.width = w * ratio
+ canvas.height = h * ratio
+ if (w < 10 || h < 10) {
+ this.setState({ dragging: false, draggingBox: false, box: { x: 0, y: 0, w: 0, h: 0 } })
+ return
+ }
+ this.setState({ dragging: false, draggingBox: false })
+ // query_div.appendChild(canvas)
+ const newImage = new Image()
+ let loaded = false
+ newImage.onload = () => {
+ if (loaded) return
+ loaded = true
+ newImage.onload = null
+ ctx.drawImage(
+ newImage,
+ Math.round(x * ratio),
+ Math.round(y * ratio),
+ Math.round(w * ratio),
+ Math.round(h * ratio),
+ 0, 0, canvas.width, canvas.height
+ )
+ const blob = toBlob(canvas.toDataURL('image/jpeg', 0.9))
+ actions.upload(blob, {
+ ...this.props.query.query,
+ crop: {
+ x, y, w, h,
+ }
+ })
+ }
+ // console.log(img.src)
+ newImage.crossOrigin = 'anonymous'
+ newImage.src = img.src
+ if (newImage.complete) {
+ newImage.onload()
+ }
+ }
+
+ render() {
+ const { query } = this.props.query
+ const { box } = this.state
+ const { x, y, w, h } = box
+ if (!query) return null
+ if (query.loading) {
+ return <div className="searchQuery column"><h2>Loading results...</h2><Loader /></div>
+ }
+ let { url } = query
+ if (url && url.indexOf('static') === 0) {
+ url = '/search/' + url
+ }
+ return (
+ <div className="searchQuery row">
+ <div className="searchBox">
+ <img
+ src={url}
+ ref={ref => this.imgRef = ref}
+ onMouseDown={this.handleMouseDown}
+ crossOrigin='anonymous'
+ />
+ {!!w &&
+ <div
+ className="box"
+ style={{
+ left: x,
+ top: y,
+ width: w,
+ height: h,
+ }}
+ onMouseDown={this.handleMouseDownOnBox}
+ />
+ }
+ </div>
+ <div>
+ <h3>Your Query</h3>
+ <SearchMeta query={query} />
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ query: state.search.query,
+ options: state.search.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+ actions: bindActionCreators({ ...searchActions }, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchQuery)
diff --git a/scraper/client/search/search.reducer.js b/scraper/client/search/search.reducer.js
new file mode 100644
index 00000000..b9de60bd
--- /dev/null
+++ b/scraper/client/search/search.reducer.js
@@ -0,0 +1,84 @@
+import * as types from '../types'
+import session from '../session'
+
+const initialState = () => ({
+ query: { reset: true },
+ browse: { reset: true },
+ options: {
+ thumbnailSize: session('thumbnailSize') || 'th',
+ perPage: parseInt(session('perPage'), 10) || 50,
+ groupByHash: session('groupByHash'),
+ }
+})
+
+const loadingState = {
+ query: {
+ query: { loading: true },
+ results: []
+ },
+ loading: {
+ loading: true
+ }
+}
+
+export default function searchReducer(state = initialState(), action) {
+ // console.log(action.type, action)
+ switch (action.type) {
+ case types.search.loading:
+ if (action.tag === 'query' && action.offset) {
+ return {
+ ...state,
+ query: {
+ ...state.query,
+ loadingMore: true,
+ }
+ }
+ }
+ return {
+ ...state,
+ [action.tag]: loadingState[action.tag] || loadingState.loading,
+ }
+
+ case types.search.loaded:
+ if (action.tag === 'query' && action.offset) {
+ return {
+ ...state,
+ query: {
+ query: action.data.query,
+ results: [
+ ...state.query.results,
+ ...action.data.results,
+ ],
+ loadingMore: false,
+ }
+ }
+ }
+ return {
+ ...state,
+ [action.tag]: action.data,
+ }
+
+ case types.search.error:
+ return {
+ ...state,
+ [action.tag]: { error: action.err },
+ }
+
+ case types.search.panic:
+ return {
+ ...initialState(),
+ }
+
+ case types.search.update_options:
+ session.setAll(action.opt)
+ return {
+ ...state,
+ options: {
+ ...action.opt,
+ }
+ }
+
+ default:
+ return state
+ }
+}
diff --git a/scraper/client/search/search.results.js b/scraper/client/search/search.results.js
new file mode 100644
index 00000000..8b9e0c5e
--- /dev/null
+++ b/scraper/client/search/search.results.js
@@ -0,0 +1,49 @@
+import React, { Component } from 'react'
+import { Link, withRouter } from 'react-router-dom'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import * as querystring from 'querystring'
+
+import { Keyframes } from '../common'
+import * as searchActions from './search.actions'
+
+function SearchResults({ query, results, options }) {
+ if (!query || query.reset || query.loading || !results) {
+ return <div></div>
+ }
+ if (!query.loading && !results.length) {
+ return <div className='searchResults'><h3>No results</h3></div>
+ }
+ return (
+ <div className="searchResults">
+ <div className='searchResultsHeading row'>
+ <div className='column'>
+ <h3>Search Results</h3>
+ <small className="subtitle">
+ {'Searched 10,523,176 frames from 576,234 videos (took '}{query.timing.toFixed(2)}{' ms)'}
+ </small>
+ </div>
+ </div>
+ <Keyframes
+ frames={results}
+ showHash
+ showTimestamp={options.groupByHash}
+ showSearchButton
+ showSaveButton
+ groupByHash={options.groupByHash}
+ />
+ </div>
+ )
+}
+
+const mapStateToProps = state => ({
+ query: state.search.query.query,
+ results: state.search.query.results,
+ options: state.search.options,
+})
+
+const mapDispatchToProps = dispatch => ({
+ searchActions: bindActionCreators({ ...searchActions }, dispatch),
+})
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SearchResults))
diff --git a/scraper/client/search/search.safety.js b/scraper/client/search/search.safety.js
new file mode 100644
index 00000000..b4f92664
--- /dev/null
+++ b/scraper/client/search/search.safety.js
@@ -0,0 +1,17 @@
+import React from 'react'
+
+export default function SearchWarning() {
+ return (
+ <div className='safety'>
+ <div>
+ <h4>Safety Tips</h4>
+ <ul>
+ <li> Look away if you see something traumatic </li>
+ <li> Hit <tt>ESC</tt> to activate panic mode (hides all images) </li>
+ <li> Use thumbnails to reduce details </li>
+ <li> Take breaks and refresh yourself with positive imagery </li>
+ </ul>
+ </div>
+ </div>
+ )
+}