├── .dokku-monorepo
├── .gitignore
├── LICENSE
├── README.md
├── frontend
├── .gitignore
├── config-overrides.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── google5a5f56e25db68875.html
│ ├── index.html
│ └── manifest.json
├── scripts
│ └── get-leadership-data.js
├── src
│ ├── app
│ │ ├── App.css
│ │ ├── App.tsx
│ │ └── registerServiceWorker.js
│ ├── feature
│ │ ├── about
│ │ │ ├── About.tsx
│ │ │ └── about.md
│ │ ├── map
│ │ │ └── Map.tsx
│ │ └── sidebar
│ │ │ ├── Sidebar.tsx
│ │ │ ├── district_info
│ │ │ ├── ContactTab.tsx
│ │ │ ├── DistrictInfo.tsx
│ │ │ ├── LeadershipTab.tsx
│ │ │ └── calendar
│ │ │ │ ├── CalendarTab.tsx
│ │ │ │ ├── EventDialog.tsx
│ │ │ │ ├── SubscribeDialog.tsx
│ │ │ │ └── subscribe-text.html
│ │ │ ├── intro
│ │ │ ├── Intro.tsx
│ │ │ └── intro.md
│ │ │ ├── search
│ │ │ └── Search.tsx
│ │ │ └── status
│ │ │ └── Status.tsx
│ ├── index.css
│ ├── index.tsx
│ └── shared
│ │ ├── actions
│ │ └── index.ts
│ │ ├── components
│ │ └── Html.tsx
│ │ ├── constants.js
│ │ ├── data
│ │ ├── districts-geo.json
│ │ ├── districts-info.json
│ │ └── districts-leadership.json
│ │ ├── google
│ │ └── GCalApi.ts
│ │ ├── icons
│ │ └── Twitter.tsx
│ │ ├── models
│ │ ├── Calendar.ts
│ │ ├── CalendarEvent.ts
│ │ ├── ComponentSizes.ts
│ │ ├── District.ts
│ │ ├── DistrictLeadership.ts
│ │ ├── Location.ts
│ │ ├── RootState.ts
│ │ └── Size.ts
│ │ ├── polyfills.ts
│ │ ├── reactGAMiddlewares.js
│ │ ├── reducers
│ │ ├── component-sizes.ts
│ │ ├── index.ts
│ │ └── selected-location.ts
│ │ ├── selectors
│ │ ├── district-id-from-route.ts
│ │ └── location-from-route.ts
│ │ ├── types
│ │ ├── html.d.ts
│ │ ├── json.d.ts
│ │ ├── jss-extend.d.ts
│ │ ├── mapbox-gl.ts
│ │ ├── mapbox.d.ts
│ │ ├── md.d.ts
│ │ ├── promised-location.d.ts
│ │ ├── react-jss.d.ts
│ │ ├── react-resize-detector.ts
│ │ └── swipeable-views.ts
│ │ └── utils
│ │ └── device.ts
├── static.json
├── tsconfig.json
├── tsconfig.test.json
└── tslint.json
└── scrapers
├── .gitignore
├── Pipfile
├── Pipfile.lock
├── Procfile
├── __init__.py
├── cbmap
├── __init__.py
├── items.py
├── jwtauth.py
├── pipelines.py
├── serialize.py
├── settings.py
├── spiders
│ ├── __init__.py
│ ├── bronx-cb2.py
│ ├── bronx-cb5.py
│ ├── bronx-cb9.py
│ ├── brooklyn-cb1.py
│ ├── brooklyn-cb2.py
│ ├── brooklyn-cb3.py
│ ├── brooklyn-cb6.py
│ ├── brooklyn-cb9.py
│ ├── manhattan-cb5.py
│ ├── manhattan-cb7.py
│ ├── queens-cb2.py
│ ├── queens-cb3.py
│ ├── queens-cb5.py
│ └── queens-cb6.py
└── utils.py
├── main.py
├── runtime.txt
├── scrapy.cfg
└── server.py
/.dokku-monorepo:
--------------------------------------------------------------------------------
1 | frontend=frontend
2 | scrapers=scrapers
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Node build artifacts
2 | node_modules
3 | npm-debug.log
4 |
5 | # Local development
6 | *.env
7 | *.dev
8 | *.swp
9 | .DS_Store
10 |
11 | # Docker
12 | Dockerfile
13 | docker-compose.yml
14 |
15 | # Webstorm
16 | .idea
17 |
18 | frontend/public/scraper-data
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018-present 59Boards Contributors
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7 | of the Software, and to permit persons to whom the Software is furnished to do
8 | so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 59Boards: NYC Community Board Map & Meetings Calendar Scraper
2 |
3 | New York City's government includes a system of 59 community board districts that offer a way for the public to get involved with local politics. Many New Yorkers don't know they exist, and the process to get involved can seem overwhelming.
4 |
5 | Most boards don't provide an easy way to get notified of upcoming meetings, making participation difficult. 59Boards [scrapes](https://en.wikipedia.org/wiki/Web_scraping) event information from board websites and provides a feed for your calendar app's subscription feature.
6 |
7 | ## Chat
8 | Join us in `#app-59boards` on the [Beta NYC Slack](http://slack.beta.nyc/).
9 |
10 | ## frontend
11 |
12 | ```
13 | $ cd frontend
14 | $ npm install
15 | $ npm start
16 | ```
17 |
18 | ## scrapers
19 | ```
20 | $ ln -s ../../scrapers/output frontend/public/scraper-data
21 | $ cd scrapers
22 | $ pip install virtualenv
23 | $ virtualenv venv
24 | $ . venv/bin/activate
25 | $ pip install pipenv
26 | $ pipenv install
27 | $ python main.py --all
28 | ```
29 |
30 | You'll also need [tabula-server](https://github.com/codebutler/tabula-server/) running on localhost:4000 for the PDF scrapers to work.
31 |
32 | ## License
33 |
34 | This project is licensed under the terms of the [MIT license](/LICENSE).
35 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | .idea
24 |
25 |
--------------------------------------------------------------------------------
/frontend/config-overrides.js:
--------------------------------------------------------------------------------
1 | const paths = require('react-scripts-ts/config/paths');
2 |
3 | const path = require("path");
4 | const ruleChildren = (loader) => loader.use || loader.oneOf || Array.isArray(loader.loader) && loader.loader || []
5 |
6 | const findIndexAndRules = (rulesSource, ruleMatcher) => {
7 | let result = undefined
8 | const rules = Array.isArray(rulesSource) ? rulesSource : ruleChildren(rulesSource)
9 | rules.some((rule, index) => result = ruleMatcher(rule) ? {index, rules} : findIndexAndRules(ruleChildren(rule), ruleMatcher))
10 | return result
11 | }
12 |
13 | const createLoaderMatcher = (loader) => (rule) => rule.loader && rule.loader.indexOf(`${path.sep}${loader}${path.sep}`) !== -1
14 | const fileLoaderMatcher = createLoaderMatcher('file-loader')
15 |
16 | const addBeforeRule = (rulesSource, ruleMatcher, value) => {
17 | const {index, rules} = findIndexAndRules(rulesSource, ruleMatcher)
18 | rules.splice(index, 0, value)
19 | }
20 |
21 | module.exports = function override(config) {
22 | config.module.rules.push({
23 | test: /\.(js|jsx)$/,
24 | include: paths.appSrc,
25 | loader: require.resolve('babel-loader'),
26 | options: {
27 | babelrc: false,
28 | presets: [require.resolve('babel-preset-react-app')],
29 | cacheDirectory: true,
30 | },
31 | });
32 | addBeforeRule(config.module.rules, fileLoaderMatcher, {
33 | test: /\.html$/,
34 | use: [
35 | { loader: require.resolve('html-loader') }
36 | ]
37 | });
38 | addBeforeRule(config.module.rules, fileLoaderMatcher, {
39 | test: /\.md$/,
40 | use: [
41 | { loader: require.resolve('html-loader') },
42 | { loader: require.resolve('markdown-loader') }
43 | ]
44 | });
45 | return config;
46 | };
47 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "59boards",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@mapbox/geo-viewport": "^0.2.2",
7 | "@turf/bbox": "^5.1.5",
8 | "@turf/boolean-point-in-polygon": "^5.1.5",
9 | "@turf/combine": "^5.1.5",
10 | "@turf/helpers": "^6.0.1",
11 | "@types/lodash": "^4.14.104",
12 | "array-flatten": "^2.1.1",
13 | "autosuggest-highlight": "^3.1.1",
14 | "core-js": "^2.5.3",
15 | "history": "^4.7.2",
16 | "html-loader": "^0.5.5",
17 | "immutable": "^3.8.2",
18 | "jss-extend": "^6.2.0",
19 | "lodash": "^4.17.5",
20 | "lodash-decorators": "latest",
21 | "mapbox": "^1.0.0-beta9",
22 | "mapbox-gl": "^0.44.1",
23 | "markdown-loader": "^2.0.2",
24 | "material-ui": "^1.0.0-beta.36",
25 | "material-ui-icons": "^1.0.0-beta.36",
26 | "moment": "^2.21.0",
27 | "npm": "^5.7.1",
28 | "promised-location": "^1.0.1",
29 | "raven-js": "^3.23.1",
30 | "react": "^16.2.0",
31 | "react-autosuggest": "^9.3.4",
32 | "react-dom": "^16.2.0",
33 | "react-ga": "^2.4.1",
34 | "react-redux": "^5.0.7",
35 | "react-resize-detector": "^2.2.0",
36 | "react-router-dom": "^4.2.2",
37 | "react-router-redux": "^5.0.0-alpha.9",
38 | "react-scripts": "^1.1.1",
39 | "react-scripts-ts": "^2.13.0",
40 | "react-swipeable-views": "^0.12.13",
41 | "redux": "^3.7.2",
42 | "redux-devtools-extension": "^2.13.2",
43 | "redux-logger": "^3.0.6",
44 | "redux-thunk": "^2.2.0",
45 | "typeface-roboto": "0.0.54",
46 | "typescript": "^2.7.2",
47 | "whatwg-fetch": "^2.0.3"
48 | },
49 | "scripts": {
50 | "start": "react-app-rewired start --scripts-version react-scripts-ts",
51 | "build": "react-app-rewired build --scripts-version react-scripts-ts",
52 | "test": "react-app-rewired test --scripts-version react-scripts-ts --env=jsdom",
53 | "eject": "react-app-rewired eject"
54 | },
55 | "devDependencies": {
56 | "@types/autosuggest-highlight": "^3.1.0",
57 | "@types/mapbox-gl": "^0.43.3",
58 | "@types/node": "^9.4.6",
59 | "@types/promise.prototype.finally": "^2.0.2",
60 | "@types/prop-types": "^15.5.2",
61 | "@types/react": "^16.0.40",
62 | "@types/react-autosuggest": "^9.3.3",
63 | "@types/react-dom": "^16.0.4",
64 | "@types/react-redux": "^5.0.15",
65 | "@types/react-router-dom": "^4.2.4",
66 | "@types/react-router-redux": "^5.0.12",
67 | "@types/react-swipeable-views": "^0.12.1",
68 | "@types/recompose": "^0.24.5",
69 | "@types/redux-logger": "^3.0.5",
70 | "@types/request": "^2.47.0",
71 | "@types/rest": "^1.3.28",
72 | "@types/through2": "^2.0.33",
73 | "JSONStream": "^1.3.2",
74 | "babel-plugin-transform-decorators": "^6.24.1",
75 | "csv-parser": "^1.12.0",
76 | "prop-types": "latest",
77 | "react-app-rewired": "^1.5.0",
78 | "redux-devtools": "^3.4.1",
79 | "request": "latest",
80 | "through2": "latest",
81 | "through2-map": "^3.0.0",
82 | "through2-reduce": "^1.1.1"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codebutler/59boards/fc7255aac18d67e08b4ae20c671540a6f80dc6e3/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/google5a5f56e25db68875.html:
--------------------------------------------------------------------------------
1 | google-site-verification: google5a5f56e25db68875.html
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | 59Boards - NYC Community Board Map
17 |
18 |
19 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "59Boards",
3 | "name": "59Boards: NYC Community Board Map",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/scripts/get-leadership-data.js:
--------------------------------------------------------------------------------
1 | const request = require('request');
2 | const csv = require('csv-parser');
3 | const t2map = require('through2-map');
4 | const _ = require('lodash');
5 | const fs = require('fs');
6 | const path = require('path');
7 | const JSONStream = require('JSONStream');
8 |
9 | const OUTPUT_DIR = path.join(path.dirname(__filename), '..', 'src', 'shared', 'data');
10 | const DISTRICT_INFO = require(path.join(OUTPUT_DIR, 'districts-info.json'));
11 |
12 | const findDistrict = (borough, number) => _.find(DISTRICT_INFO, district =>
13 | district.borough.toLowerCase() === borough.toLowerCase()
14 | && parseInt(district.number) === parseInt(number));
15 |
16 | // Community Board Leadership (Manhattan)
17 | // https://data.cityofnewyork.us/City-Government/Community-Board-Leadership/3gkd-ddzn
18 |
19 | request.get('https://data.cityofnewyork.us/api/views/3gkd-ddzn/rows.csv?accessType=DOWNLOAD')
20 | .pipe(csv())
21 | .pipe(t2map.obj(item => [
22 | findDistrict(
23 | item['Borough'] || 'Manhattan',
24 | item['Community Board']
25 | )['id'].toString(),
26 | _(item)
27 | .mapKeys((value, key) => _.camelCase(key))
28 | .mapValues((value) => value.trim())]))
29 | .pipe(JSONStream.stringifyObject('{\n', ',\n', '\n}\n', ' '))
30 | .pipe(fs.createWriteStream(path.join(OUTPUT_DIR, 'districts-leadership.json')));
31 |
--------------------------------------------------------------------------------
/frontend/src/app/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | position: absolute;
3 | overflow: hidden;
4 | top: 0;
5 | left: 0;
6 | width: 100%;
7 | height: 100%;
8 | }
9 |
10 | .App-logo {
11 | animation: App-logo-spin infinite 20s linear;
12 | height: 80px;
13 | }
14 |
15 | .App-header {
16 | background-color: #222;
17 | height: 150px;
18 | padding: 20px;
19 | color: white;
20 | }
21 |
22 | .App-title {
23 | font-size: 1.5em;
24 | }
25 |
26 | .App-intro {
27 | font-size: large;
28 | }
29 |
30 | .mapboxgl-map {
31 | position: absolute !important;
32 | top: 0 !important;
33 | right: 0 !important;
34 | left: 0 !important;
35 | bottom: 0 !important;
36 | }
37 |
38 | .mapboxgl-popup-content {
39 | pointer-events: none !important;
40 | }
41 |
42 |
--------------------------------------------------------------------------------
/frontend/src/app/App.tsx:
--------------------------------------------------------------------------------
1 | import MapboxClient from 'mapbox';
2 | import Reboot from 'material-ui/Reboot';
3 | import PropTypes from 'prop-types';
4 | import React, { Component } from 'react';
5 | import './App.css';
6 | import Map from '../feature/map/Map';
7 | import Sidebar from '../feature/sidebar/Sidebar';
8 | import { MAPBOX_TOKEN } from '../shared/constants';
9 | import ReactResizeDetector from 'react-resize-detector';
10 | import { connect } from 'react-redux';
11 | import { componentResized, RootAction } from '../shared/actions';
12 | import { Dispatch } from 'redux';
13 | import { Route, RouteComponentProps, withRouter } from 'react-router';
14 | import About from '../feature/about/About';
15 | import { create } from 'jss';
16 | import JssProvider from 'react-jss/lib/JssProvider';
17 | import { createGenerateClassName, jssPreset } from 'material-ui/styles';
18 | import jssExtend from 'jss-extend';
19 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
20 | import createMuiTheme from 'material-ui/styles/createMuiTheme';
21 | import indigo from 'material-ui/colors/indigo';
22 | import deepOrange from 'material-ui/colors/deepOrange';
23 | import withStyles from 'material-ui/styles/withStyles';
24 | import { WithStyles } from 'material-ui';
25 | import ReactGA from 'react-ga';
26 |
27 | const mapboxClient = new MapboxClient(MAPBOX_TOKEN);
28 |
29 | export interface AppContext {
30 | mapboxClient: MapboxClient;
31 | }
32 |
33 | interface DispatchProps {
34 | onResize: (width: number, height: number) => void;
35 | }
36 |
37 | type Props = DispatchProps;
38 | type PropsWithRoute = Props & RouteComponentProps<{}>;
39 | type PropsWithRouteAndStyle = PropsWithRoute & WithStyles<'@global'>;
40 |
41 | // Configure JSS
42 | const jss = create({ plugins: [jssExtend(), ...jssPreset().plugins] });
43 |
44 | // Custom Material-UI class name generator.
45 | const generateClassName = createGenerateClassName();
46 |
47 | const theme = createMuiTheme({
48 | palette: {
49 | primary: {...indigo},
50 | secondary: {...deepOrange}
51 | }
52 | });
53 |
54 | ReactGA.initialize('UA-115583455-1');
55 |
56 | class App extends Component {
57 |
58 | static childContextTypes = {
59 | mapboxClient: PropTypes.instanceOf(MapboxClient).isRequired
60 | };
61 |
62 | constructor(props: PropsWithRouteAndStyle) {
63 | super(props);
64 | this.state = {};
65 | }
66 |
67 | getChildContext(): AppContext {
68 | return { mapboxClient };
69 | }
70 |
71 | render() {
72 | return (
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
85 |
86 |
87 |
88 | );
89 | }
90 | }
91 |
92 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
93 | return {
94 | onResize: (width: number, height: number) => {
95 | dispatch(componentResized('app', { width, height }));
96 | }
97 | };
98 | };
99 |
100 | const styles = () => ({
101 | '@global': {
102 | 'a': {
103 | color: theme.palette.secondary.main,
104 | textDecoration: 'none',
105 | '&:hover': {
106 | textDecoration: 'underline',
107 | }
108 | }
109 | }
110 | });
111 |
112 | export default withRouter(
113 | connect(
114 | null,
115 | mapDispatchToProps
116 | )(
117 | withStyles(styles)(App)
118 | )
119 | );
120 |
--------------------------------------------------------------------------------
/frontend/src/app/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then(registration => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch(error => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then(response => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then(registration => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.'
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then(registration => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/frontend/src/feature/about/About.tsx:
--------------------------------------------------------------------------------
1 | import { Component, default as React } from 'react';
2 | import Dialog, { DialogActions, DialogContent, DialogContentText, DialogTitle } from 'material-ui/Dialog';
3 | import Button from 'material-ui/Button';
4 | import { connect } from 'react-redux';
5 | import { RootAction } from '../../shared/actions';
6 | import { Dispatch } from 'redux';
7 | import { push } from 'react-router-redux';
8 | import { bind } from 'lodash-decorators/bind';
9 | import Html from '../../shared/components/Html';
10 | import AboutHtml from './about.md';
11 |
12 | interface DispatchProps {
13 | onDialogExited: () => void;
14 | }
15 |
16 | type Props = DispatchProps;
17 |
18 | interface State {
19 | isOpen: boolean;
20 | }
21 |
22 | class About extends Component {
23 |
24 | constructor(props: Props) {
25 | super(props);
26 | this.state = { isOpen: true };
27 | }
28 |
29 | render() {
30 | return (
31 |
46 | );
47 | }
48 |
49 | @bind()
50 | private onDialogClose() {
51 | this.setState({ isOpen: false });
52 | }
53 | }
54 |
55 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
56 | return {
57 | onDialogExited: () => {
58 | dispatch(push('/'));
59 | }
60 | };
61 | };
62 |
63 | export default connect(
64 | null,
65 | mapDispatchToProps
66 | )(About);
67 |
--------------------------------------------------------------------------------
/frontend/src/feature/about/about.md:
--------------------------------------------------------------------------------
1 | Created By Eric Butler
2 |
3 | * [@codebutler](https://twitter.com/codebutler)
4 | * [GitHub](https://github.com/codebutler/59boards)
5 |
--------------------------------------------------------------------------------
/frontend/src/feature/map/Map.tsx:
--------------------------------------------------------------------------------
1 | import bbox from '@turf/bbox';
2 | import { Feature, FeatureCollection, GeoJsonProperties, Polygon } from 'geojson';
3 | import mapboxgl from 'mapbox-gl';
4 | import React, { Component } from 'react';
5 | import { connect } from 'react-redux';
6 | import { Dispatch } from 'redux';
7 | import { RootAction, selectDistrict } from '../../shared/actions';
8 | import { DUMMY_BORO_IDS, MAPBOX_TOKEN, NYC_BOUNDING_BOX } from '../../shared/constants';
9 | import Location from '../../shared/models/Location';
10 | import { RootState } from '../../shared/models/RootState';
11 | import withWidth, { WithWidthProps } from 'material-ui/utils/withWidth';
12 | import { Breakpoint } from 'material-ui/styles/createBreakpoints';
13 | import { bind, debounce } from 'lodash-decorators';
14 | import districtIdFromRoute from '../../shared/selectors/district-id-from-route';
15 | import DISTRICTS_GEOJSON from '../../shared/data/districts-geo.json';
16 | import EventData = mapboxgl.EventData;
17 | import LngLatLike = mapboxgl.LngLatLike;
18 | import MapMouseEvent = mapboxgl.MapMouseEvent;
19 | import Marker = mapboxgl.Marker;
20 | import Point = mapboxgl.Point;
21 | import LngLatBoundsLike = mapboxgl.LngLatBoundsLike;
22 | import { isMobileSafari } from '../../shared/utils/device';
23 |
24 | mapboxgl.accessToken = MAPBOX_TOKEN;
25 |
26 | interface StateProps {
27 | selectedLocation: Location;
28 | selectedDistrictId: number;
29 | appSize: Size;
30 | sidebarSize?: Size;
31 | }
32 |
33 | interface DispatchProps {
34 | onDistrictSelected: (district: number) => void;
35 | }
36 |
37 | type Props = StateProps & DispatchProps;
38 | type PropsWithStyles = Props & WithWidthProps;
39 |
40 | interface State {
41 | hoveredFeature?: Feature;
42 | hoveredPoint?: Point;
43 | hoveredLngLat?: LngLatLike;
44 | mapLoaded: boolean;
45 | }
46 |
47 | class Map extends Component {
48 |
49 | map: mapboxgl.Map;
50 | popup: mapboxgl.Popup;
51 | mapContainer: HTMLDivElement | null;
52 | locationMarker: Marker;
53 |
54 | private static boroDisplayText(boroCD: number) {
55 | const districtNum = parseInt(boroCD.toString().substr(1), 10);
56 | const boroIndex = boroCD.toString()[0];
57 | const boroNames = { 1: 'Manhattan', 2: 'Bronx', 3: 'Brooklyn', 4: 'Queens', 5: 'Staten Island'};
58 | return `${boroNames[boroIndex]} CB${districtNum}`;
59 | }
60 |
61 | private static getMapPadding(width: Breakpoint, sidebarSize?: Size): mapboxgl.PaddingOptions {
62 | const defaultPadding = 20;
63 | const topPadding = (width === 'xs' && sidebarSize)
64 | ? defaultPadding + sidebarSize.height
65 | : defaultPadding;
66 | const leftPadding = (width !== 'xs' && sidebarSize)
67 | ? defaultPadding + sidebarSize.width
68 | : defaultPadding;
69 | return {
70 | top: topPadding,
71 | right: defaultPadding,
72 | bottom: defaultPadding,
73 | left: leftPadding
74 | };
75 | }
76 |
77 | constructor(props: PropsWithStyles) {
78 | super(props);
79 |
80 | this.state = {
81 | mapLoaded: false
82 | };
83 | }
84 |
85 | componentDidMount() {
86 | const map = this.map = new mapboxgl.Map({
87 | container: this.mapContainer!,
88 | style: 'mapbox://styles/mapbox/light-v9',
89 | center: [-74.0060, 40.7128],
90 | zoom: 11
91 | });
92 |
93 | this.popup = new mapboxgl.Popup({
94 | closeButton: false,
95 | closeOnClick: false
96 | });
97 |
98 | this.locationMarker = new mapboxgl.Marker(null!!, {});
99 |
100 | map.on('load', this.onMapLoad);
101 |
102 | map.on('click', 'district-fills', (e: MapMouseEvent) => {
103 | this.setState({
104 | hoveredFeature: undefined,
105 | hoveredPoint: undefined,
106 | hoveredLngLat: undefined,
107 | });
108 |
109 | const districtId = e.features[0].properties!.BoroCD;
110 | this.props.onDistrictSelected(districtId);
111 | });
112 |
113 | // https://stackoverflow.com/a/46051711
114 | if (!isMobileSafari()) {
115 | map.on('mouseenter', 'district-fills', () => {
116 | map.getCanvas().style.cursor = 'pointer';
117 | });
118 |
119 | map.on('mouseleave', 'district-fills', () => {
120 | map.getCanvas().style.cursor = '';
121 | });
122 |
123 | map.on('mousemove', 'district-fills', (e: MapMouseEvent) => {
124 | this.setState({
125 | hoveredFeature: e.features[0],
126 | hoveredPoint: e.point,
127 | hoveredLngLat: e.lngLat
128 | });
129 | });
130 |
131 | map.on('mouseleave', 'district-fills', (e: MapMouseEvent) => {
132 | this.setState({
133 | hoveredFeature: undefined,
134 | hoveredPoint: undefined,
135 | hoveredLngLat: undefined,
136 | });
137 | });
138 | }
139 | }
140 |
141 | componentWillUpdate(nextProps: PropsWithStyles, nextState: State) {
142 | const curProps = this.props;
143 | const curState = this.state;
144 |
145 | if (!nextState.mapLoaded) {
146 | return;
147 | }
148 |
149 | const { hoveredFeature, hoveredLngLat } = nextState;
150 | if (hoveredFeature) {
151 | const boroCd = hoveredFeature.properties!.BoroCD;
152 | this.map.setFilter('district-fills-hover', ['==', 'BoroCD', boroCd]);
153 | } else {
154 | this.map.setFilter('district-fills-hover', ['==', 'BoroCD', '']);
155 | }
156 |
157 | const {selectedLocation, selectedDistrictId} = nextProps;
158 | if (selectedLocation) {
159 | this.locationMarker.setLngLat(selectedLocation.center);
160 | this.locationMarker.addTo(this.map);
161 | } else {
162 | this.locationMarker.remove();
163 | }
164 |
165 | const filter = ['==', 'BoroCD', selectedDistrictId ? selectedDistrictId : ''];
166 | this.map.setFilter('district-borders-selected', filter);
167 | this.map.setFilter('district-fills-selected', filter);
168 |
169 | if (hoveredFeature && hoveredLngLat) {
170 | this.popup
171 | .setLngLat(hoveredLngLat)
172 | .setHTML(Map.boroDisplayText(hoveredFeature.properties!.BoroCD))
173 | .addTo(this.map);
174 | } else {
175 | this.popup.remove();
176 | }
177 |
178 | const oldSidebarSize = curProps.sidebarSize;
179 | const sidebarSize = nextProps.sidebarSize;
180 |
181 | const oldWidth = curProps.width;
182 | const width = nextProps.width;
183 |
184 | const oldSelectedDistrictId = curProps.selectedDistrictId;
185 |
186 | const oldAppHeight = curProps.appSize.height;
187 | const appHeight = nextProps.appSize.height;
188 |
189 | if (selectedDistrictId !== oldSelectedDistrictId ||
190 | sidebarSize !== oldSidebarSize ||
191 | width !== oldWidth ||
192 | oldAppHeight !== appHeight ||
193 | nextState.mapLoaded !== curState.mapLoaded) {
194 |
195 | if (selectedDistrictId) {
196 | const featureCollection = DISTRICTS_GEOJSON as FeatureCollection;
197 | const districtFeatures = featureCollection.features
198 | .filter((feature: Feature) => {
199 | return feature.properties!.BoroCD === selectedDistrictId;
200 | });
201 | if (districtFeatures.length <= 0) {
202 | return;
203 | }
204 | const bounds = new mapboxgl.LngLatBounds();
205 | districtFeatures.forEach((feature: Feature) => {
206 | const [minX, minY, maxX, maxY] = bbox(feature);
207 | bounds.extend(new mapboxgl.LngLatBounds([minX, minY], [maxX, maxY]));
208 | });
209 | this.fitMapBounds(bounds, nextProps);
210 | } else {
211 | this.fitMapBounds(NYC_BOUNDING_BOX, nextProps);
212 | }
213 | }
214 | }
215 |
216 | render() {
217 | return (
218 | this.mapContainer = el} />
219 | );
220 | }
221 |
222 | @bind()
223 | @debounce(100)
224 | private fitMapBounds(bounds: LngLatBoundsLike, props: PropsWithStyles) {
225 | this.map.fitBounds(bounds, {
226 | padding: Map.getMapPadding(props.width, props.sidebarSize),
227 | duration: 500
228 | });
229 | }
230 |
231 | @bind()
232 | private onMapLoad(event: EventData) {
233 | const map = event.target;
234 |
235 | map.addSource('districts', {
236 | type: 'geojson',
237 | data: DISTRICTS_GEOJSON
238 | });
239 |
240 | map.addLayer({
241 | 'id': 'district-fills',
242 | 'type': 'fill',
243 | 'source': 'districts',
244 | 'layout': {},
245 | 'paint': {
246 | 'fill-color': '#6d74b6',
247 | 'fill-opacity': 0.12
248 | },
249 | 'filter': ['!in', 'BoroCD', ...DUMMY_BORO_IDS]
250 | });
251 |
252 | map.addLayer({
253 | 'id': 'district-fills-hover',
254 | 'type': 'fill',
255 | 'source': 'districts',
256 | 'layout': {},
257 | 'paint': {
258 | 'fill-color': '#6d74b6',
259 | 'fill-opacity': 0.4
260 | },
261 | 'filter': ['==', 'BoroCD', '']
262 | });
263 |
264 | map.addLayer({
265 | 'id': 'district-fills-selected',
266 | 'type': 'fill',
267 | 'source': 'districts',
268 | 'layout': {},
269 | 'paint': {
270 | 'fill-color': '#b66d6d',
271 | 'fill-opacity': 0.4
272 | },
273 | 'filter': ['==', 'BoroCD', '']
274 | });
275 |
276 | map.addLayer({
277 | 'id': 'district-borders',
278 | 'type': 'line',
279 | 'source': 'districts',
280 | 'layout': {},
281 | 'paint': {
282 | 'line-color': '#1f336b',
283 | 'line-width': 1
284 | },
285 | 'filter': ['!in', 'BoroCD', ...DUMMY_BORO_IDS]
286 | });
287 |
288 | map.addLayer({
289 | 'id': 'district-borders-selected',
290 | 'type': 'line',
291 | 'source': 'districts',
292 | 'layout': {},
293 | 'paint': {
294 | 'line-color': '#6b1f1f',
295 | 'line-width': 2
296 | },
297 | 'filter': ['==', 'BoroCD', '']
298 | });
299 |
300 | this.setState({ mapLoaded: true });
301 | }
302 | }
303 |
304 | const mapStateToProps = (state: RootState): StateProps => {
305 | const selectedDistrictId = districtIdFromRoute(state)!;
306 | return {
307 | selectedLocation: state.selectedLocation!,
308 | selectedDistrictId: selectedDistrictId,
309 | appSize: state.componentSizes.app,
310 | sidebarSize: state.componentSizes.sidebar
311 | };
312 | };
313 |
314 | const mapDispatchToProps = (dispatch: Dispatch
): DispatchProps => {
315 | return {
316 | onDistrictSelected: (districtId: number) => {
317 | dispatch(selectDistrict(districtId));
318 | }
319 | };
320 | };
321 |
322 | export default connect(
323 | mapStateToProps,
324 | mapDispatchToProps
325 | )(withWidth()(Map));
326 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { bind } from 'lodash-decorators';
2 | import React, { Component } from 'react';
3 | import { connect } from 'react-redux';
4 | import { Dispatch } from 'redux';
5 | import { clearSelection, componentResized, RootAction } from '../../shared/actions';
6 | import { RootState } from '../../shared/models/RootState';
7 | import DistrictInfo from './district_info/DistrictInfo';
8 | import Intro from './intro/Intro';
9 | import Search from './search/Search';
10 | import withStyles from 'material-ui/styles/withStyles';
11 | import { Theme } from 'material-ui/styles';
12 | import { WithStyles } from 'material-ui';
13 | import ReactResizeDetector from 'react-resize-detector';
14 | import { Route, RouteComponentProps, Switch, withRouter } from 'react-router';
15 | import Status from './status/Status';
16 | import districtIdFromRoute from '../../shared/selectors/district-id-from-route';
17 | import withWidth, { WithWidthProps } from 'material-ui/utils/withWidth';
18 | import { isMobileSafari } from '../../shared/utils/device';
19 |
20 | interface StateProps {
21 | selectedDistrictId?: number;
22 | }
23 |
24 | interface DispatchProps {
25 | onClearSelection: () => void;
26 | onResize: (width: number, height: number) => void;
27 | }
28 |
29 | interface State {
30 | isSearchFocused: boolean;
31 | }
32 |
33 | type ClassKey =
34 | | 'sidebar'
35 | | 'introContainer';
36 |
37 | type Props = StateProps & DispatchProps;
38 | type PropsWithRoute = Props & RouteComponentProps<{}>;
39 | type PropsWithStyles = PropsWithRoute & WithStyles & WithWidthProps;
40 |
41 | class Sidebar extends Component {
42 |
43 | introContainer: HTMLElement | null;
44 |
45 | constructor(props: PropsWithStyles) {
46 | super(props);
47 | this.state = { isSearchFocused: false };
48 | }
49 |
50 | render() {
51 | const { classes, onResize } = this.props;
52 | return (
53 |
54 |
55 |
56 |
60 | {!this.props.selectedDistrictId && (
61 | this.introContainer = el} className={classes.introContainer}>
62 |
63 |
64 | )}
65 | {this.props.selectedDistrictId && (
66 |
67 | )}
68 | {!this.props.selectedDistrictId && (
69 |
70 | )}
71 | >
72 | )
73 | }
74 | />
75 |
76 |
81 |
82 | );
83 | }
84 |
85 | componentDidUpdate() {
86 | const isMobile = (this.props.width) === 'xs';
87 | const hideIntroForMobile = this.state.isSearchFocused && isMobile && !isMobileSafari();
88 | if (this.introContainer) {
89 | if (hideIntroForMobile) {
90 | this.introContainer.style.marginTop = `${-this.introContainer.clientHeight}px`;
91 | } else {
92 | this.introContainer.style.marginTop = '0px';
93 | }
94 | }
95 | }
96 |
97 | @bind()
98 | private onCloseClicked() {
99 | this.props.onClearSelection();
100 | }
101 |
102 | @bind()
103 | private onSearchFocusChanged(isFocused: boolean) {
104 | this.setState({ isSearchFocused: isFocused });
105 | }
106 | }
107 |
108 | const mapStateToProps = (state: RootState): StateProps => {
109 | return {
110 | selectedDistrictId: districtIdFromRoute(state)
111 | };
112 | };
113 |
114 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
115 | return {
116 | onClearSelection: () => {
117 | dispatch(clearSelection());
118 | },
119 | onResize: (width: number, height: number) => {
120 | dispatch(componentResized('sidebar', { width, height }));
121 | }
122 | };
123 | };
124 |
125 | const styles = (theme: Theme) => ({
126 | sidebar: {
127 | position: 'absolute' as 'absolute',
128 | maxHeight: '100%',
129 | overflow: 'scroll' as 'scroll',
130 | zIndex: 1,
131 | width: 392,
132 | maxWidth: '100%',
133 | padding: 8,
134 | [theme.breakpoints.down('xs')]: {
135 | width: '100%'
136 | }
137 | },
138 | introContainer: {
139 | transition: 'margin 300ms'
140 | }
141 | });
142 |
143 | export default withRouter(
144 | connect(
145 | mapStateToProps,
146 | mapDispatchToProps
147 | )(
148 | withStyles(styles)(withWidth()(Sidebar))
149 | )
150 | );
151 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/district_info/ContactTab.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import LinkIcon from 'material-ui-icons/Link';
3 | import PhoneIcon from 'material-ui-icons/Phone';
4 | import PlaceIcon from 'material-ui-icons/Place';
5 | import EmailIcon from 'material-ui-icons/Email';
6 | import TwitterIcon from '../../../shared/icons/Twitter';
7 | import { List, ListItem, ListItemIcon, ListItemText, WithStyles } from 'material-ui';
8 | import withStyles from 'material-ui/styles/withStyles';
9 | import { Theme } from 'material-ui/styles';
10 | import District from '../../../shared/models/District';
11 |
12 | interface Props {
13 | district: District;
14 | }
15 |
16 | type ClassKey =
17 | | 'contactList'
18 | | 'contactListItemText';
19 |
20 | type PropsWithStyles = Props & WithStyles;
21 |
22 | class ContactTab extends Component {
23 |
24 | render() {
25 | const { classes, district } = this.props;
26 | return (
27 |
31 | {district.address && (
32 |
38 |
39 |
40 |
41 |
45 |
46 | )}
47 | { district.website && (
48 |
54 |
55 |
56 |
57 |
61 |
62 | )}
63 | { district.twitter && (
64 |
70 |
71 |
72 |
73 |
77 |
78 | )}
79 | { district.email && (
80 |
86 |
87 |
88 |
89 |
93 |
94 | )}
95 | { district.phone && (
96 |
101 |
102 |
103 |
104 |
108 |
109 | )}
110 |
111 | );
112 | }
113 | }
114 |
115 | const styles = (theme: Theme) => (
116 | {
117 | contactList: {
118 | overflow: 'hidden' as 'hidden',
119 | whiteSpace: 'nowrap' as 'nonowrap',
120 | },
121 | contactListItemText: {
122 | maskImage: 'linear-gradient(left, white 80%, rgba(255,255,255,0) 100%)'
123 | },
124 | });
125 |
126 | export default withStyles(styles)(ContactTab);
127 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/district_info/DistrictInfo.tsx:
--------------------------------------------------------------------------------
1 | import { WithStyles } from 'material-ui';
2 | import CloseIcon from 'material-ui-icons/Close';
3 | import Card, { CardContent, CardHeader } from 'material-ui/Card';
4 | import IconButton from 'material-ui/IconButton';
5 | import { Theme } from 'material-ui/styles';
6 | import withStyles from 'material-ui/styles/withStyles';
7 | import Tabs, { Tab } from 'material-ui/Tabs';
8 | import React, { Component } from 'react';
9 | import { connect } from 'react-redux';
10 | import DISTRICTS from '../../../shared/data/districts-info.json';
11 | import District from '../../../shared/models/District';
12 | import { RootState } from '../../../shared/models/RootState';
13 | import SwipeableViews from 'react-swipeable-views';
14 | import districtIdFromRoute from '../../../shared/selectors/district-id-from-route';
15 | import ContactTab from './ContactTab';
16 | import CalendarTab from './calendar/CalendarTab';
17 | import LeadershipTab from './LeadershipTab';
18 |
19 | interface OwnProps {
20 | onCloseInfoClicked: () => void;
21 | }
22 |
23 | interface StateProps {
24 | district: District;
25 | }
26 |
27 | type ClassKey =
28 | | 'card'
29 | | 'cardHeader'
30 | | 'cardContent'
31 | | 'title'
32 | | 'tab';
33 |
34 | type Props = OwnProps & StateProps;
35 | type PropsWithStyles = Props & WithStyles;
36 |
37 | interface State {
38 | selectedTab: number;
39 | }
40 |
41 | const SHOW_LEADERSHIP = false; // This feature isn't quite ready
42 |
43 | class DistrictInfo extends Component {
44 |
45 | constructor(props: PropsWithStyles) {
46 | super(props);
47 | this.state = { selectedTab: 0 };
48 | }
49 |
50 | render() {
51 | const { classes, district } = this.props;
52 | if (district) {
53 | return (
54 |
55 |
59 |
60 |
61 | )}
62 | title={`${district.borough} CB${district.number}`}
63 | subheader={district.neighborhoods}
64 | />
65 |
66 | this.setState({ selectedTab: value })}
69 | indicatorColor="primary"
70 | textColor="primary"
71 | fullWidth={true}
72 | >
73 |
74 | {SHOW_LEADERSHIP && }
75 |
76 |
77 | {SHOW_LEADERSHIP ? (
78 | { this.setState({selectedTab: index}); }}
82 | >
83 |
84 |
85 |
86 |
87 | ) : (
88 | { this.setState({selectedTab: index}); }}
92 | >
93 |
94 |
95 |
96 | )}
97 |
98 |
99 | );
100 | } else {
101 | return (
102 |
103 |
106 |
107 |
108 | )}
109 | title="No Community Board"
110 | />
111 |
112 | );
113 | }
114 | }
115 | }
116 |
117 | const styles = (theme: Theme) => (
118 | {
119 | card: {
120 | marginBottom: theme.spacing.unit
121 | },
122 | cardHeader: {
123 | paddingBottom: 0,
124 | },
125 | cardContent: {
126 | padding: 0,
127 | '&:last-child': {
128 | paddingBottom: theme.spacing.unit
129 | }
130 | },
131 | title: {
132 | marginBottom: 16,
133 | fontSize: 14,
134 | color: theme.palette.text.secondary
135 | },
136 | tab: {
137 | [theme.breakpoints.up('md')]: {
138 | minWidth: 'inherit',
139 | },
140 | }
141 | }
142 | );
143 |
144 | const mapStateToProps = (state: RootState): StateProps => {
145 | const selectedDistrictId = districtIdFromRoute(state)!;
146 | return {
147 | district: DISTRICTS[selectedDistrictId]
148 | };
149 | };
150 |
151 | export default connect(
152 | mapStateToProps
153 | )(withStyles(styles)(DistrictInfo));
154 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/district_info/LeadershipTab.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import District from '../../../shared/models/District';
3 | import DISTRICT_LEADERSHIP from '../../../shared/data/districts-leadership.json';
4 | import DistrictLeadership from '../../../shared/models/DistrictLeadership';
5 | import { List, ListItem, ListItemText, WithStyles } from 'material-ui';
6 | import withStyles from 'material-ui/styles/withStyles';
7 | import { Theme } from 'material-ui/styles';
8 |
9 | interface Props {
10 | district: District;
11 | }
12 |
13 | type ClassKey =
14 | | 'emptyListContainer';
15 |
16 | type PropsWithStyles = Props & WithStyles;
17 |
18 | class LeadershipTab extends Component {
19 | render() {
20 | const { classes } = this.props;
21 | const leadership = DISTRICT_LEADERSHIP[this.props.district.id] as DistrictLeadership;
22 | if (!leadership) {
23 | return (
24 |
25 |
Information currently unavailable, check website.
26 |
27 | );
28 | }
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 | }
41 |
42 | const styles = (theme: Theme) => (
43 | {
44 | emptyListContainer: {
45 | textAlign: 'center' as 'center',
46 | paddingTop: theme.spacing.unit * 3,
47 | paddingBottom: theme.spacing.unit,
48 | }
49 | }
50 | );
51 |
52 | export default withStyles(styles)(LeadershipTab);
53 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/district_info/calendar/CalendarTab.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import _, { Dictionary } from 'lodash';
3 | import { CircularProgress, List, ListItem, ListItemIcon, ListItemText, ListSubheader, WithStyles } from 'material-ui';
4 | import { Theme } from 'material-ui/styles';
5 | import withStyles from 'material-ui/styles/withStyles';
6 | import moment, { Moment } from 'moment';
7 | import District from '../../../../shared/models/District';
8 | import { Calendar } from '../../../../shared/models/Calendar';
9 | import PropTypes from 'prop-types';
10 | import { SwipeableViewsChildContext } from '../../../../shared/types/swipeable-views';
11 | import Divider from 'material-ui/Divider';
12 | import EventIcon from 'material-ui-icons/Event';
13 | import WarningIcon from 'material-ui-icons/Warning';
14 | import { amber } from 'material-ui/colors';
15 | import SubscribeDialog from './SubscribeDialog';
16 | import Typography from 'material-ui/Typography';
17 | import Grid from 'material-ui/Grid';
18 | import EventDialog from './EventDialog';
19 |
20 | interface Props {
21 | district: District;
22 | }
23 |
24 | type ClassKey =
25 | | 'calendarList'
26 | | 'calendarListItemText'
27 | | 'eventAddress'
28 | | 'eventDate'
29 | | 'eventDateDom'
30 | | 'eventDateDow'
31 | | 'eventsHeader'
32 | | 'emptyListContainer';
33 |
34 | type PropsWithStyles = Props & WithStyles;
35 |
36 | interface State {
37 | events?: CalendarEvent[];
38 | isSubscribeDialogOpen: boolean;
39 | calendar?: Calendar;
40 | calendarWebUrl?: string;
41 | selectedEvent?: CalendarEvent;
42 | }
43 |
44 | interface Context {
45 | swipeableViews: SwipeableViewsChildContext;
46 | }
47 |
48 | enum RenderEventsState {
49 | LOADING,
50 | HAS_EVENTS,
51 | NO_EVENTS
52 | }
53 |
54 | interface RenderData {
55 | isScraped: boolean;
56 | calendarWebUrl?: string;
57 | calendarIcalUrl?: string;
58 | eventsState: RenderEventsState;
59 | eventsByMonth?: Dictionary;
60 | }
61 |
62 | interface EventRenderData {
63 | event: CalendarEvent;
64 | groupKey: string;
65 | monthText: string;
66 | domText: string;
67 | dowText: string;
68 | primaryText: string;
69 | secondaryText: string;
70 | fragmentKey: string;
71 | listItemKey: string;
72 | isFirstForDay: boolean;
73 | }
74 |
75 | class CalendarTab extends Component {
76 |
77 | static contextTypes = {
78 | swipeableViews: PropTypes.object.isRequired,
79 | };
80 |
81 | context: Context;
82 |
83 | constructor(props: PropsWithStyles) {
84 | super(props);
85 | this.state = { isSubscribeDialogOpen: false };
86 | }
87 |
88 | componentDidMount() {
89 | this.fetchEvents();
90 | }
91 |
92 | componentDidUpdate(prevProps: PropsWithStyles, prevState: State) {
93 | if (this.props.district !== prevProps.district) {
94 | this.fetchEvents();
95 | }
96 |
97 | this.context.swipeableViews.slideUpdateHeight();
98 | }
99 |
100 | render() {
101 | const { classes } = this.props;
102 | const { isSubscribeDialogOpen } = this.state;
103 | const data = this.createRenderData();
104 | return (
105 |
106 | { data.eventsState === RenderEventsState.LOADING && (
107 |
108 |
109 |
110 | )}
111 | { data.eventsState === RenderEventsState.HAS_EVENTS && (
112 |
113 | this.setState({ isSubscribeDialogOpen: true })}
116 | >
117 |
118 |
119 |
120 |
121 |
122 |
123 | {data.isScraped && (
124 |
125 |
126 |
127 |
128 |
133 |
134 | )}
135 | {_(data.eventsByMonth)
136 | .map((monthEvents, month) => (
137 |
138 | {month}
139 | {monthEvents.map((event: EventRenderData) => (
140 | this.setState({ selectedEvent: event.event })}
144 | >
145 |
146 |
151 |
154 | {event.domText}
155 |
156 |
159 | {event.dowText}
160 |
161 |
162 |
163 |
171 |
172 |
173 | )
174 | )}
175 | )
176 | )
177 | .value()
178 | }
179 | {this.state.selectedEvent &&
180 | this.setState({selectedEvent: undefined})}
183 | />
184 | }
185 |
186 | )}
187 | { data.eventsState === RenderEventsState.NO_EVENTS && (
188 |
189 |
Events currently unavailable,
190 | check website.
191 |
192 | )}
193 | { isSubscribeDialogOpen && (
194 |
this.setState({ isSubscribeDialogOpen: false })}
197 | />
198 | )}
199 |
200 | );
201 | }
202 |
203 | private fetchEvents() {
204 | this.setState({events: undefined});
205 | if (this.props.district.calendar) {
206 | const cal = new Calendar(this.props.district.calendar);
207 | cal.events
208 | .then((events) => {
209 | this.setState({ events, calendar: cal });
210 | })
211 | .catch((err) => {
212 | console.log('failed to get events', err);
213 | this.setState({ events: [] });
214 | });
215 | } else {
216 | this.setState({ events: [] });
217 | }
218 | }
219 |
220 | private createRenderData(): RenderData {
221 | const { district } = this.props;
222 | const { events, calendar } = this.state;
223 |
224 | const getEventState = (): RenderEventsState => {
225 | if (!events) {
226 | return RenderEventsState.LOADING;
227 | } else if (events && events.length > 0) {
228 | return RenderEventsState.HAS_EVENTS;
229 | } else {
230 | return RenderEventsState.NO_EVENTS;
231 | }
232 | };
233 |
234 | const getSecondaryText = (event: CalendarEvent, eventMoment: Moment): string => {
235 | const eventTime = eventMoment.format('h:mma');
236 | if (event.location) {
237 | return `${eventTime} at ${event.location}`;
238 | } else {
239 | return eventTime;
240 | }
241 | };
242 |
243 | const getIsFirstForDay = (list: ArrayLike, index: number): boolean => {
244 | if (index === 0) {
245 | return true;
246 | }
247 | const prevEvent = list[index - 1];
248 | const thisEvent = list[index];
249 | return !moment(prevEvent.date).isSame(moment(thisEvent.date), 'day');
250 | };
251 |
252 | return {
253 | isScraped: !!(district.calendar && district.calendar.scraperId),
254 | calendarWebUrl: district.calendar ? district.calendar.web : district.website,
255 | calendarIcalUrl: calendar && calendar.icalUrl,
256 | eventsState: getEventState(),
257 | eventsByMonth: events && _(events)
258 | .map((event, index, list) => {
259 | const eventMoment = moment(event.date);
260 | return {
261 | event: event,
262 | groupKey: eventMoment.format('MMMM YYYY'),
263 | domText: eventMoment.format('D'),
264 | dowText: eventMoment.format('ddd'),
265 | primaryText: event.summary,
266 | secondaryText: getSecondaryText(event, eventMoment),
267 | fragmentKey: `fragment-${event.id}`,
268 | listItemKey: `item-${event.id}`,
269 | isFirstForDay: getIsFirstForDay(list, index)
270 | } as EventRenderData;
271 | })
272 | .groupBy((event) => event.groupKey)
273 | .value()
274 | };
275 | }
276 | }
277 |
278 | const styles = (theme: Theme) => (
279 | {
280 | calendarList: {
281 | overflow: 'hidden' as 'hidden',
282 | whiteSpace: 'nowrap' as 'nonowrap',
283 | },
284 | calendarListItemText: {
285 | maskImage: 'linear-gradient(left, white 80%, rgba(255,255,255,0) 100%)'
286 | },
287 | eventsHeader: {
288 | color: theme.palette.text.secondary
289 | },
290 | eventAddress: {
291 | whiteSpace: 'nowrap' as 'nowrap'
292 | },
293 | eventDate: {
294 | marginRight: theme.spacing.unit * 2
295 | },
296 | eventDateDom: {
297 | fontSize: '1.2rem'
298 | },
299 | eventDateDow: {
300 | fontSize: '0.8rem'
301 | },
302 | emptyListContainer: {
303 | textAlign: 'center' as 'center',
304 | paddingTop: theme.spacing.unit * 3,
305 | paddingBottom: theme.spacing.unit,
306 | }
307 | }
308 | );
309 |
310 | export default withStyles(styles)(CalendarTab);
311 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/district_info/calendar/EventDialog.tsx:
--------------------------------------------------------------------------------
1 | import Dialog from 'material-ui/Dialog/Dialog';
2 | import DialogTitle from 'material-ui/Dialog/DialogTitle';
3 | import DialogContent from 'material-ui/Dialog/DialogContent';
4 | import AccessTimeIcon from 'material-ui-icons/AccessTime';
5 | import PlaceIcon from 'material-ui-icons/Place';
6 | import React, { Component } from 'react';
7 | import TextFieldsIcons from 'material-ui-icons/ShortText';
8 | import Grid from 'material-ui/Grid/Grid';
9 | import DialogActions from 'material-ui/Dialog/DialogActions';
10 | import Button from 'material-ui/Button/Button';
11 | import Html from '../../../../shared/components/Html';
12 | import moment from 'moment';
13 | import { Theme, WithStyles } from 'material-ui';
14 | import withStyles from 'material-ui/styles/withStyles';
15 | import { bind } from 'lodash-decorators/bind';
16 |
17 | interface Props {
18 | event: CalendarEvent;
19 | onDialogExited: () => void;
20 | }
21 |
22 | interface State {
23 | isOpen: boolean;
24 | }
25 |
26 | type PropsWithStyles = Props & WithStyles<'eventDescription'>;
27 |
28 | class EventDialog extends Component {
29 |
30 | constructor(props: PropsWithStyles) {
31 | super(props);
32 | this.state = { isOpen: true };
33 | }
34 |
35 | render(): React.ReactNode {
36 | const { event, classes } = this.props;
37 | return (
38 |
74 | );
75 | }
76 |
77 | @bind()
78 | private onDialogClose() {
79 | this.setState({ isOpen: false });
80 | }
81 | }
82 |
83 | const styles = (theme: Theme) => ({
84 | eventDescription: {
85 | whiteSpace: 'pre-line' as 'pre-line'
86 | }
87 | });
88 |
89 | export default withStyles(styles)(EventDialog);
90 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/district_info/calendar/SubscribeDialog.tsx:
--------------------------------------------------------------------------------
1 | import { Component, default as React } from 'react';
2 | import Dialog, { DialogActions, DialogTitle } from 'material-ui/Dialog';
3 | import Button from 'material-ui/Button';
4 | import { bind } from 'lodash-decorators/bind';
5 | import { DialogContent, DialogContentText, InputAdornment } from 'material-ui';
6 | import IconButton from 'material-ui/IconButton';
7 | import ContentCopyIcon from 'material-ui-icons/ContentCopy';
8 | import TextField from 'material-ui/TextField';
9 | import SubscribeText from './subscribe-text.html';
10 | import Html from '../../../../shared/components/Html';
11 |
12 | interface Props {
13 | subscribeUrl: string;
14 | onDialogExited: () => void;
15 | }
16 |
17 | interface State {
18 | isOpen: boolean;
19 | }
20 |
21 | class SubscribeDialog extends Component {
22 |
23 | private inputEl: HTMLInputElement;
24 |
25 | constructor(props: Props) {
26 | super(props);
27 | this.state = { isOpen: true };
28 | }
29 |
30 | render() {
31 | return (
32 |
64 | );
65 | }
66 |
67 | @bind()
68 | private onCopyClicked() {
69 | if (this.inputEl) {
70 | this.inputEl.select();
71 | document.execCommand('copy');
72 | }
73 | }
74 |
75 | @bind()
76 | private onDialogClose() {
77 | this.setState({ isOpen: false });
78 | }
79 | }
80 |
81 | export default SubscribeDialog;
82 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/district_info/calendar/subscribe-text.html:
--------------------------------------------------------------------------------
1 | To have the latest meetings for this community board automatically appear in your calendar
2 | app, copy and paste the URL below into its subscription feature. See instructions for
3 | Google Calendar and
4 | iCloud.
5 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/intro/Intro.tsx:
--------------------------------------------------------------------------------
1 | import { CardHeader, Menu, MenuItem, WithStyles } from 'material-ui';
2 | import Card, { CardContent } from 'material-ui/Card';
3 | import { Theme, withStyles } from 'material-ui/styles';
4 | import React, { Component, MouseEvent } from 'react';
5 | import IconButton from 'material-ui/IconButton';
6 | import MoreVertIcon from 'material-ui-icons/MoreVert';
7 | import { bind } from 'lodash-decorators/bind';
8 | import { connect } from 'react-redux';
9 | import { RootAction } from '../../../shared/actions';
10 | import { Dispatch } from 'redux';
11 | import { push } from 'react-router-redux';
12 | import IntroHtml from './intro.md';
13 | import Html from '../../../shared/components/Html';
14 |
15 | interface DispatchProps {
16 | onShowAbout: () => void;
17 | onShowStatus: () => void;
18 | }
19 |
20 | type Props = DispatchProps;
21 | type PropsWithStyles = Props & WithStyles<'card'|'introText'>;
22 |
23 | interface State {
24 | menuAnchorEl?: HTMLElement;
25 | }
26 |
27 | class Intro extends Component {
28 |
29 | state = {} as State;
30 |
31 | render() {
32 | const { classes } = this.props;
33 | const { menuAnchorEl } = this.state;
34 | return (
35 |
36 |
39 |
40 | }
41 | title="59Boards.nyc"
42 | />
43 |
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | @bind()
61 | private onShowMenuButtonClick(event: MouseEvent) {
62 | this.setState({ menuAnchorEl: event.currentTarget });
63 | }
64 |
65 | @bind()
66 | private onReportIssueItemClick(event: MouseEvent) {
67 | window.open('https://github.com/codebutler/59boards/issues', '_blank');
68 | }
69 |
70 | @bind()
71 | private onStatusItemClick(event: MouseEvent) {
72 | this.props.onShowStatus();
73 | this.onMenuClose();
74 | }
75 |
76 | @bind()
77 | private onAboutItemClick(event: MouseEvent) {
78 | this.props.onShowAbout();
79 | this.onMenuClose();
80 | }
81 |
82 | @bind()
83 | private onMenuClose() {
84 | this.setState({ menuAnchorEl: undefined });
85 | }
86 | }
87 |
88 | const styles = (theme: Theme) => ({
89 | card: {
90 | marginBottom: theme.spacing.unit
91 | },
92 | introText: {
93 | extend: theme.typography.body1,
94 | '& p': {
95 | marginTop: 0
96 | },
97 | '& ul': {
98 | marginBottom: 0
99 | },
100 | '& ul li': {
101 | paddingBottom: theme.spacing.unit
102 | },
103 | }
104 | });
105 |
106 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
107 | return {
108 | onShowAbout: () => {
109 | dispatch(push('/about'));
110 | },
111 | onShowStatus: () => {
112 | dispatch(push('/status'));
113 | }
114 | };
115 | };
116 |
117 | export default connect(
118 | null,
119 | mapDispatchToProps
120 | )(withStyles(styles)(Intro));
121 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/intro/intro.md:
--------------------------------------------------------------------------------
1 | New York City's government includes a system of 59 community board districts that offer a way for the public to get involved with local politics. Many New Yorkers don't know they exist, and the process to get involved can seem overwhelming.
2 |
3 | Most boards don't provide an easy way to get notified of upcoming meetings, making participation difficult.
4 | 59Boards [scrapes](https://en.wikipedia.org/wiki/Web_scraping) event information from board websites and provides a feed for your calendar app's subscription feature.
5 |
6 | Enter your address below or select a district on the map to get started!
7 |
8 | * [About Community Boards (nyc.gov)](http://www.nyc.gov/html/cau/html/cb/about.shtml)
9 | * [How to join your community board (Curbed)](https://ny.curbed.com/2017/3/15/14918194/nyc-community-board-member-get-involved)
10 |
--------------------------------------------------------------------------------
/frontend/src/feature/sidebar/search/Search.tsx:
--------------------------------------------------------------------------------
1 | import flatten, { NestedArray } from 'array-flatten';
2 | import match from 'autosuggest-highlight/match';
3 | import parse from 'autosuggest-highlight/parse';
4 | import MapboxClient from 'mapbox';
5 | import { CircularProgress, WithStyles } from 'material-ui';
6 | import MyLocationIcon from 'material-ui-icons/MyLocation';
7 | import SearchIcon from 'material-ui-icons/Search';
8 | import Card, { CardContent } from 'material-ui/Card';
9 | import Grid from 'material-ui/Grid';
10 | import IconButton from 'material-ui/IconButton';
11 | import InputAdornment from 'material-ui/Input/InputAdornment';
12 | import { MenuItem } from 'material-ui/Menu';
13 | import Paper from 'material-ui/Paper';
14 | import { Theme, withStyles } from 'material-ui/styles';
15 | import TextField from 'material-ui/TextField';
16 | import PromisedLocation from 'promised-location';
17 | import PropTypes from 'prop-types';
18 | import React, { Component, CSSProperties, FocusEvent, FormEvent } from 'react';
19 | import Autosuggest, {
20 | ChangeEvent,
21 | InputProps,
22 | RenderSuggestionParams,
23 | RenderSuggestionsContainerParams,
24 | SuggestionSelectedEventData,
25 | SuggestionsFetchRequestedParams
26 | } from 'react-autosuggest';
27 | import { connect } from 'react-redux';
28 | import { Dispatch } from 'redux';
29 | import { Response } from 'rest';
30 | import { RootAction, selectLocation } from '../../../shared/actions';
31 | import { AppContext } from '../../../app/App';
32 | import { NYC_BOUNDING_BOX, NYC_CENTER } from '../../../shared/constants';
33 | import { RootState } from '../../../shared/models/RootState';
34 | import Location from '../../../shared/models/Location';
35 | import { bind } from 'lodash-decorators';
36 | import CarmenLocation = mapbox.CarmenLocation;
37 |
38 | interface OwnProps {
39 | onSearchFocusChanged: (isFocused: boolean) => void;
40 | }
41 |
42 | interface StateProps {
43 | selectedLocation?: Location;
44 | }
45 |
46 | interface DispatchProps {
47 | onLocationSelected: (location: Location) => void;
48 | }
49 |
50 | type Props = OwnProps & StateProps & DispatchProps;
51 |
52 | type ClassKey =
53 | | 'card'
54 | | 'container'
55 | | 'suggestionsContainerOpen'
56 | | 'suggestion'
57 | | 'suggestionsList'
58 | | 'cardContent'
59 | | 'root'
60 | | 'gridTextField'
61 | | 'myLocationAdornment'
62 | | 'myLocationIcon'
63 | | 'myLocationProgress';
64 |
65 | type PropsWithStyles = Props & WithStyles;
66 |
67 | class Search extends Component {
68 |
69 | static contextTypes = {
70 | mapboxClient: PropTypes.instanceOf(MapboxClient).isRequired
71 | };
72 |
73 | context: AppContext;
74 |
75 | state = {
76 | inputValue: '',
77 | location: null,
78 | suggestions: [],
79 | isWaitingForLocation: false
80 | };
81 |
82 | private static getSuggestionValue(suggestion: Location) {
83 | return suggestion.place_name!;
84 | }
85 |
86 | private static renderSuggestionsContainer(options: RenderSuggestionsContainerParams) {
87 | const {containerProps, children} = options;
88 | return (
89 |
90 | {children}
91 |
92 | );
93 | }
94 |
95 | componentWillMount() {
96 | const selectedLocation = this.props.selectedLocation;
97 | if (selectedLocation) {
98 | this.setState({ inputValue: selectedLocation.place_name });
99 | }
100 | }
101 |
102 | componentWillUnmount() {
103 | this.props.onSearchFocusChanged(false);
104 | }
105 |
106 | render() {
107 | const { classes } = this.props;
108 | const { inputValue } = this.state;
109 | return (
110 |
132 | );
133 | }
134 |
135 | @bind()
136 | private renderInput(inputProps: InputProps) {
137 | const { classes, ref, ...other } = inputProps;
138 | const {isWaitingForLocation} = this.state;
139 | return (
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
160 |
161 |
162 | ) : (
163 |
167 |
168 |
172 |
173 |
174 | ),
175 | disableUnderline: true,
176 | classes: {
177 | input: classes.input,
178 | },
179 | ...other as object,
180 | }}
181 | />
182 |
183 |
184 |
185 |
186 | );
187 | }
188 |
189 | @bind()
190 | private onChange(event: React.FormEvent