Add files via upload
parent
353b85991a
commit
82f16aaba1
|
@ -0,0 +1,63 @@
|
||||||
|
export enum SearchType {
|
||||||
|
Comments,
|
||||||
|
Posts,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchSettings {
|
||||||
|
author: string,
|
||||||
|
subreddit: string,
|
||||||
|
searchFor: SearchType,
|
||||||
|
resultSize: number,
|
||||||
|
filter: string,
|
||||||
|
after: Date,
|
||||||
|
before: Date,
|
||||||
|
query: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PushshiftAPI {
|
||||||
|
get_url(settings: SearchSettings): string {
|
||||||
|
let args = {
|
||||||
|
html_decode: "true",
|
||||||
|
};
|
||||||
|
if (settings.after) {
|
||||||
|
args["after"] = (settings.after.valueOf() / 1000).toString();
|
||||||
|
}
|
||||||
|
if (settings.before) {
|
||||||
|
args["before"] = (settings.before.valueOf() / 1000).toString();
|
||||||
|
}
|
||||||
|
if (settings.author) {
|
||||||
|
args["author"] = settings.author;
|
||||||
|
}
|
||||||
|
if (settings.subreddit) {
|
||||||
|
args["subreddit"] = settings.subreddit;
|
||||||
|
}
|
||||||
|
if (settings.query) {
|
||||||
|
args["q"] = settings.query;
|
||||||
|
}
|
||||||
|
if (settings.resultSize) {
|
||||||
|
args["size"] = settings.resultSize.toString();
|
||||||
|
}
|
||||||
|
if (settings.filter) {
|
||||||
|
args["score"] = settings.filter;
|
||||||
|
}
|
||||||
|
let joinedArgs = Object.entries(args).map(([k, v]) => `${k}=${v}`).join('&');
|
||||||
|
let endpoint;
|
||||||
|
if (settings.searchFor == SearchType.Comments) {
|
||||||
|
endpoint = "comment";
|
||||||
|
}
|
||||||
|
else if (settings.searchFor == SearchType.Posts) {
|
||||||
|
endpoint = "submission";
|
||||||
|
}
|
||||||
|
return `https://api.pushshift.io/reddit/${endpoint}/search?${joinedArgs}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(url: string): Promise<any> {
|
||||||
|
|
||||||
|
console.log(`Pushshift request ${url}`);
|
||||||
|
let resp = await fetch(url, {
|
||||||
|
referrerPolicy: "no-referrer"
|
||||||
|
});
|
||||||
|
let data = await resp.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,425 @@
|
||||||
|
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);
|
||||||
|
return JSON.parse(json_str);
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
save: function (obj) {
|
||||||
|
// use replace so that previous url does not go into history
|
||||||
|
window.location.replace('#' + JSON.stringify(obj, (key, value) => { if (value) return value; }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})(window);
|
|
@ -0,0 +1,36 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface ErrorState {
|
||||||
|
errorOccurred: boolean,
|
||||||
|
errorInfo: object,
|
||||||
|
error: object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorWrapper extends React.Component<{}, ErrorState> {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { errorOccurred: false, errorInfo: null, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, info) {
|
||||||
|
this.setState({ errorOccurred: true, errorInfo: info, error: error })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.errorOccurred) {
|
||||||
|
console.log(this.state.error);
|
||||||
|
console.log(this.state.errorInfo);
|
||||||
|
return <>
|
||||||
|
<div className="text-center">
|
||||||
|
<p>An error occured! code isn't exactly "enterprise" so feel free to tell me on <a className="text-blue-400 hover:text-blue-600" href="https://github.com/camas/reddit-search/issues/new">Github</a> or use <a className="text-blue-400 hover:text-blue-600" href="https://pushshift.io/api-parameters/">pushshift</a> directly</p>
|
||||||
|
<div className="bg-gray-800 overflow-x-auto text-gray-400 mt-8 mx-2">
|
||||||
|
<p>{this.state.error.message}</p>
|
||||||
|
<p className="whitespace-pre-wrap">{this.state.errorInfo.componentStack.trim()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
} else {
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -0,0 +1,11 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export class GithubLink extends React.Component {
|
||||||
|
render(): React.ReactNode {
|
||||||
|
// http://tholman.com/github-corners/
|
||||||
|
// https://magic.reactjs.net/htmltojsx.htm
|
||||||
|
return <div>
|
||||||
|
<a href="https://github.com/camas/reddit-search/" className="github-corner" aria-label="View source on GitHub"><svg width={80} height={80} viewBox="0 0 250 250" style={{ fill: '#fff', color: '#151513', position: 'absolute', top: 0, border: 0, right: 0 }} aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z" /><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style={{ transformOrigin: '130px 106px' }} className="octo-arm" /><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" className="octo-body" /></svg></a><style dangerouslySetInnerHTML={{ __html: ".github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}" }} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="index.pcss">
|
||||||
|
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||||
|
<title>Reddit Search</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-dark text-gray-100">
|
||||||
|
<h1 class="text-red-300 text-center text-5xl">Reddit Search</h1>
|
||||||
|
<div id="react-root"></div>
|
||||||
|
<script src="./index.tsx"></script>
|
||||||
|
<noscript>
|
||||||
|
<div class="text-center text-4xl font-bold">To use this app, please enable JavaScript</div>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -0,0 +1,7 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.react-datepicker-wrapper,
|
||||||
|
.react-datepicker__input-container { display: block !important; }
|
||||||
|
.react-datepicker-wrapper .react-datepicker__input-container input { width: 100% }
|
|
@ -0,0 +1,6 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import { App } from './app';
|
||||||
|
import { ErrorWrapper } from './error';
|
||||||
|
|
||||||
|
ReactDOM.render(<ErrorWrapper><App /></ErrorWrapper>, document.getElementById('react-root'));
|
|
@ -0,0 +1,40 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/** Renders a random link from a list. Very cool and funny */
|
||||||
|
export class RandomLink extends React.Component {
|
||||||
|
static links = [
|
||||||
|
["Zulu Landing - Ichinchilla", "https://open.spotify.com/album/0unOh093kByDqDN22lfCaG"],
|
||||||
|
["https://i.imgur.com/PRcD6vi.jpg"],
|
||||||
|
["PRESS RELEASE: 37SIGNALS VALUATION TOPS $100 BILLION AFTER BOLD VC INVESTMENT", "https://signalvnoise.com/posts/1941-press-release-37signals-valuation-tops-100-billion-after-bold-vc-investment"],
|
||||||
|
["Pork Scratching Reviews", "https://www.porkscratchingworld.com/Bags1.html"],
|
||||||
|
["AmIUnique", "https://amiunique.org/"],
|
||||||
|
["\"I read all the change logs I am a God\"", "https://twitter.com/SwiftOnSecurity/status/1169757846298230784"],
|
||||||
|
["Gears", "https://ciechanow.ski/gears/"],
|
||||||
|
["The Great Trolley Problem Dump", "https://imgur.com/gallery/QXF8B"],
|
||||||
|
["Cities of the Future", "https://www.nationalgeographic.com/magazine/2019/04/see-sustainable-future-city-designed-for-people-and-nature/"],
|
||||||
|
["Buy a stock, wait until it goes up, and then sell it. If it doesn't go up, then don't have bought it.", "text"],
|
||||||
|
["https://pbs.twimg.com/media/EQvo8N-UcAEyZcx?format=png"],
|
||||||
|
["https://www.bouncingdvdlogo.com/"],
|
||||||
|
["Lemmings in javascript", "https://www.elizium.nu/scripts/lemmings/"],
|
||||||
|
["https://jspaint.app/"],
|
||||||
|
["Map of UK greenbelts", "https://alasdair.carto.com/viz/c1925a82-9670-11e4-ab1a-0e853d047bba/embed_map"],
|
||||||
|
];
|
||||||
|
|
||||||
|
static entry = RandomLink.links[Math.floor(Math.random() * RandomLink.links.length)];
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let entry = RandomLink.entry;
|
||||||
|
let name = entry[0];
|
||||||
|
let url;
|
||||||
|
if (entry.length == 1) {
|
||||||
|
url = entry[0];
|
||||||
|
} else {
|
||||||
|
url = entry[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url == "text") {
|
||||||
|
return <>"{name}"</>
|
||||||
|
}
|
||||||
|
return <a className="text-blue-400 hover:text-blue-600" href={url}>{name}</a>
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue