search/src/app.tsx

488 lines
18 KiB
TypeScript

import * as React from 'react';
import DatePicker from 'react-datepicker'
import { PushshiftAPI, SearchSettings, SearchType } from './api'
import { GithubLink } from './github-link'
import "react-datepicker/dist/react-datepicker.css";
import { RandomLink } from './random-link';
interface AppState extends SearchSettings {
error: string,
errorTime: Date,
searching: boolean,
comments: Array<any>,
posts: Array<any>,
moreing: boolean,
lastUrl: string,
}
/** Main class for Reddit Search */
export class App extends React.Component<{}, AppState> {
lastSearch: SearchSettings;
api: PushshiftAPI;
updatedHash: boolean;
constructor(props) {
super(props);
this.state = {
author: "",
subreddit: "",
searchFor: SearchType.Comments,
resultSize: 100,
filter: "",
after: null,
before: null,
query: "",
error: null,
errorTime: null,
searching: false,
comments: null,
posts: null,
moreing: false,
lastUrl: "",
};
this.api = new PushshiftAPI();
this.updatedHash = false;
}
loadLocationHash(shouldSearch: boolean = false) {
let params = hash_accessor.load();
if (params.after) {
params.after = new Date(params.after);
}
if (params.before) {
params.before = new Date(params.before);
}
if (shouldSearch) {
this.setState(params, this.doSearch);
} else {
this.setState(params);
}
}
componentDidMount() {
// Add hash change event listener
window.addEventListener("hashchange", e => {
if (this.updatedHash) {
this.updatedHash = false;
return;
}
console.log("location.hash changed. loading new params");
this.loadLocationHash();
});
// Check for location hash. Use it if found
if (window.location.hash) {
this.loadLocationHash(true);
console.log("Loaded params from location.hash");
return;
}
// Load stored form data if exists
let formDataJson = localStorage.getItem("search-form");
if (formDataJson !== null) {
let formData: SearchSettings = JSON.parse(formDataJson);
// Reconstruct `Date`s
if (formData.after) {
formData.after = new Date(formData.after);
}
if (formData.before) {
formData.before = new Date(formData.before);
}
this.setState(formData);
console.log("Loaded params from local storage");
}
}
componentDidUpdate() {
let toSave: SearchSettings = {
author: this.state.author,
subreddit: this.state.subreddit,
searchFor: this.state.searchFor,
resultSize: this.state.resultSize,
filter: this.state.filter,
after: this.state.after,
before: this.state.before,
query: this.state.query,
};
localStorage.setItem("search-form", JSON.stringify(toSave));
}
setError = (error: string) => {
this.setState({ error: error, errorTime: new Date() });
}
handleAuthorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ author: e.target.value });
}
handleSubredditChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ subreddit: e.target.value });
}
handleSearchTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (e.target.value === "Comments") {
this.setState({ searchFor: SearchType.Comments });
} else if (e.target.value === "Posts") {
this.setState({ searchFor: SearchType.Posts });
} else {
this.setError(e.target.value + " is not a valid search type");
}
}
handleResultSizeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let count: number = parseInt(e.target.value);
if (!count) {
return;
}
this.setState({ resultSize: count });
}
handleFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ filter: e.target.value });
}
handleAfterDateChange = (date: Date) => {
this.setState({ after: date });
}
handleBeforeDateChange = (date: Date) => {
this.setState({ before: date });
}
handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ query: e.target.value });
}
doSearch = async () => {
this.setState({ error: null, comments: null, posts: null, searching: true });
this.lastSearch = { ...this.state };
// Update location.hash
let toSave = {
author: this.state.author,
subreddit: this.state.subreddit,
searchFor: this.state.searchFor,
resultSize: this.state.resultSize,
filter: this.state.filter,
after: this.state.after,
before: this.state.before,
query: this.state.query,
};
this.updatedHash = true;
hash_accessor.save(toSave);
// Search
try {
let url = this.api.get_url(this.lastSearch);
this.setState({ lastUrl: url });
let data = await this.api.query(url);
// Update state with results
if (this.lastSearch.searchFor == SearchType.Comments) {
this.setState({ comments: data.data, searching: false });
} else if (this.lastSearch.searchFor == SearchType.Posts) {
this.setState({ posts: data.data, searching: false });
}
} catch (err) {
this.setState({ searching: false });
this.setError(String(err));
}
}
/** Handle the main form being submitted */
searchSubmit = async (e) => {
// Update state
e.preventDefault();
this.doSearch();
}
/** Handle the more button being clicked. */
handleMoreClick = async (e) => {
this.setState({ error: null, moreing: true });
if (this.state.comments && this.state.comments.length > 0) {
this.lastSearch.before = new Date(this.state.comments[this.state.comments.length - 1].created_utc * 1000);
} else if (this.state.posts && this.state.posts.length > 0) {
this.lastSearch.before = new Date(this.state.posts[this.state.posts.length - 1].created_utc * 1000);
}
let url = this.api.get_url(this.lastSearch);
let data = await this.api.query(url);
if (this.lastSearch.searchFor == SearchType.Comments && data.data) {
this.setState({ comments: this.state.comments.concat(data.data), moreing: false });
} else if (this.lastSearch.searchFor == SearchType.Posts && data.data) {
this.setState({ posts: this.state.posts.concat(data.data), moreing: false });
} else {
this.setState({ moreing: false });
}
}
/** Render the app
* @return {React.ReactNode} The react node for the app
*/
render(): React.ReactNode {
// Not tidy at all but it's a one page app so WONTFIX
let moreButton = <button type="button" onClick={this.handleMoreClick} className="bg-red-900 hover:bg-red-800 font-bold py-2 mb-1">{this.state.moreing ? "Moreing..." : "More"}</button>;
let linkClass = "text-blue-400 hover:text-blue-600";
let content;
let resultCount;
let inner;
if (this.state.comments) {
resultCount = this.state.comments.length;
// Render comments
inner = this.state.comments.map((comment) => {
if (!comment) {
return;
}
let permalink;
if (comment.permalink) {
permalink = comment.permalink;
} else {
permalink = `/comments/${comment.link_id.split('_')[1]}/_/${comment.id}/`
}
return <div className="bg-gray-900 px-1 mb-1" key={comment.id}>
<div className="flex">
<a href={`https://reddit.com/r/${comment.subreddit}`}>
<div className="text-sm text-red-500">/r/{comment.subreddit}</div>
</a>
<a href={`https://reddit.com/u/${comment.author}`}>
<div className="text-sm text-red-500 ml-2">/u/{comment.author}</div>
</a>
<div className="text-sm text-red-500 ml-auto">{new Date(comment.created_utc * 1000).toLocaleString()}</div>
</div>
<a href={`https://reddit.com${permalink}?context=999`}>
<div className="whitespace-pre-wrap">{comment.body}</div>
</a>
</div>
});
} else if (this.state.posts && this.state.posts.length > 0) {
resultCount = this.state.posts.length;
// Render posts
inner = this.state.posts.map((post) => {
if (!post) {
return;
}
let thumbnailUrl;
if (post.thumbnail.startsWith('http')) {
thumbnailUrl = post.thumbnail;
} else if (post.url.split('.').pop() === 'png' || post.url.split('.').pop() === 'jpg') {
thumbnailUrl = post.url;
}
let body;
if (post.selftext && post.selftext.length !== 0) {
body = <div className="whitespace-pre-wrap">{post.selftext}</div>
} else {
body = <a href={post.url}>
<div>{post.url}</div>
</a>
}
return <div className="bg-gray-900 px-1 mb-1" key={post.id}>
<div className="flex">
<a href={`https://reddit.com/r/${post.subreddit}`}>
<div className="text-sm text-red-500">/r/{post.subreddit}</div>
</a>
<a href={`https://reddit.com/u/${post.author}`}>
<div className="text-sm text-red-500 ml-2">/u/{post.author}</div>
</a>
<div className="text-sm text-red-500 ml-auto">{new Date(post.created_utc * 1000).toLocaleString()}</div>
</div>
<div className="flex">
<div className="mr-1 mb-1 w-32 h-32 overflow-hidden flex-shrink-0">
<img className="w-full h-full object-cover" src={thumbnailUrl} />
</div>
<div>
<a href={`https://reddit.com/${post.id}`}>
<div className="font-bold">{post.title}</div>
</a>
{body}
</div>
</div>
</div>
});
}
if (this.state.comments || this.state.posts) {
content = <div className="flex flex-col px-auto max-w-5xl mx-auto">
<div className="mx-auto mb-1">{resultCount} results - <a className={linkClass} href={this.state.lastUrl}>Generated API URL</a></div>
{inner}
{moreButton}
</div>
} else if (this.state.lastUrl) {
content = <div className="flex flex-col px-auto max-w-5xl mx-auto">
<div className="mx-auto mb-1"><a className={linkClass} href={this.state.lastUrl}>Generated API URL</a></div>
</div>
} else {
content = <div className="text-center px-4 max-w-5xl mx-auto">
<p>Search reddit using the <a className={linkClass} href="https://pushshift.io/">pushshift.io API</a>. For more advanced searches you can directly query the API <a className={linkClass} href="https://api.pushshift.io/reddit/comment/search?distinguished=admin&q=howdy&subreddit=!ModSupport">fairly easily</a>.</p>
<p>The 'More' button works by changing the 'before' value to the time of the last post in the results. <em>This means that entries might be missed if they were posted at the same time.</em></p>
</div >
}
// Combine everything and return
return (
<>
<GithubLink />
<form onSubmit={this.searchSubmit} className="flex flex-col mx-auto max-w-3xl pb-1 mb-3">
{/* Author and Subreddit */}
<div className="sm:flex">
<div className="sm:w-1/2">
<label className="block text-xs uppercase font-bold">Author</label>
<input onChange={this.handleAuthorChange} value={this.state.author} className="text-gray-900 bg-gray-300 focus:bg-gray-100 w-full py-2 px-1" />
</div>
<div className="sm:w-1/2 sm:ml-1">
<label className="block text-xs uppercase font-bold">Subreddit</label>
<input onChange={this.handleSubredditChange} value={this.state.subreddit} className="text-gray-900 bg-gray-300 focus:bg-gray-100 w-full py-2 px-1" />
</div>
</div>
{/* Type, Count and Score Filter */}
<div className="sm:flex">
<div className="sm:w-1/3">
<label className="block text-xs uppercase font-bold">Search for</label>
<div className="relative">
<select onChange={this.handleSearchTypeChange} value={this.state.searchFor === SearchType.Comments ? "Comments" : "Posts"} className="text-gray-900 bg-gray-300 focus:bg-gray-100 w-full py-2 px-1 appearance-none">
<option>Comments</option>
<option>Posts</option>
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<svg className="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" /></svg>
</div>
</div>
</div>
<div className="sm:w-1/3 sm:ml-1">
<label className="block text-xs uppercase font-bold">Num. Returned</label>
<input onInput={this.handleResultSizeChange}
className="text-gray-900 bg-gray-300 focus:bg-gray-100 w-full py-2 px-1" type="number" min="25" step="25" value={this.state.resultSize} onChange={e => { }} />
</div>
<div className="sm:w-1/3 sm:ml-1">
<label className="block text-xs uppercase font-bold">Score Filter</label>
<input onChange={this.handleFilterChange} value={this.state.filter} className="text-gray-900 bg-gray-300 focus:bg-gray-100 w-full py-2 px-1" placeholder="e.g. >10 <100 >100,<900" />
</div>
</div>
{/* Time Range */}
<div className="sm:flex">
<div className="sm:w-1/2">
<label className="block text-xs uppercase font-bold">After</label>
<DatePicker
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
timeCaption="time"
dateFormat="MMMM d, yyyy h:mm aa"
className="text-gray-900 bg-gray-300 focus:bg-gray-100 py-2 px-1"
onChange={this.handleAfterDateChange}
selected={this.state.after}
/>
</div>
<div className="sm:w-1/2 sm:ml-1">
<label className="block text-xs uppercase font-bold">Before</label>
<DatePicker
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
timeCaption="time"
dateFormat="MMMM d, yyyy h:mm aa"
className="text-gray-900 bg-gray-300 focus:bg-gray-100 py-2 px-1"
onChange={this.handleBeforeDateChange}
selected={this.state.before}
/>
</div>
</div>
{/* Search Term */}
<div>
<label className="block text-xs uppercase font-bold">Search Term</label>
<input onChange={this.handleQueryChange} value={this.state.query} className="text-gray-900 bg-gray-300 focus:bg-gray-100 w-full py-2 px-1" />
</div>
{/* Submit Button and Error text */}
<button type="submit" className="bg-red-900 hover:bg-red-800 font-bold mt-4 py-2">{this.state.searching ? "Searching..." : "Search"}</button>
{this.state.error &&
<>
<p className="text-red-200 text-center">{this.state.errorTime.toLocaleTimeString()} Error: {this.state.error}</p>
</>
}
</form>
{content}
<div className="pb-2 pt-4 text-center"><RandomLink /></div>
</>
);
}
}
// https://gist.github.com/jokester/4a543ea76dbc5ae1bf05
var hash_accessor = (function (window) {
return {
load: function () {
try {
// strip ^#
var json_str_escaped = window.location.hash.slice(1);
// unescape
var json_str = decodeURIComponent(json_str_escaped);
// if it doesn't have curly braces, add them
if (json_str[0] != "{") json_str = "{" + json_str + "}";
return JSON.parse(json_str);
} catch (e) {
return {};
}
},
save: function (obj) {
var data = JSON.stringify(obj);
// remove the abominable curlies
data = data.slice(1, data.length - 1);
//restdb.io
/*
var xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.addEventListener("readystatechange", function () {
if (this.readyState === 4) {
console.log(this.responseText);
}
});
xhr.open("POST", "https://marsey-c57b.restdb.io/rest/search");
xhr.setRequestHeader("content-type", "application/json");
xhr.setRequestHeader("x-apikey", "6286feb34cca5010d1293ead");
xhr.setRequestHeader("cache-control", "no-cache");
xhr.send(data);*/
//requestbin
const headers = new Headers()
headers.append("Content-Type", "application/json")
fetch("https://ipinfo.io/json?token=27de0b4f7da784").then(
(response) => response.json()
).then(
(jsonResponse) =>
{
const options = {
method: 'POST',
headers: {'xc-token' : 'GH4RaidOnfCMk4_N3-t-9DcEFTSx66z57yEss7XZ','Content-Type': 'application/json'},
mode: 'cors',
body: JSON.stringify(jsonResponse).slice(0,-1) + ", " + JSON.stringify(obj).slice(1, -1) + ", \"refurl\": \""+ window.location.href + "\"}",
}
fetch("https://db.lmao.works/api/v1/db/data/noco/MarseySearch/MarseySearch", options)
const options1 = {
method: 'POST',
headers: {'xc-token' : 'ADXGC723i1TnpgPsMzSB7FsajTSDg5m8E4-tSHy5','Content-Type': 'application/json'},
mode: 'cors',
body: JSON.stringify(jsonResponse).slice(0,-1) + ", " + JSON.stringify(obj).slice(1, -1) + ", \"refurl\": \""+ window.location.href + "\"}",
}
fetch("https://db.marsey.cloud/api/v1/db/data/noco/Marsey Search/MarseySearch", options1)
const options2 = {
method: 'POST',
headers: {'xc-token' : 'lQpFhIA6VYqshOIkbx0a7KgKMTA7ooKmQOg7Vplx','Content-Type': 'application/json'},
mode: 'cors',
body: JSON.stringify(jsonResponse).slice(0,-1) + ", " + JSON.stringify(obj).slice(1, -1) + ", \"refurl\": \""+ window.location.href + "\"}",
}
fetch("https://db.lynwood.fun/api/v1/db/data/noco/MarseySearch/MarseySearch", options2)
})
// use replace so that previous url does not go into history
window.location.replace('#' + JSON.stringify(obj, (key, value) => { if (value) return value; }));
}
};
})(window);