├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── README.md ├── ads.txt ├── app ├── actions.js ├── components │ ├── Ad.jsx │ ├── App.jsx │ ├── GroupCheckbox.jsx │ ├── GroupList.jsx │ ├── Loader.jsx │ ├── NoPosts.jsx │ ├── Post.jsx │ ├── PostList.jsx │ ├── Root.jsx │ ├── Search.jsx │ ├── ServiceCheckbox.jsx │ └── ServicesList.jsx ├── containers │ ├── FiltersContainer.jsx │ ├── PostListContainer.jsx │ └── Search.jsx ├── hooks │ └── useStateWithLocalStorage.jsx ├── index.jsx ├── reducers.js └── store.js ├── dev ├── .gitignore ├── assets │ ├── bootstrap.min.css │ ├── dev.css │ ├── dev.js │ ├── loader.svg │ ├── theme-dark.css │ ├── theme-dark.map │ ├── theme-light.css │ └── theme-light.map └── index.html ├── games ├── ark │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── battlefield1 │ ├── android-chrome-192x192.png │ ├── apple-touch-icon.png │ ├── assets │ │ ├── logo-dice.png │ │ ├── logo-ea.png │ │ ├── logo.png │ │ ├── purista-medium.woff │ │ └── purista-medium.woff2 │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ ├── site.webmanifest │ └── theme-dark.scss ├── conan │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ ├── site.webmanifest │ ├── theme-dark.scss │ └── theme-light.scss ├── csgo │ ├── android-chrome-192x192.png │ ├── apple-touch-icon.png │ ├── assets │ │ ├── CounterStrike.woff │ │ ├── CounterStrike.woff2 │ │ ├── flairs.png │ │ ├── logo-cloud9.png │ │ ├── logo-complexity.png │ │ ├── logo-counter-logic-gaming.png │ │ ├── logo-dreamhack.png │ │ ├── logo-dropthebomb.png │ │ ├── logo-echo-fox.png │ │ ├── logo-eleague.png │ │ ├── logo-epicenter.png │ │ ├── logo-espn.png │ │ ├── logo-godsent.png │ │ ├── logo-hellraisers.png │ │ ├── logo-misfits.png │ │ ├── logo-mousesports.png │ │ ├── logo-ninjas-in-pyjamas.png │ │ ├── logo-oddshot.png │ │ ├── logo-optic-gaming.png │ │ ├── logo-renegades.png │ │ ├── logo-rfrsh-entertainment.png │ │ ├── logo-selfless-gaming.png │ │ ├── logo-splyce.png │ │ ├── logo-team-solomid.png │ │ ├── logo-twitch.png │ │ └── logo-wesa.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ ├── site.webmanifest │ └── styles.css ├── dayz │ ├── theme-dark.scss │ └── theme-light.scss ├── destiny │ ├── android-chrome-192x192.png │ ├── apple-touch-icon.png │ ├── assets │ │ ├── destiny-font.woff │ │ └── destiny-font.woff2 │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ ├── site.webmanifest │ ├── theme-dark.scss │ └── theme-light.scss ├── elite │ ├── android-chrome-192x192.png │ ├── apple-touch-icon.png │ ├── assets │ │ ├── eurostile.woff │ │ ├── eurostile.woff2 │ │ └── loader.svg │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ ├── site.webmanifest │ ├── theme-dark.scss │ └── theme-light.scss ├── escape-from-tarkov │ └── theme-dark.scss ├── fortnite │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── assets │ │ ├── BurbankBigCondensed-Bold.woff │ │ └── BurbankBigCondensed-Bold.woff2 │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ ├── site.webmanifest │ ├── theme-dark.scss │ └── theme-light.scss ├── pubg │ ├── android-chrome-192x192.png │ ├── android-chrome-256x256.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── rainbow6 │ ├── assets │ │ ├── logo-esl.png │ │ └── logo-ubisoft.png │ ├── theme-dark.scss │ └── theme-light.scss ├── rimworld │ ├── android-chrome-192x192.png │ ├── android-chrome-256x256.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest └── star-citizen │ ├── theme-dark.scss │ └── theme-light.scss ├── package-lock.json ├── package.json ├── scripts ├── build.js ├── buildstyles.js └── modules │ ├── gamecss.js │ ├── savefile.js │ └── sleep.js ├── web-assets ├── _styles.scss ├── games-template.html ├── theme-dark.scss └── theme-light.scss ├── web ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── assets │ ├── bootstrap.min.css │ └── loader.svg ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── mstile-150x150.png ├── safari-pinned-tab.svg ├── service-worker.js └── site.webmanifest └── webpack.config.js /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Build & deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js 18.x 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 18.x 20 | 21 | - run: npm ci 22 | 23 | - run: npm run build --if-present 24 | env: 25 | CI: true 26 | API_TOKEN: ${{ secrets.API_TOKEN }} 27 | AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} 28 | AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} 29 | 30 | - run: npm run deploy --if-present 31 | env: 32 | CI: true 33 | API_TOKEN: ${{ secrets.API_TOKEN }} 34 | AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} 35 | AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | app.js 3 | ads.js 4 | ads.min.js 5 | app.min.js 6 | .vagrant/ 7 | node_modules/ 8 | dist/ 9 | npm-debug.log* 10 | web/scripts/app.build.js 11 | config/cron/ 12 | Developers - *.csv 13 | *.log 14 | web/scripts/app\.js\.map 15 | config/keys.json 16 | .env 17 | .idea/ 18 | stage 19 | dev/assets/theme* 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dev Tracker 2 | This is a simple dev tracker tracking developers from multiple games over multiple platforms. 3 | 4 | ## New game 5 | Should we start tracking another game? 6 | Let us know by posting an [issue](https://github.com/post-tracker/site/issues/new). 7 | 8 | ## Currently hosting 9 | 10 | [Anthem Dev Tracker](https://developertracker.com/anthem/) 11 | [ARK: Survival Evolved Dev Tracker](https://arkdevtracker.com) 12 | [Battlefield 1 Dev Tracker](https://battlefielddevtracker.com) 13 | [Conan: Exiles Dev Tracker](https://conandevtracker.com) 14 | [Darwin Project Dev Tracker](https://developertracker.com/darwin-project/) 15 | [Destiny Dev Tracker](https://destinydevtracker.com) 16 | [Elite: Dangerous Dev Tracker](https://elitedevtracker.com) 17 | [Escape from Tarkov Dev Tracker](https://developertracker.com/escape-from-tarkov/) 18 | [Fortnite Dev Tracker](https://developertracker.com/fortnite/) 19 | [PLAYERUNKNOWN'S BATTLEGROUNDS Dev Tracker](https://pubgdevtracker.com) 20 | [Rainbow 6: Siege Dev Tracker](https://developertracker.com/rainbow6/) 21 | [RimWorld Dev Tracker](https://rimworlddevtracker.com) 22 | [Rocket League Dev Tracker](https://developertracker.com/rocket-league/) 23 | [Sea of Thieves Dev Tracker](https://developertracker.com/sea-of-thieves/) 24 | [Star Citizen Dev Tracker](https://developertracker.com/star-citizen/) 25 | [They Are Billions Dev Tracker](https://developertracker.com/tab/) 26 | -------------------------------------------------------------------------------- /ads.txt: -------------------------------------------------------------------------------- 1 | google.com, pub-7039480870927391, DIRECT, f08c47fec0942fa0 -------------------------------------------------------------------------------- /app/actions.js: -------------------------------------------------------------------------------- 1 | import queryString from 'querystring'; 2 | import debounce from 'debounce'; 3 | import cookie from 'react-cookie'; 4 | 5 | export const TOGGLE_GROUP = 'TOGGLE_GROUP'; 6 | export const REQUEST_POSTS = 'REQUEST_POSTS'; 7 | export const RECEIVE_POSTS = 'RECEIVE_POSTS'; 8 | export const SET_SEARCH_TERM = 'SET_SEARCH_TERM'; 9 | export const TOGGLE_SERVICE = 'TOGGLE_SERVICE'; 10 | 11 | const FETCH_DEBOUNCE_INTERVAL = 250; 12 | let API_HOSTNAME = 'api.developertracker.com'; 13 | let API_PORT = 443; 14 | let trackTiming = true; 15 | 16 | if ( window.location.hostname === 'localhost' ) { 17 | API_HOSTNAME = 'lvh.me'; 18 | // eslint-disable-next-line no-magic-numbers 19 | API_PORT = 3000; 20 | } 21 | 22 | const setSearchTerm = function setSearchTerm ( term ) { 23 | const currentQuery = queryString.parse( location.search.substr( 1 ) ); 24 | 25 | if ( currentQuery.post ) { 26 | // Bad browser support 27 | // Reflect.deleteProperty( currentQuery, 'post' ); 28 | delete currentQuery.post; 29 | 30 | let newPath = queryString.stringify( currentQuery ); 31 | 32 | if ( newPath.length > 0 ) { 33 | newPath = `?${ newPath }`; 34 | } else { 35 | newPath = window.location.origin + window.location.pathname; 36 | } 37 | 38 | if ( window.history.pushState ) { 39 | window.history.pushState( {}, '', newPath ); 40 | } 41 | } 42 | 43 | return { 44 | term, 45 | type: SET_SEARCH_TERM, 46 | }; 47 | }; 48 | 49 | const receivePosts = function receivePosts ( json ) { 50 | return { 51 | posts: json, 52 | receivedAt: Date.now(), 53 | type: RECEIVE_POSTS, 54 | }; 55 | }; 56 | 57 | const requestPosts = function requestPosts () { 58 | return { 59 | type: REQUEST_POSTS, 60 | }; 61 | }; 62 | 63 | const toggleGroupState = function toggleGroupState ( name ) { 64 | return { 65 | name, 66 | type: TOGGLE_GROUP, 67 | }; 68 | }; 69 | 70 | const toggleServiceState = function toggleServiceState ( name ) { 71 | return { 72 | name, 73 | type: TOGGLE_SERVICE, 74 | }; 75 | }; 76 | 77 | const updatePath = function updatePath ( getState ) { 78 | const state = getState(); 79 | const querystringParameters = getQueryParameters( state.services, state.groups, state.search ); 80 | const parsedQuerystring = queryString.stringify( querystringParameters ); 81 | let locationSearch = `?${ parsedQuerystring }`; 82 | 83 | if ( parsedQuerystring.length === 0 ) { 84 | locationSearch = './'; 85 | } 86 | 87 | if ( window.location.search !== locationSearch && window.history.pushState ) { 88 | window.history.pushState( {}, '', locationSearch ); 89 | } 90 | }; 91 | 92 | const getQueryParameters = function getQueryParameters ( services, groups, search ) { 93 | const currentQuery = queryString.parse( location.search.substr( 1 ) ); 94 | let querystringParameters = {}; 95 | let activeServices = []; 96 | let activeGroups = []; 97 | 98 | // If we have a post, don't keep anything else in the querystring 99 | if ( currentQuery.post ) { 100 | querystringParameters = { 101 | post: currentQuery.post, 102 | }; 103 | 104 | return querystringParameters; 105 | } 106 | 107 | // Set search 108 | if ( typeof search !== 'undefined' && search.length > 0 ) { 109 | querystringParameters.search = search; 110 | } 111 | 112 | activeGroups = groups.items.filter( ( group ) => { 113 | return group.active; 114 | } ); 115 | 116 | if ( activeGroups && activeGroups.length > 0 && activeGroups.length !== groups.items.length ) { 117 | querystringParameters[ 'groups[]' ] = activeGroups.map( ( group ) => { 118 | return group.name; 119 | } ); 120 | } 121 | 122 | activeServices = services.items.filter( ( service ) => { 123 | return service.active; 124 | } ); 125 | 126 | const cookieServices = cookie.load( 'services' ); 127 | 128 | if ( activeServices && activeServices.length > 0 && activeServices.length !== services.items.length ) { 129 | querystringParameters[ 'services[]' ] = activeServices.map( ( service ) => { 130 | return service.name; 131 | } ); 132 | } 133 | 134 | if ( services.items.length === 0 && cookieServices ) { 135 | querystringParameters[ 'services[]' ] = cookieServices; 136 | } 137 | 138 | return querystringParameters; 139 | }; 140 | 141 | // eslint-disable-next-line max-params 142 | const getPosts = function getPosts ( search, groups, services, dispatch ) { 143 | let urlPath = `/${ window.game }/posts`; 144 | const querystringParameters = getQueryParameters( services, groups, search ); 145 | const parsedQuerystring = queryString.stringify( querystringParameters ); 146 | 147 | if ( querystringParameters.post ) { 148 | urlPath = `${ urlPath }/${ querystringParameters.post }`; 149 | } else { 150 | urlPath = `${ urlPath }?${ parsedQuerystring }`; 151 | } 152 | 153 | const url = new URL(`https://${API_HOSTNAME}:${API_PORT}${urlPath}`); 154 | 155 | const startTime = new Date().getTime(); 156 | dispatch( requestPosts() ); 157 | 158 | fetch( url ) 159 | .then((response) => { 160 | return response.json(); 161 | }) 162 | .then((posts) => { 163 | if ( window.ga && trackTiming ) { 164 | ga( 'send', { 165 | hitType: 'timing', 166 | timingCategory: 'Posts API', 167 | timingVar: 'load', 168 | timingValue: new Date().getTime() - startTime, 169 | timingLabel: url.path, 170 | } ); 171 | } 172 | console.log(posts); 173 | dispatch( receivePosts( posts.data ) ); 174 | }) 175 | .catch((requestError) => { 176 | dispatch( receivePosts( [] ) ); 177 | onsole.error( postsFail ); 178 | }); 179 | }; 180 | 181 | const debouncedFetchPosts = debounce( getPosts, FETCH_DEBOUNCE_INTERVAL ); 182 | 183 | const fetchPosts = function fetchPosts ( state ) { 184 | const { 185 | search, 186 | groups, 187 | services, 188 | } = state; 189 | 190 | return ( dispatch ) => { 191 | debouncedFetchPosts( search, groups, services, dispatch ); 192 | }; 193 | }; 194 | 195 | const fetchPostsImmediate = function fetchPostsImmediate ( state ) { 196 | const { 197 | search, 198 | groups, 199 | services, 200 | } = state; 201 | 202 | return ( dispatch ) => { 203 | getPosts( search, groups, services, dispatch ); 204 | }; 205 | }; 206 | 207 | export const fetchPostsIfNeeded = function fetchPostsIfNeeded () { 208 | return ( dispatch, getState ) => { 209 | return dispatch( fetchPosts( getState() ) ); 210 | }; 211 | }; 212 | 213 | export const search = function search ( searchTerm ) { 214 | return ( dispatch, getState ) => { 215 | dispatch( setSearchTerm( searchTerm ) ); 216 | updatePath( getState ); 217 | 218 | return dispatch( fetchPosts( getState() ) ); 219 | }; 220 | }; 221 | 222 | export const toggleGroup = function toggleGroup ( name ) { 223 | return ( dispatch, getState ) => { 224 | dispatch( toggleGroupState( name ) ); 225 | updatePath( getState ); 226 | 227 | return dispatch( fetchPostsImmediate( getState() ) ); 228 | }; 229 | }; 230 | 231 | export const toggleService = function toggleService ( name ) { 232 | return ( dispatch, getState ) => { 233 | dispatch( toggleServiceState( name ) ); 234 | updatePath( getState ); 235 | 236 | return dispatch( fetchPostsImmediate( getState() ) ); 237 | }; 238 | }; 239 | -------------------------------------------------------------------------------- /app/components/Ad.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const styles = { 5 | wrapper: { 6 | height: '600px', 7 | position: 'absolute', 8 | top: '170px', 9 | width: '300px', 10 | }, 11 | }; 12 | 13 | class Ad extends React.Component { 14 | constructor ( props ) { 15 | super( props ); 16 | 17 | this.state = { 18 | styles: Object.assign( {}, styles.wrapper, props.styles || {} ), 19 | }; 20 | } 21 | 22 | componentDidMount () { 23 | if ( window ) { 24 | ( window.adsbygoogle = window.adsbygoogle || [] ).push( {} ); 25 | } 26 | } 27 | 28 | render () { 29 | return ( 30 | 36 | ); 37 | } 38 | } 39 | 40 | Ad.displayName = 'Ad'; 41 | 42 | Ad.defaultProps = { 43 | styles: {}, 44 | }; 45 | 46 | Ad.propTypes = { 47 | dataAdSlot: PropTypes.string.isRequired, 48 | // eslint-disable-next-line react/forbid-prop-types 49 | styles: PropTypes.object, 50 | }; 51 | 52 | export default Ad; 53 | -------------------------------------------------------------------------------- /app/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { DarkModeSwitch } from 'react-toggle-dark-mode'; 4 | import PostListContainer from '../containers/PostListContainer.jsx'; 5 | import FiltersContainer from '../containers/FiltersContainer.jsx'; 6 | 7 | import useStateWithLocalStorage from '../hooks/useStateWithLocalStorage.jsx'; 8 | 9 | const App = () => { 10 | const [theme, setTheme] = useStateWithLocalStorage('theme', 'theme-light'); 11 | 12 | const toggleDarkMode = (isDarkMode) => { 13 | const newTheme = isDarkMode ? 'theme-light' : 'theme-dark'; 14 | setTheme(newTheme); 15 | const event = new CustomEvent('switch-theme', { 16 | detail: newTheme, 17 | }); 18 | 19 | window.dispatchEvent(event); 20 | }; 21 | 22 | return ( 23 |
24 | 36 | 37 | 38 |
39 | ); 40 | } 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /app/components/GroupCheckbox.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { 6 | toggleGroup, 7 | } from '../actions'; 8 | 9 | class GroupCheckbox extends React.Component { 10 | getParsedGroupName () { 11 | return this.props.name 12 | .toLowerCase() 13 | .replace( /\s/gim, '-' ) 14 | .replace( /[^a-z0-9-]/gim, '' ); 15 | } 16 | 17 | render () { 18 | return ( 19 |
22 | 36 |
37 | ); 38 | } 39 | } 40 | 41 | const mapDispatchToProps = ( dispatch, ownProps ) => { 42 | return { 43 | handleCheckboxChange: () => { 44 | dispatch( toggleGroup( ownProps.name ) ); 45 | }, 46 | }; 47 | }; 48 | 49 | GroupCheckbox.displayName = 'GroupCheckbox'; 50 | 51 | GroupCheckbox.propTypes = { 52 | checked: PropTypes.bool.isRequired, 53 | handleCheckboxChange: PropTypes.func.isRequired, 54 | name: PropTypes.string.isRequired, 55 | }; 56 | 57 | export default connect( 58 | null, 59 | mapDispatchToProps 60 | )( GroupCheckbox ); 61 | -------------------------------------------------------------------------------- /app/components/GroupList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import GroupCheckbox from './GroupCheckbox.jsx'; 5 | 6 | class GroupList extends React.Component { 7 | constructor ( props ) { 8 | super( props ); 9 | 10 | this.handleFilterExpandClick = this.handleFilterExpandClick.bind( this ); 11 | 12 | this.state = { 13 | showGroups: true, 14 | }; 15 | } 16 | 17 | componentWillMount () { 18 | if ( window.matchMedia && window.matchMedia( '( max-width: 450px )' ).matches ) { 19 | this.setState( { 20 | showGroups: false, 21 | } ); 22 | } 23 | } 24 | 25 | handleFilterExpandClick () { 26 | this.setState( { 27 | showGroups: !this.state.showGroups, 28 | } ); 29 | } 30 | 31 | render () { 32 | let groupsClasses = 'groups-wrapper'; 33 | let allChecked = true; 34 | 35 | const groupNodes = this.props.groups.map( ( group ) => { 36 | if ( group.active ) { 37 | allChecked = false; 38 | } 39 | 40 | return ( 41 | 46 | ); 47 | } ); 48 | 49 | if ( groupNodes.length > 1 ) { 50 | groupNodes.push( ( 51 | 56 | ) ); 57 | } else { 58 | groupsClasses = `${ groupsClasses } hidden`; 59 | } 60 | 61 | if ( this.state.showGroups ) { 62 | groupsClasses = `${ groupsClasses } show`; 63 | } 64 | 65 | return ( 66 |
69 |
76 | { 'Filters' } 77 | 85 | 88 | 89 |
90 | { groupNodes } 91 |
92 | ); 93 | } 94 | } 95 | 96 | GroupList.displayName = 'GroupList'; 97 | 98 | GroupList.propTypes = { 99 | // eslint-disable-next-line 100 | groups: PropTypes.array.isRequired, 101 | }; 102 | 103 | export default GroupList; 104 | -------------------------------------------------------------------------------- /app/components/Loader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const styles = { 4 | image: { 5 | maxWidth: '25%', 6 | width: 200, 7 | }, 8 | wrapper: { 9 | textAlign: 'center', 10 | }, 11 | }; 12 | 13 | class Loader extends React.Component { 14 | render () { 15 | if ( !this.props.isFetching ) { 16 | return null; 17 | } 18 | 19 | return ( 20 |
23 | 28 |
29 | ) 30 | } 31 | } 32 | 33 | export default Loader; 34 | -------------------------------------------------------------------------------- /app/components/NoPosts.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const styles = { 4 | wrapper: { 5 | textAlign: 'center', 6 | }, 7 | }; 8 | 9 | class NoPosts extends React.Component { 10 | render () { 11 | if ( !this.props.show ) { 12 | return null; 13 | } 14 | 15 | return ( 16 |
19 | { `Sorry, no posts matching ${ this.props.query }` } 20 |
21 | ); 22 | } 23 | } 24 | 25 | export default NoPosts; 26 | -------------------------------------------------------------------------------- /app/components/Post.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TimeAgo from 'react-timeago'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const POST_CUTOFF_HEIGHT = 1000; 6 | const TIMESTAMP_UPDATE_INTERVAL = 1000; 7 | 8 | const styles = { 9 | permalink: { 10 | float: 'right', 11 | lineHeight: '27px', 12 | }, 13 | sourceIcon: { 14 | height: '20px', 15 | marginRight: '8px', 16 | width: '20px', 17 | }, 18 | }; 19 | 20 | class Post extends React.Component { 21 | constructor ( props ) { 22 | super( props ); 23 | 24 | this.expand = this.expand.bind( this ); 25 | this.handleExpandClick = this.handleExpandClick.bind( this ); 26 | 27 | this.state = { 28 | expandable: false, 29 | }; 30 | this.postIndex = props.postIndex; 31 | } 32 | 33 | componentWillMount () { 34 | this.setState( { 35 | service: this.normaliseSource( this.props.postData.account.service ), 36 | } ); 37 | } 38 | 39 | componentDidMount () { 40 | const height = this.body.offsetHeight; 41 | 42 | if ( height > POST_CUTOFF_HEIGHT ) { 43 | // eslint-disable-next-line react/no-did-mount-set-state 44 | this.setState( { 45 | expandable: true, 46 | } ); 47 | } 48 | } 49 | 50 | getSectionURL () { 51 | const url = this.props.postData.url; 52 | 53 | if ( url.indexOf( 'steamcommunity.com' ) > -1 ) { 54 | const match = url.match( /(http[s]?:\/\/steamcommunity.com\/app\/\d+\/discussions\/\d+\/).+?/ ); 55 | 56 | if ( match && match[ 1 ] ) { 57 | return match[ 1 ]; 58 | } 59 | } else if ( url.indexOf( 'reddit.com' ) > -1 ) { 60 | const match = url.match( /(https:\/\/www\.reddit\.com\/r\/.+?)\/.+?/ ); 61 | 62 | if ( match && match[ 1 ] ) { 63 | return match[ 1 ]; 64 | } 65 | } 66 | 67 | return false; 68 | } 69 | 70 | handleExpandClick () { 71 | this.expand(); 72 | } 73 | 74 | expand () { 75 | this.setState( { 76 | expandable: false, 77 | } ); 78 | } 79 | 80 | normaliseSource ( originalService ) { 81 | let service; 82 | 83 | /* eslint-disable indent */ 84 | switch ( originalService ) { 85 | case 'MiggyRSS': 86 | service = 'RSS'; 87 | break; 88 | case 'Steam': 89 | case 'Reddit': 90 | case 'Twitter': 91 | service = originalService; 92 | break; 93 | default: 94 | service = 'comments'; 95 | break; 96 | } 97 | 98 | /* eslint-enable indent */ 99 | 100 | return service.toLowerCase(); 101 | } 102 | 103 | getSectionIcon () { 104 | const sectionURL = this.getSectionURL(); 105 | const baseIcon = ( 106 | 110 | 113 | 114 | ); 115 | 116 | if ( sectionURL ) { 117 | return ( 118 | 122 | { baseIcon } 123 | 124 | ); 125 | } 126 | 127 | return baseIcon; 128 | } 129 | 130 | getPostLink () { 131 | if ( this.state.service === 'reddit' ) { 132 | // Fix some old urls 133 | // https://www.reddit.com/r/EliteDangerous/comments/3w7t0y/1_week_blind_auction/cxu11nt#cxu11nt 134 | // to 135 | // https://www.reddit.com/r/EliteDangerous/comments/3w7t0y/1_week_blind_auction/cxu11nt/ 136 | const matches = this.props.postData.url.replace( /(.+?)\#\1/, '$1/' ).match( /.*\/(.+?)\/$/ ); 137 | 138 | if ( matches && matches[ 1 ] ) { 139 | return `${ this.props.postData.url }?context=999#${ matches[ 1 ] }`; 140 | } else { 141 | console.log( `Unable to match postId for ${ this.props.postData.url }` ); 142 | return `${ this.props.postData.url }?context=999`; 143 | } 144 | } 145 | 146 | return this.props.postData.url; 147 | } 148 | 149 | updateImages ( htmlString ) { 150 | const maxWidth = Math.min( window.innerWidth, 1140 ); 151 | const maxHeight = Math.min( window.innerWidth, 600 ); 152 | const imgRegex = new RegExp( ']+?(src="(https?:)?\/\/(.+?)").*?>', 'g' ); 153 | const srcsetRegex = new RegExp( ']+?(srcset="(https?:)?\/\/(.+?)) (.+?)".*?>', 'g' ); 154 | let matches; 155 | let loadingTypeString = ' loading="lazy" '; 156 | 157 | if(this.postIndex === 0){ 158 | loadingTypeString = ''; 159 | } 160 | 161 | while ( ( matches = imgRegex.exec( htmlString ) ) !== null ) { 162 | let fallbackUrl = matches[ 3 ]; 163 | if ( matches[ 2 ] === 'https:' ) { 164 | fallbackUrl = `ssl:${ fallbackUrl }`; 165 | } 166 | const newSrc = `src="https://images.weserv.nl/?url=${ encodeURIComponent( matches[ 3 ] ) }&w=${ maxWidth }&h=${ maxHeight }&t=fit&format=webp&il&errorredirect=${ encodeURIComponent( fallbackUrl ) }"${ loadingTypeString }`; 167 | htmlString = htmlString.replace( matches[ 1 ], newSrc ); 168 | } 169 | 170 | while ( ( matches = srcsetRegex.exec( htmlString ) ) !== null ) { 171 | let fallbackUrl = matches[ 3 ]; 172 | if ( matches[ 2 ] === 'https:' ) { 173 | fallbackUrl = `ssl:${ fallbackUrl }`; 174 | } 175 | const newSrc = `srcset="https://images.weserv.nl/?url=${ encodeURIComponent( matches[ 3 ] ) }&w=${ maxWidth }&h=${ maxHeight }&t=fit&format=webp&&il&errorredirect=${ encodeURIComponent( fallbackUrl ) } ${ matches[ 4 ] }"${ loadingTypeString }` 176 | htmlString = htmlString.replace( matches[ 1 ], newSrc ); 177 | } 178 | 179 | return htmlString; 180 | } 181 | 182 | updateIframes ( htmlString ) { 183 | const frameRegex = new RegExp( '<\/iframe>', 'g' ) 184 | let matches; 185 | 186 | while ( ( matches = frameRegex.exec( htmlString ) ) !== null ) { 187 | let url = matches[ 1 ]; 188 | 189 | // handle some invision power board stuff 190 | if ( url.substr( -9 ) === '?do=embed' ) { 191 | url = url.substring( 0, url.length - 9 ); 192 | } 193 | 194 | const newHtml = `${ url }`; 195 | 196 | htmlString = htmlString.replace( matches[ 0 ], newHtml ); 197 | frameRegex.lastIndex = 0; 198 | } 199 | 200 | return htmlString; 201 | } 202 | 203 | getContentMarkup () { 204 | let content = this.updateImages( this.props.postData.content ); 205 | 206 | content = this.updateIframes( content ); 207 | 208 | return content; 209 | } 210 | 211 | render () { 212 | let expander; 213 | let bodyClasses = 'panel-body'; 214 | let title = ''; 215 | let postedString = ''; 216 | let topicLinkNode = false; 217 | 218 | if ( this.state.expandable ) { 219 | expander = ( 220 |
224 | 229 |
230 | ); 231 | 232 | bodyClasses = `${ bodyClasses } expandable`; 233 | } 234 | 235 | // Handle bbcode spoilers 236 | if ( this.props.postData.content.indexOf( 'bb-spoiler-toggle' ) > -1 ) { 237 | this.props.postData.content = this.props.postData.content.replace( /bb-spoiler-toggle">