├── styles.css ├── Procfile ├── .gitignore ├── react-ui ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── loading.gif │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── src │ ├── index.js │ ├── components │ │ ├── Loading.js │ │ ├── PageSelector.js │ │ ├── QueryOrderSelector.js │ │ ├── QueryTypeSelector.js │ │ ├── QueryForm.js │ │ ├── ListTweets.js │ │ ├── InfoPanel.js │ │ └── App.js │ └── index.css ├── .gitignore ├── package.json ├── README.md └── .eslintcache ├── server ├── comparators.js ├── sorting.js ├── app.js └── twitter.js ├── package.json └── README.md /styles.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | npm start -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules -------------------------------------------------------------------------------- /react-ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /react-ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/averywlittle/tweetsort/HEAD/react-ui/public/favicon.ico -------------------------------------------------------------------------------- /react-ui/public/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/averywlittle/tweetsort/HEAD/react-ui/public/loading.gif -------------------------------------------------------------------------------- /react-ui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/averywlittle/tweetsort/HEAD/react-ui/public/logo192.png -------------------------------------------------------------------------------- /react-ui/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/averywlittle/tweetsort/HEAD/react-ui/public/logo512.png -------------------------------------------------------------------------------- /react-ui/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './components/App' 4 | import './index.css' 5 | 6 | ReactDOM.render(, document.getElementById('root')) -------------------------------------------------------------------------------- /react-ui/src/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Loading = (props) => { 4 | 5 | if (props.loading === true) { 6 | return ( 7 |
8 | loading symbol 9 |
10 | ) 11 | } else { 12 | return null 13 | } 14 | } 15 | 16 | export default Loading -------------------------------------------------------------------------------- /react-ui/.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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /react-ui/src/components/PageSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const PageSelector = (props) => { 4 | if (props.tweetsLength === 0) { 5 | return null 6 | } 7 | 8 | return ( 9 |
10 | 11 | {props.page} 12 | 13 |
14 | ) 15 | } 16 | 17 | export default PageSelector -------------------------------------------------------------------------------- /react-ui/src/components/QueryOrderSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const QueryOrderSelector = (props) => ( 4 |
5 | 12 | order 13 |
14 | ) 15 | 16 | export default QueryOrderSelector -------------------------------------------------------------------------------- /react-ui/src/components/QueryTypeSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const QueryTypeSelector = (props) => ( 4 |
5 | 13 |
14 | ) 15 | 16 | export default QueryTypeSelector -------------------------------------------------------------------------------- /react-ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /react-ui/src/components/QueryForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const QueryForm = (props) => ( 4 |
5 | @ { 7 | if (event.key === 'Enter') { 8 | console.log("Enter!") 9 | props.queryTweets() 10 | } 11 | }}/> 12 |
13 |
14 | ) 15 | 16 | export default QueryForm -------------------------------------------------------------------------------- /react-ui/src/components/ListTweets.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tweet } from 'react-twitter-widgets' 3 | 4 | const ListTweets = (props) => { 5 | 6 | const tweets = props.tweets 7 | if(tweets.length === 0) return null 8 | 9 | // If page 1 => tweets 0 to 10 10 | // If page 10 => tweets 100 to 110 11 | 12 | const maxTweetIndex = props.page * 10 13 | 14 | const renderedTweets = tweets.slice(maxTweetIndex - 10, maxTweetIndex) 15 | 16 | const content = renderedTweets.map(tweet => 17 |
18 | 19 |

20 |
21 | ) 22 | return content 23 | } 24 | 25 | export default ListTweets -------------------------------------------------------------------------------- /server/comparators.js: -------------------------------------------------------------------------------- 1 | 2 | const byFavorites = (left, right) => { 3 | if (left.favorite_count > right.favorite_count) return true 4 | else if (left.favorite_count <= right.favorite_count) return false 5 | } 6 | 7 | const byDate = (left, right) => { 8 | // Convert to dates to enable comparisons 9 | const leftDate = new Date(left.created_at) 10 | const rightDate = new Date(right.created_at) 11 | 12 | if (leftDate > rightDate) return true 13 | else if (leftDate <= rightDate) return false 14 | } 15 | 16 | const byReach = (left, right) => { 17 | if (left.retweet_count > right.retweet_count) return true 18 | else if (left.retweet_count <= right.retweet_count) return false 19 | } 20 | 21 | exports.options = { byFavorites, byDate, byReach } 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tweetsort", 3 | "version": "1.0.0", 4 | "description": "A tool to sort through a user's tweets", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server/app.js", 9 | "dev": "nodemon server/app.js", 10 | "build": "cd react-ui/ && npm install && npm run build --prod && cp -r build ../" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/averywlittle/tweetsort.git" 15 | }, 16 | "keywords": [ 17 | "tweet", 18 | "sort", 19 | "twitter", 20 | "top" 21 | ], 22 | "author": "@averywlittle", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/averywlittle/tweetsort/issues" 26 | }, 27 | "homepage": "https://tweetsort.io", 28 | "dependencies": { 29 | "cors": "^2.8.5", 30 | "dotenv": "^8.2.0", 31 | "express": "^4.17.1", 32 | "nodemon": "^2.0.6", 33 | "twitter": "^1.7.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /react-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.6", 7 | "@testing-library/react": "^11.2.2", 8 | "@testing-library/user-event": "^12.6.0", 9 | "axios": "^0.21.1", 10 | "react": "^17.0.1", 11 | "react-dom": "^17.0.1", 12 | "react-scripts": "4.0.1", 13 | "react-twitter-widgets": "^1.9.5", 14 | "web-vitals": "^0.2.4" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "proxy": "http://localhost:3001", 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app", 26 | "react-app/jest" 27 | ] 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/sorting.js: -------------------------------------------------------------------------------- 1 | 2 | const mergeSort = (unsortedArray, mergeParams) => { 3 | // No need to sort the array if the array only has one element or empty 4 | if (unsortedArray.length <= 1) { 5 | return unsortedArray 6 | } 7 | // In order to divide the array in half, we need to figure out the middle 8 | const middle = Math.floor(unsortedArray.length / 2) 9 | 10 | // This is where we will be dividing the array into left and right 11 | const left = unsortedArray.slice(0, middle) 12 | const right = unsortedArray.slice(middle) 13 | 14 | // Using recursion to combine the left and right 15 | return merge( 16 | mergeSort(left, mergeParams), mergeSort(right, mergeParams), mergeParams 17 | ) 18 | } 19 | 20 | const merge = (left, right, mergeParams) => { 21 | let resultArray = [], leftIndex = 0, rightIndex = 0 22 | 23 | // Concatenate values to result array in order 24 | while (leftIndex < left.length && rightIndex < right.length) { 25 | 26 | let comparison = mergeParams.comparator(left[leftIndex], right[rightIndex]) 27 | 28 | // Already returned in descending order, so just flip if ascending was sent 29 | if (mergeParams.order === "asc") { 30 | comparison = !comparison 31 | } 32 | 33 | if (comparison) { 34 | resultArray.push(left[leftIndex]); 35 | leftIndex++; // move left array cursor 36 | } else { 37 | resultArray.push(right[rightIndex]); 38 | rightIndex++; // move right array cursor 39 | } 40 | } 41 | 42 | // Capture possible leftover elements 43 | return resultArray 44 | .concat(left.slice(leftIndex)) 45 | .concat(right.slice(rightIndex)) 46 | } 47 | 48 | exports.sortTweets = mergeSort -------------------------------------------------------------------------------- /react-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | tweetsort 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /react-ui/src/components/InfoPanel.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ListTweets = (props) => { 4 | 5 | if (props.error) { 6 | return ( 7 |
8 |

Oops. Something went wrong.

9 |

Make sure the user you're looking for is a public user. Also, make sure the user exists! Otherwise, know that Twitter can throttle their data access for apps like this if it's being used too much. Try again, and if it still doesn't work, try again tomorrow when the capitalist overlords hand out more data stamps.

10 |
11 | ) 12 | } 13 | // Error message can show here 14 | if(props.tweetsLength === 0) 15 | { 16 | return ( 17 |
18 |

