summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scraper/.gitignore2
-rw-r--r--scraper/client/actions.js23
-rw-r--r--scraper/client/app.js47
-rw-r--r--scraper/client/common/activeLink.component.js16
-rw-r--r--scraper/client/common/common.css347
-rw-r--r--scraper/client/common/footer.component.js10
-rw-r--r--scraper/client/common/gate.component.js21
-rw-r--r--scraper/client/common/header.component.js19
-rw-r--r--scraper/client/common/index.js22
-rw-r--r--scraper/client/common/loader.component.js10
-rw-r--r--scraper/client/common/sidebar.component.js25
-rw-r--r--scraper/client/common/table.component.js121
-rw-r--r--scraper/client/index.js19
-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
-rw-r--r--scraper/client/session.js5
-rw-r--r--scraper/client/store.js64
-rw-r--r--scraper/client/types.js13
-rw-r--r--scraper/client/util.js149
29 files changed, 2090 insertions, 0 deletions
diff --git a/scraper/.gitignore b/scraper/.gitignore
index 868c3dd4..f1ce9662 100644
--- a/scraper/.gitignore
+++ b/scraper/.gitignore
@@ -2,3 +2,5 @@ datasets/s2
datasets/old
datasets/scholar_entries.numbers
datasets/scholar_entries.csv
+reports/geocode-app.js
+
diff --git a/scraper/client/actions.js b/scraper/client/actions.js
new file mode 100644
index 00000000..f281a34c
--- /dev/null
+++ b/scraper/client/actions.js
@@ -0,0 +1,23 @@
+import { get, post } from './util'
+import * as types from './types'
+
+export const api = (dispatch, method, tag, url, params) => {
+ dispatch({ type: types.api.loading, tag })
+ get(url, params).then(data => {
+ dispatch({ type: types.api.loaded, tag, data })
+ }).catch(err => {
+ dispatch({ type: types.api.error, tag, err })
+ })
+}
+
+export const getInstitutions = () => dispatch => {
+ api(dispatch, get, 'institutions', '/api/institutions', {})
+}
+
+export const getPapers = () => dispatch => {
+ api(dispatch, get, 'papers', '/api/papers', {})
+}
+
+export const postAddress = data => dispatch => {
+ api(dispatch, post, 'address', '/api/address', data)
+}
diff --git a/scraper/client/app.js b/scraper/client/app.js
new file mode 100644
index 00000000..a115041b
--- /dev/null
+++ b/scraper/client/app.js
@@ -0,0 +1,47 @@
+import React, { Component } from 'react'
+import { ConnectedRouter } from 'connected-react-router'
+import { Route, Switch } from 'react-router'
+
+import { Header, Sidebar, Footer } from './common'
+// import * as Metadata from './metadata'
+// import * as Search from './search'
+// import * as Review from './review'
+
+export default class App extends Component {
+ render() {
+ return (
+ <ConnectedRouter history={this.props.history}>
+ <div>
+ <Header />
+ <div className='app'>
+ <Route path="/metadata/" component={Sidebar} />
+ <div className='body'>
+ </div>
+ </div>
+ <Footer />
+ </div>
+ </ConnectedRouter>
+ )
+ }
+}
+
+ // <Route path="/search/" component={Search.Menu} />
+ // <Route path="/metadata/:hash/" component={Metadata.Heading} />
+ // <Switch>
+ // <Route exact path="/metadata/:hash/summary/" component={Metadata.Summary} />
+ // <Route exact path="/metadata/:hash/mediaRecord/" component={Metadata.MediaRecord} />
+ // <Route exact path="/metadata/:hash/mediaInfo/" component={Metadata.MediaInfo} />
+ // <Route exact path="/metadata/:hash/keyframe/:frame/" component={Metadata.KeyframeSingle} />
+ // <Route exact path="/metadata/:hash/keyframe/" component={Metadata.KeyframeList} />
+ // <Route exact path="/metadata/:hash/coco/" component={Metadata.Coco} />
+ // <Route exact path="/metadata/:hash/places365/" component={Metadata.Places365} />
+ // <Route exact path="/metadata/:hash/sugarcube/" component={Metadata.Sugarcube} />
+ // <Route exact path="/metadata/:hash/" component={Metadata.Summary} />
+ // <Route exact path="/metadata/" render={() => <div className='notFound'><h4>NOT FOUND</h4></div>} />
+ // <Route exact path="/search/" component={Search.Container} />
+ // <Route exact path="/search/keyframe/:verified/:hash/:frame/" component={Search.Container} />
+ // <Route exact path="/search/keyframe/:hash/:frame/" component={Search.Container} />
+ // <Route exact path="/search/browse/:hash/" component={Search.Browse} />
+ // <Route exact path="/search/random/" component={Search.Random} />
+ // <Route exact path="/search/review/" component={Review.Saved} />
+ // </Switch>
diff --git a/scraper/client/common/activeLink.component.js b/scraper/client/common/activeLink.component.js
new file mode 100644
index 00000000..59f63881
--- /dev/null
+++ b/scraper/client/common/activeLink.component.js
@@ -0,0 +1,16 @@
+import React from 'react'
+import { NavLink } from 'react-router-dom'
+
+export default function ActiveLink({
+ to,
+ className = 'navlink',
+ children
+}) {
+ return (
+ <span className={className}>
+ <NavLink to={to}>
+ {children}
+ </NavLink>
+ </span>
+ )
+}
diff --git a/scraper/client/common/common.css b/scraper/client/common/common.css
new file mode 100644
index 00000000..4b939df0
--- /dev/null
+++ b/scraper/client/common/common.css
@@ -0,0 +1,347 @@
+/* css boilerplate */
+
+* { box-sizing: border-box; }
+html,body {
+ margin: 0; padding: 0;
+ width: 100%; height: 100%;
+}
+body {
+ font-family: Helvetica, sans-serif;
+ font-weight: 300;
+}
+
+h1 {
+
+}
+h2 {
+ font-weight: normal;
+ margin: 10px 0;
+ padding: 3px;
+ font-size: 24px;
+}
+h3 {
+ font-weight: normal;
+ margin: 10px 0 0 0;
+ padding: 3px;
+ font-size: 18px;
+}
+h4 {
+ font-weight: 300;
+ font-size: 12px;
+ letter-spacing: 2px;
+ color: #888;
+ text-transform: uppercase;
+ margin: 5px 10px;
+ margin-top: 20px;
+}
+h4:first-child {
+ margin-top: 10px;
+}
+
+.app {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: flex-start;
+}
+
+/* header stuff */
+
+header {
+ width: 100%;
+ background: #11f;
+ color: white;
+ align-items: stretch;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ z-index: 3;
+}
+header > section {
+ justify-content: flex-start;
+ align-items: center;
+ display: flex;
+ flex: 1 0;
+ font-weight: bold;
+}
+header > section:last-of-type {
+ justify-content: flex-end;
+}
+
+/* sidebar / body columns */
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ height: 100%;
+ float: left;
+ width: 200px;
+ flex: 0 0 200px;
+ padding: 10px;
+ margin-right: 10px;
+}
+.sidebar a {
+ display: block;
+ padding: 10px 10px;
+ text-decoration: none;
+ color: #444;
+}
+.sidebar a.active {
+ font-weight: bold;
+ color: #222;
+}
+.body {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ flex-grow: 1;
+}
+.body > div {
+ padding-bottom: 40px;
+}
+
+/* buttons / forms */
+
+.btn:focus, .btn:hover {
+ background: #f1f1fc;
+ color: #4b48d6 !important;
+ text-decoration: none;
+}
+.btn {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background: #fff;
+ border: .05rem solid;
+ border-radius: 2px;
+ margin-right: 5px;
+ color: #11f;
+ cursor: pointer;
+ display: inline-block;
+ font-size: .8rem;
+ height: 1.8rem;
+ line-height: 1rem;
+ outline: none;
+ padding: .35rem .4rem;
+ text-align: center;
+ text-decoration: none;
+ -webkit-transition: all .2s ease;
+ -o-transition: all .2s ease;
+ transition: all .2s ease;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ vertical-align: middle;
+ white-space: nowrap;
+}
+.btn.reset,
+.btn.panic {
+ color: #b00;
+}
+.btn.btn-primary {
+ background: #11f;
+ border-color: #11f;
+ color: white;
+}
+.btn[disabled] {
+ color: #bbb !important;
+ border-color: #bbb !important;
+ background: white !important;
+ cursor: default;
+}
+.btn.btn-primary:focus,
+.btn.btn-primary:hover {
+ background: #0808ee;
+ color: white !important;
+}
+.row .btn {
+ margin: 0 5px 0 0;
+}
+input[type=text] {
+ border: 1px solid #888;
+ padding: 4px;
+ font-size: 15px;
+}
+
+
+/* tables on metadata pages */
+
+table {
+ border: 0;
+ margin: 0;
+ padding: 0;
+ border-spacing: 0;
+}
+.tableObject td,
+.tableObject th {
+ padding: 3px;
+ vertical-align: top;
+}
+.tableObject hr {
+ width: 100%;
+ color: transparent;
+ border: 0;
+ border-bottom: 1px solid #bbb;
+ align: left;
+ margin: 3px 0;
+ padding: 0;
+}
+.tableObject th,
+.tableTuples th {
+ min-width: 145px;
+ text-align: left;
+ text-transform: capitalize;
+ padding: 3px;
+ padding-right: 10px;
+ font-weight: 300;
+ color: #333;
+}
+.tableTuples td {
+ text-align: right;
+ padding: 3px;
+}
+.tableObject td {
+ font-weight: normal;
+ color: #000;
+}
+.tableObject .tableObject {
+ border: 1px solid #ddd;
+}
+.tableArray {
+ border: 1px solid #ddd;
+ border-spacing: 0;
+}
+.tableArray td {
+ border-bottom: 1px solid #ddd;
+}
+.gray {
+ font-size: 12px;
+ color: #888;
+ display: block;
+}
+.sha256.heading {
+ margin: 20px 0 0px;
+}
+.gray span {
+ padding-right: 5px;
+}
+.gray {
+ margin-bottom: 10px;
+}
+.gray a {
+ color: #666;
+}
+
+.verified {
+ color: #080;
+ font-weight: bold;
+}
+.unverified {
+ color: #f00;
+ font-weight: 300;
+}
+
+.loading, .error {
+ font-weight: normal;
+ margin: 10px 0;
+ padding: 3px;
+ font-size: 24px;
+}
+
+.title {
+ text-transform: capitalize;
+}
+.rect {
+ position: absolute;
+}
+.rect { border: 1px solid rgba(0,0,255); background-color: rgba(0,0,255,0.1); }
+
+/* videos / video preloader */
+
+video {
+ max-width: 640px;
+ margin: 10px 0;
+}
+.video {
+ margin: 0 0 10px 0;
+}
+.video .bg {
+ cursor: pointer;
+ position: relative;
+ background-size: cover;
+}
+.video .play {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate3d(-50%, -50%, 0);
+ width: 20%;
+ height: 20%;
+ background-image: url(/search/static/img/play.png);
+ background-position: center center;
+ background-size: contain;
+ background-repeat: no-repeat;
+}
+.desktop .video .play:hover {
+ -webkit-filter: invert(60%) sepia(100%) saturate(500%) hue-rotate(160deg);
+}
+
+/* spectre.css loader */
+
+.loaderWrapper {
+ display: inline-block;
+ position: relative;
+ width: .8rem;
+ height: .8rem;
+ padding: 10px;
+}
+.loader {
+ color: transparent !important;
+ min-height: .8rem;
+ pointer-events: none;
+ position: relative;
+}
+
+.loader::after {
+ animation: loader 500ms infinite linear;
+ border: .1rem solid #5755d9;
+ border-radius: 50%;
+ border-right-color: transparent;
+ border-top-color: transparent;
+ content: "";
+ display: block;
+ height: .8rem;
+ left: 50%;
+ margin-left: -.4rem;
+ margin-top: -.4rem;
+ position: absolute;
+ top: 50%;
+ width: .8rem;
+ z-index: 1;
+}
+
+.loader.loader-lg {
+ min-height: 2rem;
+}
+
+.loader.loader-lg::after {
+ height: 1.6rem;
+ margin-left: -.8rem;
+ margin-top: -.8rem;
+ width: 1.6rem;
+}
+
+@keyframes loader {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+} \ No newline at end of file
diff --git a/scraper/client/common/footer.component.js b/scraper/client/common/footer.component.js
new file mode 100644
index 00000000..7c82b44b
--- /dev/null
+++ b/scraper/client/common/footer.component.js
@@ -0,0 +1,10 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+export default function Footer(props) {
+ return (
+ <footer>
+ </footer>
+ );
+}
diff --git a/scraper/client/common/gate.component.js b/scraper/client/common/gate.component.js
new file mode 100644
index 00000000..9bf9287b
--- /dev/null
+++ b/scraper/client/common/gate.component.js
@@ -0,0 +1,21 @@
+import React from 'react'
+import { connect } from 'react-redux'
+
+function Gate(props) {
+ const { app, tag, View } = props
+ const data = app[tag]
+ if (!data) return null
+ if (data === 'loading') {
+ return <div className='tableObject loading'>{tag}{': Loading'}</div>
+ }
+ if (data.err) {
+ return <div className='tableObject error'>{tag}{' Error: '}{data.err}</div>
+ }
+ return <View data={data} {...props} />
+}
+
+const mapStateToProps = state => ({
+ app: state.metadata
+})
+
+export default connect(mapStateToProps)(Gate)
diff --git a/scraper/client/common/header.component.js b/scraper/client/common/header.component.js
new file mode 100644
index 00000000..650df5fc
--- /dev/null
+++ b/scraper/client/common/header.component.js
@@ -0,0 +1,19 @@
+import React, { Component } from 'react'
+import { NavLink } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+class Header extends Component {
+ componentDidMount(){
+
+ }
+ render() {
+ return (
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ // hash: state.metadata.hash,
+})
+
+export default connect(mapStateToProps)(Sidebar)
diff --git a/scraper/client/common/index.js b/scraper/client/common/index.js
new file mode 100644
index 00000000..7f4d9870
--- /dev/null
+++ b/scraper/client/common/index.js
@@ -0,0 +1,22 @@
+import ActiveLink from './activeLink.component'
+import Header from './header.component'
+import Footer from './footer.component'
+import Loader from './loader.component'
+import Sidebar from './sidebar.component'
+import Gate from './gate.component'
+import { TableObject, TableArray, TableTuples, TableRow, TableCell } from './table.component'
+import './common.css'
+
+export {
+ Header,
+ Footer,
+ Sidebar,
+ Loader,
+ Gate,
+ TableObject,
+ TableArray,
+ TableTuples,
+ TableRow,
+ TableCell,
+ ActiveLink,
+}
diff --git a/scraper/client/common/loader.component.js b/scraper/client/common/loader.component.js
new file mode 100644
index 00000000..6795424b
--- /dev/null
+++ b/scraper/client/common/loader.component.js
@@ -0,0 +1,10 @@
+import React, { Component } from 'react'
+
+export default function Loader() {
+ return (
+ <div className='loaderWrapper'>
+ <div className='loader'>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/scraper/client/common/sidebar.component.js b/scraper/client/common/sidebar.component.js
new file mode 100644
index 00000000..f0dbe750
--- /dev/null
+++ b/scraper/client/common/sidebar.component.js
@@ -0,0 +1,25 @@
+import React, { Component } from 'react'
+import { NavLink } from 'react-router-dom'
+import { connect } from 'react-redux'
+
+class Sidebar extends Component {
+ render() {
+ const { hash } = this.props
+ if (!hash) {
+ return (
+ <div className="sidebar">
+ </div>
+ )
+ }
+ return (
+ <div className="sidebar">
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ hash: state.metadata.hash,
+})
+
+export default connect(mapStateToProps)(Sidebar)
diff --git a/scraper/client/common/table.component.js b/scraper/client/common/table.component.js
new file mode 100644
index 00000000..76a1d57c
--- /dev/null
+++ b/scraper/client/common/table.component.js
@@ -0,0 +1,121 @@
+import React from 'react'
+
+import { formatName } from '../util'
+
+const __HR__ = '__HR__'
+
+export function TableObject({ tag, object, order, summary }) {
+ if (!object) return null
+ if (object === 'loading') {
+ return <div className='tableObject loading'>{tag}{': Loading'}</div>
+ }
+ if (object.err) {
+ return <div className='tableObject error'>{tag}{' Error: '}{object.err}</div>
+ }
+ let objects = Object.keys(object)
+ if (order) {
+ const grouped = objects.reduce((a, b) => {
+ const index = order.indexOf(b)
+ if (index !== -1) {
+ a.order.push([index, b])
+ } else {
+ a.alpha.push(b)
+ }
+ return a
+ }, { order: [], alpha: [] })
+ objects = grouped.order
+ .sort((a, b) => a[0] - b[0])
+ .map(([i, s]) => s)
+ if (!summary) {
+ objects = objects
+ // .concat([__HR__])
+ .concat(grouped.alpha.sort())
+ }
+ } else {
+ objects = objects.sort()
+ }
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableObject ' + tag}>
+ <tbody>
+ {objects.map((key, i) => (
+ <TableRow key={key + '_' + i} name={key} value={object[key]} />
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableArray({ tag, list }) {
+ if (!list) return null
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableArray ' + tag}>
+ <tbody>
+ {list.map((value, i) => (
+ <tr key={tag + '_' + i}>
+ <TableCell value={value} />
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableTuples({ tag, list }) {
+ if (!list) return null
+ return (
+ <div>
+ {tag && <h3>{tag}</h3>}
+ <table className={'tableTuples ' + tag}>
+ <tbody>
+ {list.map(([key, ...values], i) => (
+ <tr key={tag + '_' + i}>
+ <th>{formatName(key)}</th>
+ {values.map((value, j) => (
+ <TableCell key={i + '_' + j} value={value} />
+ ))}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )
+}
+
+export function TableRow({ name, value }) {
+ if (name === __HR__) {
+ return (
+ <tr>
+ <th className='tr'>
+ <hr />
+ </th>
+ </tr>
+ )
+ }
+ return (
+ <tr>
+ <th>{formatName(name)}</th>
+ <TableCell name={name} value={value} />
+ </tr>
+ )
+}
+
+export function TableCell({ value }) {
+ if (value && typeof value === 'object') {
+ if (value._raw) {
+ value = value.value
+ } else if (value.length) {
+ value = <TableArray nested tag={''} list={value} />
+ } else {
+ value = <TableObject nested tag={''} object={value} />
+ }
+ }
+ return (
+ <td>{value}</td>
+ )
+}
diff --git a/scraper/client/index.js b/scraper/client/index.js
new file mode 100644
index 00000000..eddc5fb2
--- /dev/null
+++ b/scraper/client/index.js
@@ -0,0 +1,19 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import { AppContainer } from 'react-hot-loader'
+import { Provider } from 'react-redux'
+
+import App from './app'
+
+import { store, history } from './store'
+
+const container = document.createElement('div')
+document.body.appendChild(container)
+
+ReactDOM.render(
+ <AppContainer>
+ <Provider store={store}>
+ <App history={history} />
+ </Provider>
+ </AppContainer>, container
+)
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>
+ )
+}
diff --git a/scraper/client/session.js b/scraper/client/session.js
new file mode 100644
index 00000000..2f006451
--- /dev/null
+++ b/scraper/client/session.js
@@ -0,0 +1,5 @@
+import Storage from 'store2'
+
+const session = Storage.namespace('megapixels.scraper')
+
+export default session
diff --git a/scraper/client/store.js b/scraper/client/store.js
new file mode 100644
index 00000000..08b6e801
--- /dev/null
+++ b/scraper/client/store.js
@@ -0,0 +1,64 @@
+import { applyMiddleware, compose, combineReducers, createStore } from 'redux'
+import { connectRouter, routerMiddleware } from 'connected-react-router'
+import { createBrowserHistory } from 'history'
+import thunk from 'redux-thunk'
+import * as types from './types'
+// import { login } from './util'
+
+// import metadataReducer from './metadata/metadata.reducer'
+
+const initialState = () => ({
+ options: {}
+})
+
+export default function apiReducer(state = initialState(), action) {
+ // console.log(action.type, action)
+ switch (action.type) {
+ case types.api.loading:
+ return {
+ ...state,
+ [action.tag]: { loading: true },
+ }
+
+ case types.api.loaded:
+ return {
+ ...state,
+ [action.tag]: action.data,
+ }
+
+ case types.api.error:
+ return {
+ ...state,
+ [action.tag]: { error: action.err },
+ }
+
+ default:
+ return state
+ }
+}
+
+const rootReducer = combineReducers({
+ api: apiReducer,
+})
+
+function configureStore(initialState = {}, history) {
+ const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
+
+ const store = createStore(
+ connectRouter(history)(rootReducer), // new root reducer with router state
+ initialState,
+ composeEnhancers(
+ applyMiddleware(
+ thunk,
+ routerMiddleware(history)
+ ),
+ ),
+ )
+
+ return store
+}
+
+const history = createBrowserHistory()
+const store = configureStore({}, history)
+
+export { store, history }
diff --git a/scraper/client/types.js b/scraper/client/types.js
new file mode 100644
index 00000000..8507d556
--- /dev/null
+++ b/scraper/client/types.js
@@ -0,0 +1,13 @@
+export const asType = (type, name) => [type, name].join('_').toUpperCase()
+export const tagAsType = (type, names) => (
+ names.reduce((tags, name) => {
+ tags[name] = asType(type, name)
+ return tags
+ }, {})
+)
+
+export const api = tagAsType('api', [
+ 'loading', 'loaded', 'error',
+])
+
+export const init = '@@INIT'
diff --git a/scraper/client/util.js b/scraper/client/util.js
new file mode 100644
index 00000000..ef93507e
--- /dev/null
+++ b/scraper/client/util.js
@@ -0,0 +1,149 @@
+/* Mobile check */
+
+export const isiPhone = !!((navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i)))
+export const isiPad = !!(navigator.userAgent.match(/iPad/i))
+export const isAndroid = !!(navigator.userAgent.match(/Android/i))
+export const isMobile = isiPhone || isiPad || isAndroid
+export const isDesktop = !isMobile
+
+const htmlClassList = document.body.parentNode.classList
+htmlClassList.add(isDesktop ? 'desktop' : 'mobile')
+
+/* Default image dimensions */
+
+export const widths = {
+ th: 160,
+ sm: 320,
+ md: 640,
+ lg: 1280,
+}
+
+/* Formatting functions */
+
+const acronyms = 'id url cc sa fp md5 sha256'.split(' ').map(s => '_' + s)
+const acronymsUpperCase = acronyms.map(s => s.toUpperCase())
+
+export const formatName = s => {
+ acronyms.forEach((acronym, i) => s = s.replace(acronym, acronymsUpperCase[i]))
+ return s.replace(/_/g, ' ')
+}
+
+// Use to pad frame numbers with zeroes
+export const pad = (n, m) => {
+ let s = String(n || 0)
+ while (s.length < m) {
+ s = '0' + s
+ }
+ return s
+}
+
+// Verified is 0/1 when retrieved from SQL, but 'verified' or 'unverified' when retrieved elsewhere
+export const isVerified = verified => verified === 1 || verified === '1' || verified === 'verified'
+export const verify = verified => isVerified(verified) ? 'verified' : 'unverified'
+
+export const courtesyS = (n, s) => n + ' ' + (n === 1 ? s : s + 's')
+
+export const padSeconds = n => n < 10 ? '0' + n : n
+
+export const timestamp = (n = 0, fps = 25) => {
+ n /= fps
+ let s = padSeconds(Math.round(n) % 60)
+ n = Math.floor(n / 60)
+ if (n > 60) {
+ return Math.floor(n / 60) + ':' + padSeconds(n % 60) + ':' + s
+ }
+ return (n % 60) + ':' + s
+}
+
+export const percent = n => (n * 100).toFixed(1) + '%'
+
+export const px = (n, w) => Math.round(n * w) + 'px'
+
+export const clamp = (n, a, b) => n < a ? a : n < b ? n : b
+
+/* URLs */
+
+export const hashPath = sha256 => {
+ if (!sha256 || sha256.length < 9) {
+ throw new Error('Invalid sha256')
+ }
+ return [
+ sha256.slice(0, 3),
+ sha256.slice(3, 6),
+ sha256.slice(6, 9),
+ sha256,
+ ].join('/')
+}
+
+export const imageUrl = (verified, sha256, frame, size = 'th') => [
+ 'https://' + process.env.S3_HOST + '/v1/media/keyframes',
+ isVerified(verified) ? null : 'unverified',
+ hashPath(sha256),
+ pad(frame, 6),
+ size,
+ 'index.jpg'
+].filter(s => !!s).join('/')
+
+export const metadataUri = (sha256, tag) => '/metadata/' + sha256 + '/' + tag + '/'
+export const keyframeUri = (sha256, frame) => '/metadata/' + sha256 + '/keyframe/' + pad(frame, 6) + '/'
+
+export const preloadImage = opt => {
+ let { verified, hash, frame, url } = opt
+ if (hash && frame) {
+ url = imageUrl(verified, hash, frame, 'md')
+ }
+ const image = new Image()
+ let loaded = false
+ image.onload = () => {
+ if (loaded) return
+ loaded = true
+ image.onload = null
+ }
+ // console.log(img.src)
+ image.crossOrigin = 'anonymous'
+ image.src = url
+ if (image.complete) {
+ image.onload()
+ }
+}
+
+/* AJAX */
+
+export const get = (uri, data) => {
+ let headers = {
+ Accept: 'application/json, application/xml, text/play, text/html, *.*',
+ }
+ let opt = {
+ method: 'GET',
+ body: data,
+ headers,
+ // credentials: 'include',
+ }
+ // console.log(headers)
+ // headers['X-CSRFToken'] = csrftoken
+ return fetch(uri, opt).then(res => res.json())
+}
+
+export const post = (uri, data) => {
+ let headers
+ if (data instanceof FormData) {
+ headers = {
+ Accept: 'application/json, application/xml, text/play, text/html, *.*',
+ }
+ } else {
+ headers = {
+ Accept: 'application/json, application/xml, text/play, text/html, *.*',
+ 'Content-Type': 'application/json; charset=utf-8',
+ }
+ data = JSON.stringify(data)
+ }
+ let opt = {
+ method: 'POST',
+ body: data,
+ headers,
+ // credentials: 'include',
+ }
+ // console.log(headers)
+ // headers['X-CSRFToken'] = csrftoken
+ return fetch(uri, opt).then(res => res.json())
+}