├── .editorconfig ├── .env.example ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── api ├── get-index.ts ├── get-tweet-ast │ └── [tweetId].ts └── sync-index.ts ├── components ├── App.tsx ├── ErrorPage.tsx ├── GitHubShareButton.tsx ├── InlineTweet │ ├── InlineTweet.tsx │ ├── Twemoji.tsx │ └── styles.module.css ├── LoadingIndicator │ ├── LoadingIndicator.tsx │ └── styles.module.css ├── Page404.tsx ├── Paper │ ├── Paper.tsx │ └── styles.module.css ├── QueryParamProvider.tsx ├── TweetIndexSearch │ ├── TweetIndexSearch.tsx │ └── styles.module.css ├── index.ts └── styles.module.css ├── lib ├── client │ ├── algolia.js │ ├── bootstrap.ts │ └── sdk.ts ├── server │ ├── algolia.ts │ ├── sync.ts │ └── twitter.ts └── types.ts ├── license ├── media ├── favicon.ico ├── icons │ ├── cluster.svg │ ├── customizable.svg │ ├── fast.svg │ ├── global.svg │ ├── logs.svg │ ├── open-source.svg │ ├── search.svg │ ├── setup.svg │ ├── stripe.svg │ └── sync.svg ├── logo.svg └── screenshot-search-ui-0.jpg ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── _error.tsx ├── index.tsx └── test.tsx ├── public ├── 404.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── default-avatar.png ├── error.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── favicon.png ├── manifest.json ├── noflash.js └── social.jpg ├── readme.md ├── styles └── global.css ├── tsconfig.json ├── tsoa.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # This is an example .env file. 3 | # 4 | # All of these environment vars must be defined either in your environment or in 5 | # a local .env file in order to run this app. 6 | # 7 | # @see https://github.com/rolodato/dotenv-safe 8 | # ------------------------------------------------------------------------------ 9 | 10 | NEXT_PUBLIC_ALGOLIA_APPLICATION_ID= 11 | NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY= 12 | 13 | ALGOLIA_SECRET_KEY= 14 | ALGOLA_INDEX_NAME= 15 | 16 | TWITTER_CONSUMER_KEY= 17 | TWITTER_CONSUMER_SECRET= 18 | 19 | TWITTER_ACCESS_TOKEN= 20 | TWITTER_ACCESS_TOKEN_SECRET= 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "ecmaFeatures": { 6 | "legacyDecorators": true, 7 | "jsx": true 8 | } 9 | }, 10 | "settings": { 11 | "react": { 12 | "version": "detect" 13 | } 14 | }, 15 | "overrides": [ 16 | { 17 | "files": ["**/*.js", "**/*.jsx"], 18 | "parser": "babel-eslint", 19 | "extends": [ 20 | "standard", 21 | "standard-react", 22 | "plugin:prettier/recommended", 23 | "prettier/standard", 24 | "prettier/react" 25 | ], 26 | "env": { "node": true }, 27 | "rules": { 28 | "space-before-function-paren": 0, 29 | "react/prop-types": 0, 30 | "react/jsx-handler-names": 0, 31 | "react/jsx-fragments": 0, 32 | "react/no-unused-prop-types": 0, 33 | "import/export": 0, 34 | "standard/no-callback-literal": 0 35 | } 36 | }, 37 | { 38 | "files": ["**/*.ts", "**/*.tsx"], 39 | "parser": "@typescript-eslint/parser", 40 | "plugins": ["@typescript-eslint"], 41 | "extends": [ 42 | "plugin:@typescript-eslint/recommended", 43 | "standard", 44 | "standard-react", 45 | "plugin:prettier/recommended", 46 | "prettier/standard", 47 | "prettier/react" 48 | ], 49 | "env": { "browser": true, "node": true }, 50 | "rules": { 51 | "@typescript-eslint/no-explicit-any": 0, 52 | "@typescript-eslint/explicit-module-boundary-types": 0, 53 | "no-use-before-define": 0, 54 | "@typescript-eslint/no-use-before-define": 0, 55 | "space-before-function-paren": 0, 56 | "react/prop-types": 0, 57 | "react/jsx-handler-names": 0, 58 | "react/jsx-fragments": 0, 59 | "react/no-unused-prop-types": 0, 60 | "import/export": 0, 61 | "standard/no-callback-literal": 0 62 | } 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage/ 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | 15 | # production 16 | build/ 17 | dist/ 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env.local 31 | .env.build 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | # vercel 37 | .vercel 38 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .snapshots/ 2 | build/ 3 | dist/ 4 | node_modules/ 5 | .next/ 6 | .vercel/ 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "bracketSpacing": true, 8 | "jsxBracketSameLine": false, 9 | "arrowParens": "always", 10 | "trailingComma": "none" 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: yarn 3 | node_js: 4 | - 12 5 | -------------------------------------------------------------------------------- /api/get-index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | import { getIndex } from '../lib/server/sync' 4 | 5 | export default async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ): Promise => { 9 | console.log('getTweetIndex') 10 | 11 | if (req.method !== 'GET') { 12 | return res.status(405).send({ error: 'method not allowed' }) 13 | } 14 | 15 | const index = await getIndex() 16 | const exists = await index.exists() 17 | 18 | res.status(200).json({ 19 | indexName: index.indexName, 20 | exists 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /api/get-tweet-ast/[tweetId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { fetchTweetAst } from 'static-tweets' 3 | import Cors from 'cors' 4 | 5 | const cors = initMiddleware( 6 | Cors({ 7 | methods: ['GET', 'OPTIONS'] 8 | }) 9 | ) 10 | 11 | export default async ( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ): Promise => { 15 | await cors(req, res) 16 | 17 | if (req.method === 'OPTIONS') { 18 | return res.status(200).end() 19 | } 20 | 21 | if (req.method !== 'GET') { 22 | return res.status(405).send({ error: 'method not allowed' }) 23 | } 24 | 25 | const tweetId = req.query.tweetId as string 26 | 27 | if (!tweetId) { 28 | return res 29 | .status(400) 30 | .send({ error: 'missing required parameter "tweetId"' }) 31 | } 32 | 33 | console.log('getTweetAst', tweetId) 34 | const tweetAst = await fetchTweetAst(tweetId) 35 | console.log('tweetAst', tweetId, tweetAst) 36 | 37 | res.status(200).json(tweetAst) 38 | } 39 | 40 | // Helper method to wait for a middleware to execute before continuing 41 | // And to throw an error when an error happens in a middleware 42 | function initMiddleware(middleware) { 43 | return (req, res) => 44 | new Promise((resolve, reject) => { 45 | middleware(req, res, (result) => { 46 | if (result instanceof Error) { 47 | return reject(result) 48 | } 49 | return resolve(result) 50 | }) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /api/sync-index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | import { twitterClient } from '../lib/server/twitter' 4 | import { getIndex, syncAccount } from '../lib/server/sync' 5 | 6 | export default async ( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ): Promise => { 10 | console.log('syncTweetIndex') 11 | 12 | if (req.method !== 'PUT') { 13 | return res.status(405).send({ error: 'method not allowed' }) 14 | } 15 | 16 | const { full = false } = req.body 17 | 18 | const index = await getIndex() 19 | await syncAccount(twitterClient, index, full) 20 | 21 | res.status(200).json({ 22 | indexName: index.indexName, 23 | exists: true 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Button, CSSReset, ThemeProvider } from '@chakra-ui/core' 4 | 5 | import { QueryParamProvider } from './QueryParamProvider' 6 | import { TweetIndexSearch } from './TweetIndexSearch/TweetIndexSearch' 7 | import { LoadingIndicator } from './LoadingIndicator/LoadingIndicator' 8 | import { Paper } from './Paper/Paper' 9 | import { GitHubShareButton } from './GitHubShareButton' 10 | import * as sdk from '../lib/client/sdk' 11 | 12 | import styles from './styles.module.css' 13 | 14 | export class App extends React.Component { 15 | state = { 16 | status: 'ready', 17 | loading: false, 18 | syncing: false, 19 | searchIndex: { 20 | indexName: 'tweets', 21 | exists: true 22 | }, 23 | error: null 24 | } 25 | 26 | componentDidMount() { 27 | this._reset(false) 28 | } 29 | 30 | render() { 31 | const { status, loading, syncing, searchIndex, error } = this.state 32 | 33 | const isFree = false 34 | 35 | let content = null 36 | 37 | if (status === 'bootstrapping') { 38 | content = ( 39 | 40 | 41 | 42 | ) 43 | } else if (status === 'error') { 44 | content = Error: {error} 45 | } else { 46 | content = ( 47 |
48 | {loading && } 49 | 50 | {syncing ? ( 51 | <> 52 |

Syncing your Tweets...

53 | 54 | {isFree ? ( 55 |

56 | We only sync your 100 most recent tweets on the free plan. 57 |

58 | ) : ( 59 |

60 | Your twitter history will continue syncing in the background. 61 |

62 | )} 63 | 64 | ) : ( 65 | searchIndex && ( 66 | 67 | ) 68 | )} 69 |
70 | ) 71 | } 72 | 73 | return ( 74 | 75 | 76 | 77 | 78 |
79 | 80 | 81 | {!isFree && ( 82 | 90 | )} 91 | 92 | {content} 93 |
94 |
95 |
96 | ) 97 | } 98 | 99 | _reset = (loading = true) => { 100 | this.setState({ loading }) 101 | 102 | sdk 103 | .getIndex() 104 | .then((searchIndex) => { 105 | if (!searchIndex.exists) { 106 | this._sync({ first: true }) 107 | } 108 | 109 | this.setState({ status: 'ready', loading: false, searchIndex }) 110 | }) 111 | .catch((err) => { 112 | console.error(err) 113 | this.setState({ status: 'error', error: err.message, loading: false }) 114 | }) 115 | } 116 | 117 | _sync = (opts: any = {}) => { 118 | this.setState({ loading: true, syncing: true }) 119 | 120 | const onDone = (searchIndex = this.state.searchIndex) => { 121 | this.setState({ 122 | status: 'ready', 123 | loading: false, 124 | syncing: false, 125 | searchIndex 126 | }) 127 | } 128 | 129 | let timeout = null 130 | if (!opts.first && this.state.status !== 'error') { 131 | timeout = setTimeout(onDone, 8000) 132 | } 133 | 134 | sdk 135 | .syncIndex() 136 | .then((searchIndex) => { 137 | if (timeout) { 138 | clearTimeout(timeout) 139 | timeout = null 140 | } 141 | 142 | if (opts.first) { 143 | timeout = setTimeout(() => onDone(searchIndex), 3000) 144 | } else { 145 | onDone(searchIndex) 146 | } 147 | }) 148 | .catch((err) => { 149 | console.error(err) 150 | 151 | if (timeout) { 152 | clearTimeout(timeout) 153 | timeout = null 154 | } 155 | 156 | this.setState({ 157 | status: 'error', 158 | error: err.message, 159 | syncing: false, 160 | loading: false, 161 | searchIndex: null 162 | }) 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /components/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | 4 | import styles from './styles.module.css' 5 | 6 | export const ErrorPage: React.FC<{ statusCode?: number }> = ({ 7 | statusCode 8 | }) => { 9 | const title = 'Error' 10 | 11 | return ( 12 | <> 13 | 14 | {title} 15 | 16 | 17 |
18 |
19 |

Error Loading Page

20 | 21 | {statusCode &&

Error code: {statusCode}

} 22 | 23 | Error 24 |
25 |
26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/GitHubShareButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './styles.module.css' 4 | 5 | export const GitHubShareButton: React.FC = () => { 6 | return ( 7 | 15 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /components/InlineTweet/InlineTweet.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import processString from 'react-process-string' 3 | import BlockImage from 'react-block-image' 4 | import cs from 'classnames' 5 | import qs from 'qs' 6 | 7 | import { Tooltip } from '@chakra-ui/core' 8 | 9 | import Twemoji from './Twemoji' 10 | import styles from './styles.module.css' 11 | 12 | export function InlineTweet(props) { 13 | const { config = {}, className, onFocusTweet, ...rest } = props 14 | const [text, setText] = React.useState(config.text) 15 | 16 | React.useEffect(() => { 17 | setText( 18 | processString([ 19 | { 20 | regex: /((http|https|ftp|ftps):\/\/[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,3}(\/\S*)?)/g, 21 | fn: (key, result) => { 22 | return ( 23 | 24 | {' '} 25 | 30 | {result[1]} 31 | 32 | 33 | ) 34 | } 35 | }, 36 | { 37 | regex: /(?:^|[^a-zA-Z0-9_@!@#$%&*])(?:(?:@|@)(?!\/))([a-zA-Z0-9/_]{1,15})(?:\b(?!@|@)|$)/, 38 | fn: (key, result) => { 39 | return ( 40 | 41 | {' '} 42 | 47 | @{result[1]} 48 | 49 | 50 | ) 51 | } 52 | }, 53 | { 54 | regex: /(?:^|[^a-zA-Z0-9_@!@#$%&*])(?:#(?!\/))([a-zA-Z0-9/_]{1,280})(?:\b(?!#)|$)/, 55 | fn: (key, result) => { 56 | return ( 57 | 58 | {' '} 59 | 66 | #{result[1]} 67 | 68 | 69 | ) 70 | } 71 | }, 72 | { 73 | regex: /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/, 74 | fn: (key, result) => { 75 | return ( 76 | 81 | {result[1]} 82 | 83 | ) 84 | } 85 | }, 86 | { 87 | regex: /([^<]*)<\/ais-highlight-0000000000>/gm, 88 | fn: (key, result) => { 89 | console.log({ key, result }) 90 | return ( 91 | 92 | {' '} 93 | {result[1]} 94 | 95 | ) 96 | } 97 | } 98 | ])(config.text.trim()) 99 | ) 100 | }, [config.text]) 101 | 102 | const onClickTweet = React.useCallback( 103 | (event) => { 104 | event.stopPropagation() 105 | const url = `https://twitter.com/${config.user.nickname}/status/${config.id_str}` 106 | const win = window.open(url, '_blank') 107 | win.focus() 108 | }, 109 | [config.user.nickname, config.id_str] 110 | ) 111 | 112 | return ( 113 |
onFocusTweet(config.id_str)} 116 | onMouseMove={() => onFocusTweet(config.id_str)} 117 | onMouseLeave={() => onFocusTweet(null)} 118 | onClick={onClickTweet} 119 | {...rest} 120 | > 121 |
122 | 128 | 132 | 138 | 139 | 140 |
141 | 142 |
143 |
{text}
144 |
145 |
146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /components/InlineTweet/Twemoji.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import isEqual from 'lodash.isequal' 3 | import PropTypes from 'prop-types' 4 | import twemoji from 'twemoji' 5 | 6 | export default class Twemoji extends React.Component { 7 | static propTypes = { 8 | children: PropTypes.node, 9 | noWrapper: PropTypes.bool, 10 | options: PropTypes.object, 11 | tag: PropTypes.string 12 | } 13 | 14 | static defaultProps = { 15 | tag: 'div' 16 | } 17 | 18 | childrenRefs: any 19 | rootRef: any 20 | 21 | constructor(props) { 22 | super(props) 23 | if (props.noWrapper) { 24 | this.childrenRefs = {} 25 | } else { 26 | this.rootRef = React.createRef() 27 | } 28 | } 29 | 30 | _parseTwemoji() { 31 | const { noWrapper } = this.props 32 | if (noWrapper) { 33 | for (const i in this.childrenRefs) { 34 | const node = this.childrenRefs[i].current 35 | twemoji.parse(node, this.props.options) 36 | } 37 | } else { 38 | const node = this.rootRef.current 39 | twemoji.parse(node, this.props.options) 40 | } 41 | } 42 | 43 | componentDidUpdate(prevProps) { 44 | if (!isEqual(this.props, prevProps)) { 45 | this._parseTwemoji() 46 | } 47 | } 48 | 49 | componentDidMount() { 50 | this._parseTwemoji() 51 | } 52 | 53 | render() { 54 | const { children, noWrapper, tag, ...other } = this.props 55 | if (noWrapper) { 56 | return ( 57 | <> 58 | {React.Children.map(children, (c, i) => { 59 | if (typeof c === 'string') { 60 | return c 61 | } 62 | this.childrenRefs[i] = this.childrenRefs[i] || React.createRef() 63 | return React.cloneElement(c as any, { ref: this.childrenRefs[i] }) 64 | })} 65 | 66 | ) 67 | } else { 68 | delete (other as any).options 69 | return React.createElement(tag, { ref: this.rootRef, ...other }, children) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /components/InlineTweet/styles.module.css: -------------------------------------------------------------------------------- 1 | .tweet { 2 | display: block; 3 | width: 100%; 4 | color: rgb(20, 23, 26); 5 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 6 | Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 7 | 8 | border-radius: 8px; 9 | padding: 6px; 10 | display: flex; 11 | flex-direction: row; 12 | } 13 | 14 | .tweet:hover { 15 | cursor: pointer; 16 | background: #efefef; 17 | } 18 | 19 | .lhs { 20 | margin-right: 0.5em; 21 | } 22 | 23 | .avatar { 24 | width: 32px; 25 | height: 32px; 26 | border-radius: 50%; 27 | } 28 | 29 | .main { 30 | flex: 1; 31 | } 32 | 33 | .body { 34 | color: rgb(20, 23, 26); 35 | font-size: 15px; 36 | white-space: pre-line; 37 | } 38 | 39 | .body .link { 40 | color: rgb(27, 149, 224); 41 | } 42 | 43 | .twemoji-sm, 44 | .twemoji-bg { 45 | display: inline-block; 46 | } 47 | 48 | .twemoji-sm { 49 | position: relative; 50 | top: -2px; 51 | height: 18px; 52 | width: 18px; 53 | } 54 | 55 | .twemoji-bg { 56 | height: 18px; 57 | width: 18px; 58 | } 59 | 60 | .tweet svg { 61 | box-sizing: content-box !important; 62 | vertical-align: baseline !important; 63 | } 64 | 65 | .highlight { 66 | border-radius: 0.5rem; 67 | /* border-bottom-left-radius: 0.125rem; */ 68 | background-image: linear-gradient( 69 | 119deg, 70 | #fff, 71 | #fff697 10.5%, 72 | #fdf59d 85.29%, 73 | #fff 74 | ); 75 | } 76 | 77 | /* div[role='tooltip'][x-placement^='right'] { */ 78 | /* max-width: auto !important; */ 79 | /* } */ 80 | -------------------------------------------------------------------------------- /components/LoadingIndicator/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import cs from 'classnames' 3 | import { Spinner } from '@chakra-ui/core' 4 | 5 | import styles from './styles.module.css' 6 | 7 | export class LoadingIndicator extends Component { 8 | render() { 9 | const { className, ...rest } = this.props 10 | 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/LoadingIndicator/styles.module.css: -------------------------------------------------------------------------------- 1 | .loadingIndicator { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | bottom: 0; 6 | right: 0; 7 | 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | -------------------------------------------------------------------------------- /components/Page404.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './styles.module.css' 4 | 5 | export const Page404: React.FC<{ error?: { message: string } }> = ({ 6 | error 7 | }) => { 8 | return ( 9 | <> 10 |
11 |
12 |

Page Not Found

13 | 14 | {error &&

{error.message}

} 15 | 16 | 404 Not Found 21 |
22 |
23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /components/Paper/Paper.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import cs from 'classnames' 3 | 4 | import styles from './styles.module.css' 5 | 6 | export class Paper extends Component { 7 | render() { 8 | const { className, ...rest } = this.props 9 | 10 | return
11 | } 12 | } 13 | -------------------------------------------------------------------------------- /components/Paper/styles.module.css: -------------------------------------------------------------------------------- 1 | .paper { 2 | position: relative; 3 | padding: 24px; 4 | 5 | background: #fff; 6 | border-radius: 4px; 7 | box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2), 8 | 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.12); 9 | } 10 | -------------------------------------------------------------------------------- /components/QueryParamProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useMemo } from 'react' 2 | import { useRouter } from 'next/router' 3 | import { QueryParamProvider as ContextProvider } from 'use-query-params' 4 | 5 | export const QueryParamProviderComponent = (props: { 6 | children?: React.ReactNode 7 | }) => { 8 | const { children, ...rest } = props 9 | const router = useRouter() 10 | const match = router.asPath.match(/[^?]+/) 11 | const pathname = match ? match[0] : router.asPath 12 | 13 | const location = useMemo( 14 | () => 15 | process.browser 16 | ? window.location 17 | : ({ 18 | search: router.asPath.replace(/[^?]+/u, '') 19 | } as Location), 20 | [router.asPath] 21 | ) 22 | 23 | const history = useMemo( 24 | () => ({ 25 | push: ({ search }: Location) => 26 | router.push( 27 | { pathname: router.pathname, query: router.query }, 28 | { search, pathname }, 29 | { shallow: true } 30 | ), 31 | replace: ({ search }: Location) => 32 | router.replace( 33 | { pathname: router.pathname, query: router.query }, 34 | { search, pathname }, 35 | { shallow: true } 36 | ) 37 | }), 38 | [pathname, router.pathname, router.query, location.pathname] 39 | // [pathname, router] 40 | ) 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | ) 47 | } 48 | 49 | export const QueryParamProvider = memo(QueryParamProviderComponent) 50 | -------------------------------------------------------------------------------- /components/TweetIndexSearch/TweetIndexSearch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Masonry from 'react-masonry-css' 3 | import { Tweet } from 'react-static-tweets' 4 | import cs from 'classnames' 5 | import debounce from 'lodash.debounce' 6 | 7 | import { 8 | withQueryParams, 9 | StringParam, 10 | BooleanParam, 11 | withDefault 12 | } from 'use-query-params' 13 | 14 | // TODO: add infinite scroll instead of manual "load more" button 15 | // import { InfiniteScroll } from 'react-simple-infinite-scroll' 16 | 17 | import { 18 | InstantSearch, 19 | Configure, 20 | connectInfiniteHits, 21 | connectSearchBox, 22 | connectToggleRefinement, 23 | connectMenu, 24 | connectStats 25 | } from 'react-instantsearch-dom' 26 | 27 | import { 28 | Button, 29 | Flex, 30 | FormLabel, 31 | Icon, 32 | Input, 33 | InputGroup, 34 | InputLeftElement, 35 | InputRightElement, 36 | Select, 37 | Switch, 38 | Tooltip 39 | } from '@chakra-ui/core' 40 | 41 | import { searchClient } from 'lib/client/algolia' 42 | import { InlineTweet } from '../InlineTweet/InlineTweet' 43 | 44 | import styles from './styles.module.css' 45 | 46 | // TODO: add ability to filter by replies vs top-level tweets 47 | const tooltips = { 48 | is_retweet: 'Do you want to include tweets that you retweeted?', 49 | is_favorite: 'Do you want to include tweets that you liked (favorited)?' 50 | } 51 | 52 | const SearchConfig = React.createContext({}) 53 | 54 | export class TweetIndexSearchImpl extends React.Component { 55 | state = { 56 | focusedTweet: null 57 | } 58 | 59 | _onChangeSearchQueryThrottle: any 60 | 61 | constructor(props) { 62 | super(props) 63 | 64 | this._onChangeSearchQueryThrottle = debounce( 65 | this._onChangeSearchQuery.bind(this), 66 | 2000 67 | ) 68 | } 69 | 70 | componentWillUnmount() { 71 | this._onChangeSearchQueryThrottle.cancel() 72 | } 73 | 74 | render() { 75 | const { indexName, query } = this.props 76 | const { focusedTweet } = this.state 77 | const { 78 | format: resultsFormat, 79 | likes: includeLikes, 80 | retweets: includeRetweets, 81 | query: searchQuery = '', 82 | user: userFilter 83 | } = query 84 | 85 | // console.log('render', query) 86 | const searchQueryDecoded = decodeURIComponent(searchQuery) 87 | 88 | return ( 89 | 92 | 93 | 94 | 95 | 100 | 101 |
102 | 108 | 109 | 116 | 117 | 124 | 125 | 136 |
137 | 138 | 139 | 140 |
141 | {focusedTweet && ( 142 |
143 | 144 |
145 | )} 146 | 147 | 148 |
149 |
150 |
151 | ) 152 | } 153 | 154 | _onChangeResultsFormat = (event) => { 155 | const { value } = event.target 156 | 157 | if (value !== 'list') { 158 | this.props.setQuery({ format: value }) 159 | } else { 160 | this.props.setQuery({ format: undefined }) 161 | } 162 | } 163 | 164 | _onChangeIncludeLikes = (value) => { 165 | if (value) { 166 | this.props.setQuery({ likes: !value }) 167 | } else { 168 | this.props.setQuery({ likes: undefined }) 169 | } 170 | } 171 | 172 | _onChangeIncludeRetweets = (value) => { 173 | if (value) { 174 | this.props.setQuery({ retweets: !value }) 175 | } else { 176 | this.props.setQuery({ retweets: undefined }) 177 | } 178 | } 179 | 180 | _onChangeSearchQuery = (value = '') => { 181 | if (value) { 182 | const query = encodeURIComponent(value) 183 | this.props.setQuery({ query }) 184 | } else { 185 | this.props.setQuery({ query: undefined }) 186 | } 187 | } 188 | 189 | _onChangeUserFilter = (value) => { 190 | if (value) { 191 | this.props.setQuery({ user: value }) 192 | } else { 193 | this.props.setQuery({ user: undefined }) 194 | } 195 | } 196 | 197 | _onFocusTweet = (tweetId) => { 198 | this.setState({ focusedTweet: tweetId }) 199 | } 200 | } 201 | 202 | const SearchBoxImpl = ({ 203 | currentRefinement, 204 | isSearchStalled, 205 | refine, 206 | onChange 207 | }) => ( 208 |
{ 214 | e.preventDefault() 215 | return false 216 | }} 217 | > 218 | 219 | } /> 220 | 221 | { 229 | const { value } = event.currentTarget 230 | 231 | refine(value) 232 | 233 | if (onChange) { 234 | onChange(value) 235 | } 236 | }} 237 | /> 238 | 239 | {isSearchStalled && ( 240 | } 242 | /> 243 | )} 244 | 245 |
246 | ) 247 | 248 | const SearchBox = connectSearchBox(SearchBoxImpl) 249 | 250 | const StatsImpl = ({ processingTimeMS, nbHits }) => ( 251 |

252 | Found {nbHits} tweets in {processingTimeMS} ms 253 |

254 | ) 255 | 256 | const Stats = connectStats(StatsImpl) 257 | 258 | const InfiniteHitsImpl = ({ hits, hasMore, refineNext }) => { 259 | return ( 260 | 261 | {(config) => { 262 | const body = hits.map((hit) => ( 263 | 264 | )) 265 | 266 | return ( 267 |
270 | {/* */} 276 | {config.resultsFormat === 'grid' ? ( 277 | 282 | {body} 283 | 284 | ) : ( 285 | body 286 | )} 287 | {/* */} 288 | 289 | 296 |
297 | ) 298 | }} 299 |
300 | ) 301 | } 302 | 303 | const InfiniteHits = connectInfiniteHits(InfiniteHitsImpl) 304 | 305 | const ToggleRefinementImpl = ({ 306 | currentRefinement, 307 | attribute, 308 | label, 309 | refine, 310 | onChange 311 | }) => ( 312 | 313 | 320 | {label} 321 | 322 | 323 | { 327 | const newValue = !currentRefinement 328 | 329 | if (onChange) { 330 | onChange(newValue) 331 | } else { 332 | refine(newValue) 333 | } 334 | }} 335 | /> 336 | 337 | ) 338 | 339 | const ToggleRefinement = connectToggleRefinement(ToggleRefinementImpl) 340 | 341 | const MenuImpl = ({ 342 | attribute, 343 | items, 344 | currentRefinement, 345 | refine, 346 | onChange 347 | }) => ( 348 | 369 | ) 370 | 371 | const Menu = connectMenu(MenuImpl) 372 | 373 | export class Hit extends React.Component { 374 | render() { 375 | const { hit, config, ...rest } = this.props 376 | 377 | if (config.resultsFormat === 'compact') { 378 | return ( 379 | 396 | ) 397 | } else { 398 | return ( 399 | 400 | ) 401 | } 402 | } 403 | } 404 | 405 | export const TweetIndexSearch = withQueryParams( 406 | { 407 | format: withDefault(StringParam, 'list'), 408 | likes: withDefault(BooleanParam, true), 409 | retweets: withDefault(BooleanParam, true), 410 | query: StringParam, 411 | user: StringParam 412 | }, 413 | TweetIndexSearchImpl 414 | ) 415 | -------------------------------------------------------------------------------- /components/TweetIndexSearch/styles.module.css: -------------------------------------------------------------------------------- 1 | .searchBox { 2 | width: 100%; 3 | margin-bottom: 24px; 4 | } 5 | 6 | .filters { 7 | display: flex; 8 | justify-content: center; 9 | margin-bottom: 12px; 10 | } 11 | 12 | .stats { 13 | text-align: center; 14 | font-size: 0.8em; 15 | margin-bottom: 12px; 16 | } 17 | 18 | .filter { 19 | margin-right: 5%; 20 | } 21 | 22 | .filter:last-child { 23 | margin-right: 0; 24 | } 25 | 26 | .select { 27 | flex: 1; 28 | width: auto !important; 29 | margin-right: 5%; 30 | } 31 | 32 | .resultsFormat { 33 | width: auto !important; 34 | margin-left: 5%; 35 | } 36 | 37 | .infiniteHits { 38 | width: 100%; 39 | display: flex; 40 | flex-direction: column; 41 | align-items: center; 42 | } 43 | 44 | .loadMore { 45 | margin: 1em 0; 46 | } 47 | 48 | .hits { 49 | display: flex; 50 | width: 100%; 51 | } 52 | 53 | .hitsColumn { 54 | background-clip: padding-box; 55 | margin-right: 12px; 56 | } 57 | 58 | .hitsColumn:last-child { 59 | margin-right: 0; 60 | } 61 | 62 | .hit { 63 | margin-bottom: 12px; 64 | } 65 | 66 | .hit :global(.twitter-tweet) iframe { 67 | width: 550px !important; 68 | } 69 | 70 | .compact .hit { 71 | margin-bottom: 0; 72 | } 73 | 74 | .loadMoreButton { 75 | margin-top: 1em; 76 | align-self: center; 77 | } 78 | 79 | .results { 80 | position: relative; 81 | } 82 | 83 | .focusedTweet { 84 | position: fixed; 85 | z-index: 1000; 86 | width: calc(min(400px, 40vw)); 87 | top: 0; 88 | bottom: 0; 89 | right: 0; 90 | display: flex; 91 | flex-direction: column; 92 | justify-content: center; 93 | } 94 | -------------------------------------------------------------------------------- /components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Page404' 2 | export * from './ErrorPage' 3 | export * from './App' 4 | -------------------------------------------------------------------------------- /components/styles.module.css: -------------------------------------------------------------------------------- 1 | @keyframes spinner { 2 | to { 3 | transform: rotate(360deg); 4 | } 5 | } 6 | 7 | .container { 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | right: 0; 12 | bottom: 0; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | padding: 2vmin; 17 | 18 | font-size: 16px; 19 | line-height: 1.5; 20 | color: rgb(55, 53, 47); 21 | caret-color: rgb(55, 53, 47); 22 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, 23 | 'Apple Color Emoji', Arial, sans-serif, 'Segoe UI Emoji', 'Segoe UI Symbol'; 24 | } 25 | 26 | .loadingIcon { 27 | animation: spinner 0.6s linear infinite; 28 | display: block; 29 | width: 24px; 30 | height: 24px; 31 | color: rgba(55, 53, 47, 0.4); 32 | } 33 | 34 | .main { 35 | display: flex; 36 | flex-direction: column; 37 | justify-content: center; 38 | align-items: center; 39 | } 40 | 41 | .errorImage { 42 | max-width: 100%; 43 | width: 640px; 44 | } 45 | 46 | .body { 47 | position: relative; 48 | width: 100%; 49 | max-width: 100vw; 50 | min-height: 100vh; 51 | 52 | display: flex; 53 | flex-direction: column; 54 | justify-content: flex-start; 55 | align-items: center; 56 | 57 | padding: 12px; 58 | } 59 | 60 | .content { 61 | max-width: 1024px; 62 | width: 100%; 63 | margin: 0 auto; 64 | } 65 | 66 | .breadcrumb { 67 | margin-bottom: 1em; 68 | } 69 | 70 | .syncButton { 71 | align-self: center; 72 | margin-bottom: 24px; 73 | } 74 | 75 | @media (max-width: 420px) { 76 | .githubCorner { 77 | display: none; 78 | } 79 | } 80 | 81 | .githubCorner:hover .octoArm { 82 | animation: octocat-wave 560ms ease-in-out; 83 | } 84 | 85 | @keyframes octocat-wave { 86 | 0%, 87 | 100% { 88 | transform: rotate(0); 89 | } 90 | 20%, 91 | 60% { 92 | transform: rotate(-25deg); 93 | } 94 | 40%, 95 | 80% { 96 | transform: rotate(10deg); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/client/algolia.js: -------------------------------------------------------------------------------- 1 | import algoliasearch from 'algoliasearch/lite' 2 | 3 | export const searchClient = algoliasearch( 4 | process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID, 5 | process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY 6 | ) 7 | -------------------------------------------------------------------------------- /lib/client/bootstrap.ts: -------------------------------------------------------------------------------- 1 | export function bootstrap() { 2 | console.log(` 3 | 4 | ████████╗██████╗ █████╗ ███╗ ██╗███████╗██╗████████╗██╗██╗ ██╗███████╗ ██████╗ ███████╗ 5 | ╚══██╔══╝██╔══██╗██╔══██╗████╗ ██║██╔════╝██║╚══██╔══╝██║██║ ██║██╔════╝ ██╔══██╗██╔════╝ 6 | ██║ ██████╔╝███████║██╔██╗ ██║███████╗██║ ██║ ██║██║ ██║█████╗ ██████╔╝███████╗ 7 | ██║ ██╔══██╗██╔══██║██║╚██╗██║╚════██║██║ ██║ ██║╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║ 8 | ██║ ██║ ██║██║ ██║██║ ╚████║███████║██║ ██║ ██║ ╚████╔╝ ███████╗ ██████╔╝███████║ 9 | ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚══════╝ 10 | 11 | This experiment is built using Next.js, Vercel, Algolia, and the Twitter API. 12 | https://github.com/transitive-bullshit/twitter-search 13 | `) 14 | } 15 | -------------------------------------------------------------------------------- /lib/client/sdk.ts: -------------------------------------------------------------------------------- 1 | export async function getIndex() { 2 | return fetch('/api/get-index').then((res) => res.json()) 3 | } 4 | 5 | export async function syncIndex() { 6 | return fetch('/api/sync-index', { 7 | method: 'PUT', 8 | headers: { 9 | 'Content-Type': 'application/json' 10 | } 11 | }).then((res) => res.json()) 12 | } 13 | -------------------------------------------------------------------------------- /lib/server/algolia.ts: -------------------------------------------------------------------------------- 1 | import algoliasearch from 'algoliasearch' 2 | export * from 'algoliasearch' 3 | 4 | export const client = algoliasearch( 5 | process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID, 6 | process.env.ALGOLIA_SECRET_KEY 7 | ) 8 | -------------------------------------------------------------------------------- /lib/server/sync.ts: -------------------------------------------------------------------------------- 1 | import pRetry from 'p-retry' 2 | import pMap from 'p-map' 3 | import { fetchTweetAst } from 'static-tweets' 4 | import * as algolia from './algolia' 5 | 6 | const algoliaIndexName = process.env.ALGOLIA_INDEX_NAME || 'tweets' 7 | const MAX_PAGE_SIZE = 200 8 | const MAX_RESULTS = 20000 9 | 10 | export async function syncAccount( 11 | twitterClient: any, 12 | index: algolia.SearchIndex, 13 | full = false 14 | ) { 15 | await configureIndex(index) 16 | 17 | const user = await twitterClient.get('account/verify_credentials') 18 | const opts: any = {} 19 | 20 | // query most recent tweets in the index to support partial re-indexing 21 | if (!full) { 22 | const resultSet = await index.search('', { 23 | offset: 0, 24 | length: MAX_PAGE_SIZE 25 | }) 26 | const latest: any = resultSet.hits[resultSet.hits.length - 1] 27 | if (latest) { 28 | opts.since_id = latest.id_str 29 | } 30 | } 31 | console.log('sync twitter user', user.id_str, opts) 32 | 33 | const handlePage = async (results: any[]) => { 34 | const algoliaObjects = await tweetsToAlgoliaObjects(results, user) 35 | 36 | // const id = '1255522888834318339' 37 | // const tweet = results.find((t) => t.id_str === id) 38 | // const obj = algoliaObjects.find((t) => t.id_str === id) 39 | // console.debug(tweet, obj) 40 | 41 | await index.saveObjects(algoliaObjects) 42 | } 43 | 44 | const [numStatuses, numFavorites] = await Promise.all([ 45 | resolvePagedTwitterQuery( 46 | twitterClient, 47 | 'statuses/user_timeline', 48 | { 49 | include_rts: true, 50 | ...opts 51 | }, 52 | handlePage 53 | ), 54 | resolvePagedTwitterQuery(twitterClient, 'favorites/list', opts, handlePage) 55 | ]) 56 | 57 | console.log('statuses', numStatuses, 'favorites', numFavorites) 58 | return { 59 | numStatuses, 60 | numFavorites 61 | } 62 | } 63 | 64 | export async function getIndex() { 65 | return algolia.client.initIndex(algoliaIndexName) 66 | } 67 | 68 | export async function configureIndex(index: algolia.SearchIndex) { 69 | await index.setSettings({ 70 | searchableAttributes: ['text', 'user.name,user.screen_name'], 71 | 72 | // only highlight results in the text field 73 | // attributesToHighlight: ['text'], 74 | 75 | attributesForFaceting: [ 76 | 'filterOnly(is_retweet)', 77 | 'filterOnly(is_favorite)', 78 | 'user.screen_name' 79 | ], 80 | 81 | // tweets will be ranked by total count with retweets 82 | // counting more that other interactions, falling back to date 83 | customRanking: [ 84 | 'desc(created_at)', 85 | 'desc(total_count)', 86 | 'desc(retweet_count)' 87 | ], 88 | 89 | // return these attributes for dislaying in search results 90 | attributesToRetrieve: [ 91 | 'id_str', 92 | 'is_retweet', 93 | 'is_favorite', 94 | 'text', 95 | 'created_at', 96 | 'retweet_count', 97 | 'favorite_count', 98 | 'total_count', 99 | 'user', 100 | 'user.name', 101 | 'user.screen_name', 102 | 'user.profile_image_url', 103 | 'ast' 104 | ], 105 | 106 | // make plural and singular matches count the same for these langs 107 | ignorePlurals: ['en'] 108 | }) 109 | } 110 | 111 | async function resolvePagedTwitterQuery( 112 | twitterClient, 113 | endpoint: string, 114 | opts: any, 115 | handlePage: (results: any[]) => Promise 116 | ): Promise { 117 | let numResults = 0 118 | let maxId: string 119 | let page = 0 120 | 121 | const count = MAX_PAGE_SIZE 122 | 123 | do { 124 | const params = { count, tweet_mode: 'extended', ...opts } 125 | if (maxId) { 126 | params.max_id = maxId 127 | } 128 | 129 | let pageResults 130 | 131 | try { 132 | pageResults = await resolveTwitterQueryPage( 133 | twitterClient, 134 | endpoint, 135 | params 136 | ) 137 | } catch (err) { 138 | console.error('twitter error', { endpoint, page, numResults }, err) 139 | 140 | if (numResults <= 0) { 141 | throw err 142 | } 143 | } 144 | 145 | if (!pageResults.length || (page > 0 && pageResults.length <= 1)) { 146 | break 147 | } 148 | 149 | if (maxId) { 150 | pageResults = pageResults.slice(1) 151 | } 152 | 153 | maxId = pageResults[pageResults.length - 1].id_str 154 | numResults += pageResults.length 155 | 156 | console.log( 157 | 'twitter', 158 | endpoint, 159 | `page=${page} size=${pageResults.length} total=${numResults}` 160 | ) 161 | 162 | await handlePage(pageResults) 163 | 164 | if (numResults > MAX_RESULTS) { 165 | break 166 | } 167 | 168 | ++page 169 | } while (true) 170 | 171 | return numResults 172 | } 173 | 174 | async function resolveTwitterQueryPage( 175 | twitterClient, 176 | endpoint: string, 177 | opts: any 178 | ) { 179 | console.log('twitter', endpoint, opts) 180 | 181 | return pRetry(() => twitterClient.get(endpoint, opts), { 182 | retries: 3, 183 | maxTimeout: 10000 184 | }) 185 | } 186 | 187 | async function tweetsToAlgoliaObjects(tweets, user) { 188 | // iterate over tweets and build the corresponding algolia records 189 | return pMap( 190 | tweets, 191 | async (tweet: any) => { 192 | // create the record that will be sent to algolia if there is text to index 193 | const algoliaTweet: any = { 194 | // use id_str not id (an int), as this int gets truncated in JS 195 | // the objectID is the key for the algolia record, and mapping 196 | // tweet id to object ID guarantees only one copy of the tweet in algolia 197 | objectID: tweet.id_str, 198 | id_str: tweet.id_str, 199 | text: tweet.full_text || tweet.text || '', 200 | created_at: Date.parse(tweet.created_at) / 1000, 201 | favorite_count: tweet.favorite_count, 202 | retweet_count: tweet.retweet_count, 203 | total_count: tweet.retweet_count + tweet.favorite_count, 204 | is_retweet: !!tweet.retweeted_status, 205 | is_favorite: !!tweet.favorited && user.id_str !== tweet.user.id_str, 206 | user: { 207 | id_str: tweet.user.id_str, 208 | name: tweet.user.name, 209 | screen_name: tweet.user.screen_name, 210 | profile_image_url: tweet.user.profile_image_url 211 | } 212 | } 213 | 214 | try { 215 | console.log('twitter', '>>> fetchTweetAst', tweet.id_str) 216 | algoliaTweet.ast = await fetchTweetAst(tweet.id_str) 217 | console.log( 218 | 'twitter', 219 | '<<< fetchTweetAst', 220 | tweet.id_str, 221 | algoliaTweet.ast 222 | ) 223 | } catch (err) { 224 | console.error('error fetching tweet ast', tweet.id_str, err) 225 | } 226 | 227 | return algoliaTweet 228 | }, 229 | { 230 | concurrency: 4 231 | } 232 | ) 233 | } 234 | -------------------------------------------------------------------------------- /lib/server/twitter.ts: -------------------------------------------------------------------------------- 1 | import Twitter from 'twitter-lite' 2 | 3 | export const twitterClient = new Twitter({ 4 | consumer_key: process.env.TWITTER_CONSUMER_KEY, 5 | consumer_secret: process.env.TWITTER_CONSUMER_SECRET, 6 | access_token_key: process.env.TWITTER_ACCESS_TOKEN, 7 | access_token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET 8 | }) 9 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface HttpBody { 2 | [key: string]: any 3 | } 4 | 5 | export interface HttpQuery { 6 | [key: string]: string 7 | } 8 | 9 | export interface HttpHeaders { 10 | [key: string]: string 11 | } 12 | 13 | export interface TweetIndex { 14 | indexName: string 15 | exists: boolean 16 | } 17 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Travis Fischer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /media/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transitive-bullshit/twitter-search/9e60221f5f79b7adc43a112d16cab94dd4498eb2/media/favicon.ico -------------------------------------------------------------------------------- /media/icons/cluster.svg: -------------------------------------------------------------------------------- 1 | server_cluster -------------------------------------------------------------------------------- /media/icons/customizable.svg: -------------------------------------------------------------------------------- 1 | control panel1 -------------------------------------------------------------------------------- /media/icons/fast.svg: -------------------------------------------------------------------------------- 1 | fast loading -------------------------------------------------------------------------------- /media/icons/global.svg: -------------------------------------------------------------------------------- 1 | connected world -------------------------------------------------------------------------------- /media/icons/logs.svg: -------------------------------------------------------------------------------- 1 | server status -------------------------------------------------------------------------------- /media/icons/open-source.svg: -------------------------------------------------------------------------------- 1 | open source -------------------------------------------------------------------------------- /media/icons/search.svg: -------------------------------------------------------------------------------- 1 | Search -------------------------------------------------------------------------------- /media/icons/stripe.svg: -------------------------------------------------------------------------------- 1 | stripe payments -------------------------------------------------------------------------------- /media/icons/sync.svg: -------------------------------------------------------------------------------- 1 | code typing -------------------------------------------------------------------------------- /media/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/screenshot-search-ui-0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/transitive-bullshit/twitter-search/9e60221f5f79b7adc43a112d16cab94dd4498eb2/media/screenshot-search-ui-0.jpg -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: process.env.ANALYZE === 'true' 3 | }) 4 | 5 | module.exports = withBundleAnalyzer({ 6 | images: { 7 | domains: ['pbs.twimg.com'] 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-search", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Algolia for searching your Tweets.", 6 | "repository": "transitive-bullshit/twitter-search", 7 | "author": "Travis Fischer ", 8 | "license": "MIT", 9 | "engines": { 10 | "node": ">=10" 11 | }, 12 | "scripts": { 13 | "dev": "next dev", 14 | "build": "next build", 15 | "start": "next start", 16 | "analyze": "cross-env ANALYZE=true next build", 17 | "analyze:server": "cross-env BUNDLE_ANALYZE=server next build", 18 | "analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build", 19 | "deps": "run-s deps:*", 20 | "deps:update": "[ -z $GITHUB_ACTIONS ] && yarn add static-tweets react-static-tweets || echo 'Skipping deps:update on CI'", 21 | "deps:link": "[ -z $GITHUB_ACTIONS ] && yarn link static-tweets react-static-tweets || echo 'Skipping deps:link on CI'", 22 | "test": "run-s test:*", 23 | "test:lint": "eslint .", 24 | "test:prettier": "prettier '**/*.{js,jsx,ts,tsx}' --check", 25 | "posttest": "run-s build" 26 | }, 27 | "dependencies": { 28 | "@chakra-ui/core": "^0.7.0", 29 | "@emotion/core": "^10.0.28", 30 | "@emotion/styled": "^10.0.27", 31 | "algoliasearch": "^4.8.5", 32 | "cors": "^2.8.5", 33 | "date-fns": "^2.17.0", 34 | "emotion-theming": "^10.0.27", 35 | "fathom-client": "^3.0.0", 36 | "lodash.debounce": "^4.0.8", 37 | "lodash.isequal": "^4.5.0", 38 | "lodash.pick": "^4.4.0", 39 | "next": "^10.0.6", 40 | "p-map": "^4.0.0", 41 | "p-retry": "^4.3.0", 42 | "prop-types": "^15.7.2", 43 | "qs": "^6.9.3", 44 | "query-string": "^6.14.0", 45 | "react": "^17.0.1", 46 | "react-block-image": "^1.0.0", 47 | "react-dom": "^17.0.1", 48 | "react-instantsearch-dom": "^6.4.0", 49 | "react-masonry-css": "^1.0.14", 50 | "react-process-string": "^1.2.0", 51 | "react-static-tweets": "^0.2.6", 52 | "react-tweet-embed": "^1.2.2", 53 | "static-tweets": "^0.2.1", 54 | "swr": "^0.4.2", 55 | "twemoji": "^12.1.6", 56 | "twitter-lite": "^1.1.0", 57 | "use-query-params": "^1.1.9" 58 | }, 59 | "devDependencies": { 60 | "@next/bundle-analyzer": "^10.0.6", 61 | "@types/classnames": "^2.2.10", 62 | "@types/node": "^14.14.22", 63 | "@types/react": "^17.0.2", 64 | "@typescript-eslint/eslint-plugin": "^4.14.1", 65 | "@typescript-eslint/parser": "^4.14.1", 66 | "babel-eslint": "^10.0.3", 67 | "cross-env": "^7.0.2", 68 | "eslint": "^7.18.0", 69 | "eslint-config-prettier": "^7.2.0", 70 | "eslint-config-standard": "^16.0.2", 71 | "eslint-config-standard-react": "^11.0.1", 72 | "eslint-plugin-import": "^2.21.2", 73 | "eslint-plugin-node": "^11.0.0", 74 | "eslint-plugin-prettier": "^3.1.1", 75 | "eslint-plugin-promise": "^4.2.1", 76 | "eslint-plugin-react": "^7.18.0", 77 | "npm-run-all": "^4.1.5", 78 | "prettier": "^2.0.5", 79 | "typescript": "^4.1.3" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Page404 } from 'components' 2 | 3 | export default Page404 4 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | // global styles shared across the entire site 2 | import 'styles/global.css' 3 | 4 | // core styles shared by all of react-static-tweets (required) 5 | import 'react-static-tweets/styles.css' 6 | 7 | import React from 'react' 8 | import Head from 'next/head' 9 | 10 | import { bootstrap } from 'lib/client/bootstrap' 11 | 12 | if (typeof window !== 'undefined') { 13 | bootstrap() 14 | } 15 | 16 | export default function App({ Component, pageProps }) { 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Document, { Html, Head, Main, NextScript } from 'next/document' 3 | 4 | export default class MyDocument extends Document { 5 | render() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 22 | 28 | 34 | 40 | 41 | 42 | 43 | 44 | 45 |