Welcome to this sorting tool!

19 |

Type in a twitter username (the one with the '@' before it) to get up to ~3,200 of an account's most recent tweets. You can sort their tweets by Date, Likes, or Retweets, and choose either ascending or descending order.

20 |

A loading symbol will show while the tweets are being fetched. If this area is empty (not even this welcome message) it means the tweets are being rendered :)

21 |

If you enjoy this little tool, follow me @averywlittle.

22 |

The code, along with more information, is available here.

23 |

If you super enjoy the tool, buy me a coffee!

24 |
25 | ) 26 | } else { 27 | return ( 28 |
29 |

{props.tweetsLength} tweets loaded! Showing page {props.page} of {props.maxPage}.

30 |
31 | ) 32 | } 33 | } 34 | 35 | export default ListTweets -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const express = require('express') 3 | const cors = require('cors') 4 | const app = express() 5 | 6 | app.use(cors()) 7 | // Serves static pages from React build 8 | app.use(express.static('build')) 9 | app.use(express.json()) 10 | 11 | const twitter = require('./twitter') 12 | const sorting = require('./sorting') 13 | const comparators = require('./comparators') 14 | 15 | ////////// 16 | 17 | app.post('/api/query/', (request, response, next) => { 18 | 19 | console.log('body', request.body) 20 | 21 | let params = { 22 | user_id: request.body.query, 23 | screen_name: request.body.query, 24 | include_entities: false, 25 | count: 200 26 | } 27 | 28 | const mergeParams = { 29 | order: request.body.queryOrder 30 | } 31 | 32 | switch (request.body.queryType) { 33 | case "favorites": 34 | mergeParams.comparator = comparators.options.byFavorites 35 | break; 36 | 37 | case "retweets": 38 | mergeParams.comparator = comparators.options.byReach 39 | break; 40 | 41 | case "date": 42 | mergeParams.comparator = comparators.options.byDate 43 | break; 44 | default: 45 | break; 46 | } 47 | 48 | twitter.getTweets(params) 49 | .then(result => { 50 | console.log(`fetch success from user @${result.user.screen_name}, number of tweets: ${result.allTweets.length}`) 51 | // filter our tweets of just plain retweets, but keep quote tweets 52 | let tweetsWithoutRetweets = result.allTweets.filter(tweet => !tweet.retweeted_status) 53 | // sort tweets 54 | let sortedArray = sorting.sortTweets(tweetsWithoutRetweets, mergeParams) 55 | 56 | response.json({ tweets: sortedArray, user: result.user }) 57 | }) 58 | .catch(error => { 59 | console.log('ERROR', error) 60 | next(error) 61 | }) 62 | }) 63 | 64 | 65 | const PORT = process.env.PORT || 3001 66 | app.listen(PORT, () => { 67 | console.log(`Server running on port ${PORT}`) 68 | }) -------------------------------------------------------------------------------- /server/twitter.js: -------------------------------------------------------------------------------- 1 | 2 | const Twitter = require('twitter') 3 | 4 | const client = new Twitter({ 5 | consumer_key: process.env.API_KEY, 6 | consumer_secret: process.env.API_KEY_SECRET, 7 | access_token_key: process.env.ACCESS_TOKEN, 8 | access_token_secret: process.env.ACCESS_TOKEN_SECRET, 9 | bearer_token: process.env.BEARER_TOKEN 10 | }) 11 | 12 | const getTweets = async (queryParams) => { 13 | 14 | let allTweets = [] 15 | let newTweets = await client.get('statuses/user_timeline', queryParams) 16 | .then(tweets => { 17 | if (tweets !== undefined) { 18 | return tweets 19 | } 20 | }) 21 | .catch(error => { 22 | return Promise.reject(error) 23 | }) 24 | 25 | allTweets = allTweets.concat(newTweets) 26 | console.log(`first tweets length ${allTweets.length}`) 27 | 28 | let oldestTweetID = allTweets[allTweets.length-1].id 29 | 30 | const user = allTweets[0].user 31 | 32 | while (newTweets.length > 0) { 33 | console.log(`getting tweets before ${oldestTweetID}`) 34 | 35 | // The next request is aligned using the previous request's oldest tweet id 36 | newTweets = await client.get('statuses/user_timeline', { ...queryParams, max_id: oldestTweetID, trim_user: true }) 37 | .then(tweets => { 38 | console.log('new tweets length', tweets.length) 39 | return tweets 40 | }) 41 | .catch(error => { 42 | return Promise.reject(error) 43 | }) 44 | 45 | if (newTweets.length <= 1) { 46 | break 47 | } else { 48 | // If duplicate tweet by id, slice the duplicated tweet out 49 | if (allTweets[allTweets.length-1].id === newTweets[0].id) { 50 | 51 | newTweets = newTweets.slice(1) 52 | } 53 | 54 | allTweets = allTweets.concat(newTweets) 55 | 56 | // Adjust lowest ID to new lowest id in newTweets if available 57 | // No initial value because we assume the newTweets 58 | oldestTweetID = newTweets.reduce((oldestID, currentTweet) => currentTweet.id < oldestID ? currentTweet.id : oldestID.id) 59 | 60 | console.log(`${allTweets.length} tweets downloaded so far`) 61 | } 62 | } 63 | 64 | console.log('all tweets length =', allTweets.length) 65 | return { allTweets, user } 66 | } 67 | 68 | exports.getTweets = getTweets -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TweetSort 2 | A tool to sort through a user's tweets. 3 | 4 | # HEADS UP: THIS PROJECT IS DEPRECATED AND THE CODE IS UP FOR EDUCATIONAL PURPOSES ONLY 5 | 6 | The reason I wish this existed is because I love lurking through the top of all time on Reddit. I want to provide that experience on Twitter. 7 | There are so many talented and experienced people on the site sharing their expertise, insight, or humor. Twitter's ephemeral nature prevents us from seeing a user's 8 | valuable input from the past. Search is great if you know the syntax for commands, but is also dependent on you as a user knowing what content you're looking for. 9 | 10 | I've found that users that thread a lot will resurface their own valuable tweets. What about users that don't do that? This tool seeks to enable people to seek out valuable past 11 | tweets from people they want to learn more from. 12 | 13 | This is available at https://www.tweetsort.io/. 14 | 15 | Challenges: 16 | Getting a user's tweets (API only returns ~200 at a time) 17 | Avoiding being rate limited 18 | -[ ] I could easily just make a simple mongo collection to track request data, and check if I'm at my limit for a specified time period. 19 | Protecting API keys (used environment variables for the first time) 20 | Storing data in cache to reduce calls to API 21 | -[ ] I could only return just the tweet id since that's all that's needed to embed a tweet. Would reduce the amount of data sent 22 | -[ ] I could only return the id, str_id, created_on, favorite_count, and retweet_count variables for reduced data, but would also have enough to sort tweets on the front end without making more requests (as long as they are for the same account/username) 23 | 24 | I could pay for more access to the twitter api and could possibly get tons more tweets. But frankly, it's expensive. This is not going to make money, so that's not going to happen. 25 | 26 | Another approach I could take is to build a web scraper utility (probably in python) and use it to get all a user's tweets. While that would accomplish the goal, it would take me some time to build. Also, for an account with a lot of tweets, idk how long a scraper would have to run to get all the data needed. I'm not storing any tweets. 27 | 28 | Overall this was a great project to practice the React fundamentals I'm learning through the full stack open course (https://fullstackopen.com/en/about). 29 | I made a node and express backend, and while I'm not storing any data I did use a merge sort to transform the data dynamically. There are no tests for this, and probably won't be. Although, that is what I'm learning about currently. This was also my first time deploying an app that wasn't a static website. So that was fun. I learned how to use environment variables. Heroku makes it easy. 30 | 31 | Can't wait for the next project. I'm going to tweet about this tomorrow and I hope the twitter api doesn't get salty about the number of requests. 32 | 33 | That's another thing I learned, that I think going forward I should be picky about what I build on top of. 34 | -------------------------------------------------------------------------------- /react-ui/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /react-ui/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import QueryForm from './QueryForm' 3 | import ListTweets from './ListTweets' 4 | import Loading from './Loading' 5 | import QueryTypeSelector from './QueryTypeSelector' 6 | import QueryOrderSelector from './QueryOrderSelector' 7 | import PageSelector from './PageSelector' 8 | import InfoPanel from './InfoPanel' 9 | import axios from 'axios' 10 | 11 | 12 | const App = () => { 13 | 14 | const [ query, setQuery ] = useState("") 15 | const [ user, setUser ] = useState(null) 16 | const [ tweets, setTweets ] = useState([]) 17 | const [ loading, setLoading ] = useState(false) 18 | const [ queryType, setQueryType ] = useState("favorites") 19 | const [ queryOrder, setQueryOrder ] = useState("desc") 20 | const [ page, setPage ] = useState(1) 21 | const [ error, setError ] = useState(null) 22 | 23 | const handleQueryChange = (event) => { 24 | setQuery(event.target.value) 25 | 26 | if (event.target.value.length === 0) { 27 | setTweets([]) 28 | setError(null) 29 | } 30 | } 31 | 32 | const handleQueryTypeChange = (event) => { 33 | event.preventDefault() 34 | setQueryType(event.target.value) 35 | } 36 | 37 | const handleQueryOrderChange = (event) => { 38 | event.preventDefault() 39 | setQueryOrder(event.target.value) 40 | } 41 | 42 | // page cannot go lower than 1 or higher than tweets.length/10 + 1 43 | const handlePageUp = (event) => { 44 | event.preventDefault() 45 | if (tweets.length !== 0) { 46 | 47 | if (page < (Math.ceil(tweets.length/10))) { 48 | setPage(page + 1) 49 | } 50 | else console.log('Page maximum') 51 | } 52 | } 53 | 54 | const handlePageDown = (event) => { 55 | event.preventDefault() 56 | if (tweets.length !== 0) { 57 | if (page !== 1) { 58 | setPage(page - 1) 59 | } 60 | else console.log('Page minimum') 61 | } 62 | } 63 | 64 | const queryTweets = () => { 65 | console.log('query', query) 66 | if (query.length > 0) { 67 | setError(null) 68 | setLoading(true) 69 | 70 | const queryObject = { query, queryType, queryOrder } 71 | 72 | // post request because we need to send some data to form the query params 73 | axios.post('/api/query/', queryObject) 74 | .then(response => { 75 | console.log(response.data.user) 76 | console.log('tweets returned', response.data.tweets.length) 77 | setUser(response.data.user) 78 | setQuery(response.data.user.screen_name) 79 | setTweets(response.data.tweets) 80 | setPage(1) 81 | setLoading(false) 82 | }) 83 | .catch(error => { 84 | console.log('POST ERROR', error) 85 | setError(error) 86 | setLoading(false) 87 | setTweets([]) 88 | }) 89 | } 90 | } 91 | 92 | return ( 93 |
94 |

tweetsort

95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 |
103 | ) 104 | } 105 | 106 | export default App -------------------------------------------------------------------------------- /react-ui/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | .interactables { 16 | padding: 3px; 17 | } 18 | 19 | .queryBar { 20 | color: black; 21 | border-top: none; 22 | border-left: none; 23 | border-right: none; 24 | border-bottom-color: rgb(49, 49, 49); 25 | border-bottom-width: 2px; 26 | border-bottom-style: solid; 27 | margin: 3px; 28 | height: 20px; 29 | font-size: medium; 30 | } 31 | 32 | .selectButtons { 33 | color: black; 34 | background-color: #f8f8f8; 35 | width: auto; 36 | height: 30px; 37 | border: none; 38 | border-radius: 4px; 39 | margin: 3px; 40 | cursor: pointer; 41 | -webkit-box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 42 | -moz-box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 43 | box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 44 | -moz-appearance:none; /* Firefox */ 45 | -webkit-appearance:none; /* Safari and Chrome */ 46 | appearance:none; 47 | display: inline-block; 48 | padding: 0 25px 0 5px; 49 | } 50 | 51 | /* Targetting Webkit browsers only. FF will show the dropdown arrow with so much padding. */ 52 | @media screen and (-webkit-min-device-pixel-ratio:0) { 53 | select {padding-right:20px} 54 | } 55 | 56 | label {position:relative} 57 | label:after { 58 | content:'<>'; 59 | font:11px "Consolas", monospace; 60 | color:#aaa; 61 | -webkit-transform:rotate(90deg); 62 | -moz-transform:rotate(90deg); 63 | -ms-transform:rotate(90deg); 64 | transform:rotate(90deg); 65 | right:8px; top:2px; 66 | padding:0 0 2px; 67 | border-bottom:1px solid #ddd; 68 | position:absolute; 69 | pointer-events:none; 70 | } 71 | label:before { 72 | content:''; 73 | right:6px; top:0px; 74 | width:20px; height:20px; 75 | background:#f8f8f8; 76 | position:absolute; 77 | pointer-events:none; 78 | display:block; 79 | } 80 | 81 | .styledButtons { 82 | color: black; 83 | background-color: #f8f8f8; 84 | margin: 5px; 85 | width: 150px; 86 | height: 35px; 87 | border: none; 88 | border-radius: 4px; 89 | cursor: pointer; 90 | -webkit-box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 91 | -moz-box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 92 | box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 93 | 94 | } 95 | 96 | .styledPagerButtons { 97 | color: black; 98 | background-color: #f8f8f8; 99 | margin: 5px; 100 | width: 110px; 101 | height: 35px; 102 | border: none; 103 | border-radius: 4px; 104 | cursor: pointer; 105 | -webkit-box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 106 | -moz-box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 107 | box-shadow: 0 3px 0 #ccc, 0 -1px #fff inset; 108 | 109 | } 110 | 111 | .pagers { 112 | margin: 0 auto; 113 | width: 55%; 114 | padding-bottom: 10px; 115 | } 116 | 117 | @media (min-width: 1250px) { 118 | #root { 119 | margin: 0 auto; 120 | width: 30%; 121 | padding: 10px; 122 | } 123 | } 124 | 125 | @media (max-width: 1249px) { 126 | #root { 127 | margin: 0 auto; 128 | width: 50%; 129 | padding: 10px; 130 | } 131 | } 132 | 133 | @media (max-width: 799px) { 134 | #root { 135 | margin: 0 auto; 136 | width: 90%; 137 | padding: 5px; 138 | } 139 | 140 | .pagers { 141 | margin: 0 auto; 142 | width: 65%; 143 | padding-bottom: 10px; 144 | } 145 | } 146 | 147 | @media (max-width: 430px) { 148 | .pagers { 149 | margin: 0 auto; 150 | width: 75%; 151 | padding-bottom: 10px; 152 | } 153 | } 154 | 155 | @media (max-width: 370px) { 156 | .pagers { 157 | margin: 0 auto; 158 | width: 85%; 159 | padding-bottom: 10px; 160 | } 161 | } 162 | 163 | .tweet { 164 | margin-left: 2px; 165 | margin-right: 2px; 166 | } 167 | 168 | .errorMessage { 169 | background-color: rgba(255, 0, 0, 0.055); 170 | color: red; 171 | padding: 1px; 172 | padding-left: 20px; 173 | padding-right: 20px; 174 | margin: 2px; 175 | border-radius: 5px; 176 | vertical-align: middle; 177 | } 178 | 179 | .successMessage { 180 | background-color: rgba(51, 255, 0, 0.068); 181 | color: green; 182 | padding: 1px; 183 | padding-left: 20px; 184 | padding-right: 20px; 185 | margin: 2px; 186 | border-radius: 5px; 187 | vertical-align: middle; 188 | } -------------------------------------------------------------------------------- /react-ui/.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/home/averywlittle/code/tweetsort/react-ui/src/index.js":"1","/home/averywlittle/code/tweetsort/react-ui/src/components/App.js":"2","/home/averywlittle/code/tweetsort/react-ui/src/components/QueryForm.js":"3","/home/averywlittle/code/tweetsort/react-ui/src/components/ListTweets.js":"4","/home/averywlittle/code/tweetsort/react-ui/src/components/Loading.js":"5","/home/averywlittle/code/tweetsort/react-ui/src/components/QueryOrderSelector.js":"6","/home/averywlittle/code/tweetsort/react-ui/src/components/QueryTypeSelector.js":"7","/home/averywlittle/code/tweetsort/react-ui/src/components/PageSelector.js":"8","/home/averywlittle/code/tweetsort/react-ui/src/components/InfoPanel.js":"9"},{"size":173,"mtime":1608171779166,"results":"10","hashOfConfig":"11"},{"size":3581,"mtime":1608270802383,"results":"12","hashOfConfig":"11"},{"size":586,"mtime":1608258118346,"results":"13","hashOfConfig":"11"},{"size":607,"mtime":1608261686597,"results":"14","hashOfConfig":"11"},{"size":320,"mtime":1608171896385,"results":"15","hashOfConfig":"11"},{"size":461,"mtime":1608241900391,"results":"16","hashOfConfig":"11"},{"size":500,"mtime":1608240533782,"results":"17","hashOfConfig":"11"},{"size":458,"mtime":1608263958526,"results":"18","hashOfConfig":"11"},{"size":1885,"mtime":1608674779487,"results":"19","hashOfConfig":"11"},{"filePath":"20","messages":"21","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"22"},"1mfrnkl",{"filePath":"23","messages":"24","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"25","usedDeprecatedRules":"22"},{"filePath":"26","messages":"27","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"22"},{"filePath":"28","messages":"29","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"22"},{"filePath":"30","messages":"31","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"22"},{"filePath":"32","messages":"33","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"22"},{"filePath":"34","messages":"35","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"22"},{"filePath":"36","messages":"37","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":"22"},{"filePath":"38","messages":"39","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/home/averywlittle/code/tweetsort/react-ui/src/index.js",[],["40","41"],"/home/averywlittle/code/tweetsort/react-ui/src/components/App.js",["42"],"import React, { useState } from 'react'\nimport QueryForm from './QueryForm'\nimport ListTweets from './ListTweets'\nimport Loading from './Loading'\nimport QueryTypeSelector from './QueryTypeSelector'\nimport QueryOrderSelector from './QueryOrderSelector'\nimport PageSelector from './PageSelector'\nimport InfoPanel from './InfoPanel'\nimport axios from 'axios'\n\n\nconst App = () => {\n\n const [ query, setQuery ] = useState(\"\")\n const [ user, setUser ] = useState(null)\n const [ tweets, setTweets ] = useState([])\n const [ loading, setLoading ] = useState(false)\n const [ queryType, setQueryType ] = useState(\"favorites\")\n const [ queryOrder, setQueryOrder ] = useState(\"desc\")\n const [ page, setPage ] = useState(1)\n const [ error, setError ] = useState(null)\n\n const handleQueryChange = (event) => {\n setQuery(event.target.value)\n\n if (event.target.value.length === 0) {\n setTweets([])\n setError(null)\n }\n }\n\n const handleQueryTypeChange = (event) => {\n event.preventDefault()\n setQueryType(event.target.value)\n }\n\n const handleQueryOrderChange = (event) => {\n event.preventDefault()\n setQueryOrder(event.target.value)\n }\n\n // page cannot go lower than 1 or higher than tweets.length/10 + 1\n const handlePageUp = (event) => {\n event.preventDefault()\n if (tweets.length !== 0) {\n\n if (page < (Math.ceil(tweets.length/10))) {\n setPage(page + 1)\n }\n else console.log('Page maximum')\n }\n }\n\n const handlePageDown = (event) => {\n event.preventDefault()\n if (tweets.length !== 0) {\n if (page !== 1) {\n setPage(page - 1)\n }\n else console.log('Page minimum')\n }\n }\n\n const queryTweets = () => {\n console.log('query', query)\n if (query.length > 0) {\n setError(null)\n setLoading(true)\n\n const queryObject = { query, queryType, queryOrder }\n\n // post request because we need to send some data to form the query params\n axios.post('/api/query/', queryObject)\n .then(response => {\n console.log(response.data.user)\n console.log('tweets returned', response.data.tweets.length)\n setUser(response.data.user)\n setQuery(response.data.user.screen_name)\n setTweets(response.data.tweets)\n setPage(1)\n setLoading(false)\n })\n .catch(error => {\n console.log('POST ERROR', error)\n setError(error)\n setLoading(false)\n setTweets([])\n })\n }\n }\n\n return (\n
\n

tweetsort

\n \n \n \n \n \n \n \n
\n )\n}\n\nexport default App","/home/averywlittle/code/tweetsort/react-ui/src/components/QueryForm.js",[],"/home/averywlittle/code/tweetsort/react-ui/src/components/ListTweets.js",[],"/home/averywlittle/code/tweetsort/react-ui/src/components/Loading.js",[],"/home/averywlittle/code/tweetsort/react-ui/src/components/QueryOrderSelector.js",[],"/home/averywlittle/code/tweetsort/react-ui/src/components/QueryTypeSelector.js",[],"/home/averywlittle/code/tweetsort/react-ui/src/components/PageSelector.js",[],"/home/averywlittle/code/tweetsort/react-ui/src/components/InfoPanel.js",[],{"ruleId":"43","replacedBy":"44"},{"ruleId":"45","replacedBy":"46"},{"ruleId":"47","severity":1,"message":"48","line":15,"column":13,"nodeType":"49","messageId":"50","endLine":15,"endColumn":17},"no-native-reassign",["51"],"no-negated-in-lhs",["52"],"no-unused-vars","'user' is assigned a value but never used.","Identifier","unusedVar","no-global-assign","no-unsafe-negation"] --------------------------------------------------------------------------------