Merge pull request #417 from mz8i/feature/mobile-ui

UI/UX Improvements
This commit is contained in:
mz8i 2019-09-30 12:23:46 +01:00 committed by GitHub
commit da4f998fb0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 862 additions and 669 deletions

133
app/package-lock.json generated
View File

@ -855,7 +855,6 @@
"version": "7.4.5", "version": "7.4.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz",
"integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==", "integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==",
"dev": true,
"requires": { "requires": {
"regenerator-runtime": "^0.13.2" "regenerator-runtime": "^0.13.2"
}, },
@ -863,8 +862,7 @@
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.13.2", "version": "0.13.2",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz",
"integrity": "sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==", "integrity": "sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA=="
"dev": true
} }
} }
}, },
@ -1037,6 +1035,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/geojson": {
"version": "7946.0.7",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.7.tgz",
"integrity": "sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ==",
"dev": true
},
"@types/glob": { "@types/glob": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz",
@ -1075,6 +1079,15 @@
"integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==", "integrity": "sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A==",
"dev": true "dev": true
}, },
"@types/leaflet": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.5.1.tgz",
"integrity": "sha512-E5k+vyE2Tv9wQsO6ZsEy08Pjd8RjHPkCzz3Ubt7feMc+5+VkbXtcZMcciczRWuMN5rFIsVywLxRhvTp7fAbbzg==",
"dev": true,
"requires": {
"@types/geojson": "*"
}
},
"@types/mime": { "@types/mime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",
@ -1139,6 +1152,16 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"@types/react-leaflet": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@types/react-leaflet/-/react-leaflet-2.4.0.tgz",
"integrity": "sha512-kDZ2Ky6FQxXRODBEFlq25Lu80Nc7UsDSHCmHTa22UQn2RIJxe3O443K0vzOrFyzWPpVEOmqBpfDkX9QSTBoFxg==",
"dev": true,
"requires": {
"@types/leaflet": "*",
"@types/react": "*"
}
},
"@types/react-router": { "@types/react-router": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.0.3.tgz",
@ -7062,6 +7085,11 @@
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true "dev": true
}, },
"gud": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
"integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
},
"gzip-size": { "gzip-size": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz",
@ -7244,25 +7272,16 @@
"dev": true "dev": true
}, },
"history": { "history": {
"version": "4.7.2", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz", "resolved": "https://registry.npmjs.org/history/-/history-4.9.0.tgz",
"integrity": "sha512-1zkBRWW6XweO0NBcjiphtVJVsIQ+SXF29z9DVkceeaSLVMFXHool+fdCZD4spDCfZJCILPILc3bm7Bc+HRi0nA==", "integrity": "sha512-H2DkjCjXf0Op9OAr6nJ56fcRkTSNrUiv41vNJ6IswJjif6wlpZK0BTfFbi7qK9dXLSYZxkq5lBsj3vUjlYBYZA==",
"requires": { "requires": {
"invariant": "^2.2.1", "@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0", "loose-envify": "^1.2.0",
"resolve-pathname": "^2.2.0", "resolve-pathname": "^2.2.0",
"value-equal": "^0.4.0", "tiny-invariant": "^1.0.2",
"warning": "^3.0.0" "tiny-warning": "^1.0.0",
}, "value-equal": "^0.4.0"
"dependencies": {
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"requires": {
"loose-envify": "^1.0.0"
}
}
} }
}, },
"hmac-drbg": { "hmac-drbg": {
@ -7277,9 +7296,12 @@
} }
}, },
"hoist-non-react-statics": { "hoist-non-react-statics": {
"version": "2.5.5", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" "integrity": "sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==",
"requires": {
"react-is": "^16.7.0"
}
}, },
"home-or-tmp": { "home-or-tmp": {
"version": "2.0.0", "version": "2.0.0",
@ -7901,6 +7923,7 @@
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dev": true,
"requires": { "requires": {
"loose-envify": "^1.0.0" "loose-envify": "^1.0.0"
} }
@ -9451,6 +9474,16 @@
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="
}, },
"mini-create-react-context": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz",
"integrity": "sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw==",
"requires": {
"@babel/runtime": "^7.4.0",
"gud": "^1.0.0",
"tiny-warning": "^1.0.2"
}
},
"mini-css-extract-plugin": { "mini-css-extract-plugin": {
"version": "0.4.5", "version": "0.4.5",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.4.5.tgz", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.4.5.tgz",
@ -10515,7 +10548,7 @@
}, },
"semver": { "semver": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "http://registry.npmjs.org/semver/-/semver-4.3.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz",
"integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c="
} }
} }
@ -13840,17 +13873,20 @@
"integrity": "sha512-Wl0p9HonxGnyTly+gfiotj5f0Su/ZD7omZ5ko0ji3lEuJe4nsokbz9UcLt9CaR/C33Ha4OmzPn8I/XqwKJUs7g==" "integrity": "sha512-Wl0p9HonxGnyTly+gfiotj5f0Su/ZD7omZ5ko0ji3lEuJe4nsokbz9UcLt9CaR/C33Ha4OmzPn8I/XqwKJUs7g=="
}, },
"react-router": { "react-router": {
"version": "4.3.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.0.1.tgz",
"integrity": "sha512-yrvL8AogDh2X42Dt9iknk4wF4V8bWREPirFfS9gLU1huk6qK41sg7Z/1S81jjTrGHxa3B8R3J6xIkDAA6CVarg==", "integrity": "sha512-EM7suCPNKb1NxcTZ2LEOWFtQBQRQXecLxVpdsP4DW4PbbqYWeRiLyV/Tt1SdCrvT2jcyXAXmVTmzvSzrPR63Bg==",
"requires": { "requires": {
"history": "^4.7.2", "@babel/runtime": "^7.1.2",
"hoist-non-react-statics": "^2.5.0", "history": "^4.9.0",
"invariant": "^2.2.4", "hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1", "loose-envify": "^1.3.1",
"mini-create-react-context": "^0.3.0",
"path-to-regexp": "^1.7.0", "path-to-regexp": "^1.7.0",
"prop-types": "^15.6.1", "prop-types": "^15.6.2",
"warning": "^4.0.1" "react-is": "^16.6.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
}, },
"dependencies": { "dependencies": {
"isarray": { "isarray": {
@ -13869,16 +13905,17 @@
} }
}, },
"react-router-dom": { "react-router-dom": {
"version": "4.3.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.0.1.tgz",
"integrity": "sha512-c/MlywfxDdCp7EnB7YfPMOfMD3tOtIjrQlj/CKfNMBxdmpJP8xcz5P/UAFn3JbnQCNUxsHyVVqllF9LhgVyFCA==", "integrity": "sha512-zaVHSy7NN0G91/Bz9GD4owex5+eop+KvgbxXsP/O+iW1/Ln+BrJ8QiIR5a6xNPtrdTvLkxqlDClx13QO1uB8CA==",
"requires": { "requires": {
"history": "^4.7.2", "@babel/runtime": "^7.1.2",
"invariant": "^2.2.4", "history": "^4.9.0",
"loose-envify": "^1.3.1", "loose-envify": "^1.3.1",
"prop-types": "^15.6.1", "prop-types": "^15.6.2",
"react-router": "^4.3.1", "react-router": "5.0.1",
"warning": "^4.0.1" "tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
} }
}, },
"read-pkg": { "read-pkg": {
@ -16114,6 +16151,16 @@
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=",
"dev": true "dev": true
}, },
"tiny-invariant": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz",
"integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA=="
},
"tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"tmp": { "tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -17002,14 +17049,6 @@
"makeerror": "1.0.x" "makeerror": "1.0.x"
} }
}, },
"warning": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.2.tgz",
"integrity": "sha512-wbTp09q/9C+jJn4KKJfJfoS6VleK/Dti0yqWSm6KMvJ4MRCXFQNapHuJXutJIrWV0Cf4AhTdeIe4qdKHR1+Hug==",
"requires": {
"loose-envify": "^1.0.0"
}
},
"watch": { "watch": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/watch/-/watch-0.18.0.tgz", "resolved": "https://registry.npmjs.org/watch/-/watch-0.18.0.tgz",

View File

@ -32,7 +32,7 @@
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-leaflet": "^1.0.1", "react-leaflet": "^1.0.1",
"react-leaflet-universal": "^1.2.0", "react-leaflet-universal": "^1.2.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^5.0.1",
"serialize-javascript": "^1.7.0", "serialize-javascript": "^1.7.0",
"sharp": "^0.21.3" "sharp": "^0.21.3"
}, },
@ -45,6 +45,7 @@
"@types/prop-types": "^15.7.1", "@types/prop-types": "^15.7.1",
"@types/react": "^16.9.1", "@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5", "@types/react-dom": "^16.8.5",
"@types/react-leaflet": "^2.4.0",
"@types/react-router-dom": "^4.3.4", "@types/react-router-dom": "^4.3.4",
"@types/webpack-env": "^1.14.0", "@types/webpack-env": "^1.14.0",
"babel-eslint": "^10.0.2", "babel-eslint": "^10.0.2",

View File

@ -2,7 +2,7 @@
* Client-side entry point to shared frontend React App * Client-side entry point to shared frontend React App
* *
*/ */
import BrowserRouter from 'react-router-dom/BrowserRouter'; import { BrowserRouter } from 'react-router-dom';
import React from 'react'; import React from 'react';
import { hydrate } from 'react-dom'; import { hydrate } from 'react-dom';

View File

@ -1,31 +1,30 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { Route, Switch, Link } from 'react-router-dom'; import { Route, Switch, Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { parse } from 'query-string';
import '../../node_modules/bootstrap/dist/css/bootstrap.min.css'; import '../../node_modules/bootstrap/dist/css/bootstrap.min.css';
import './app.css'; import './app.css';
import BuildingView from './building/building-view';
import ColouringMap from './map/map';
import Header from './header'; import Header from './header';
import MultiEdit from './building/multi-edit';
import Categories from './building/categories';
import Sidebar from './building/sidebar';
import AboutPage from './pages/about'; import AboutPage from './pages/about';
import ContributorAgreementPage from './pages/contributor-agreement'; import ContributorAgreementPage from './pages/contributor-agreement';
import PrivacyPolicyPage from './pages/privacy-policy'; import PrivacyPolicyPage from './pages/privacy-policy';
import Welcome from './pages/welcome';
import Login from './user/login'; import Login from './user/login';
import MyAccountPage from './user/my-account'; import MyAccountPage from './user/my-account';
import SignUp from './user/signup'; import SignUp from './user/signup';
import ForgottenPassword from './user/forgotten-password'; import ForgottenPassword from './user/forgotten-password';
import PasswordReset from './user/password-reset'; import PasswordReset from './user/password-reset';
import { parseCategoryURL } from '../parse'; import MapApp from './map-app';
interface AppProps {
user?: any;
building?: any;
building_like?: boolean;
}
/** /**
* App component * App component
@ -39,35 +38,28 @@ import { parseCategoryURL } from '../parse';
* map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in * map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in
* child components to navigate without a full page reload. * child components to navigate without a full page reload.
*/ */
class App extends React.Component<any, any> { // TODO: add proper types class App extends React.Component<AppProps, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS static propTypes = { // TODO: generate propTypes from TS
user: PropTypes.object, user: PropTypes.object,
building: PropTypes.object, building: PropTypes.object,
building_like: PropTypes.bool building_like: PropTypes.bool
} };
constructor(props) { constructor(props: Readonly<AppProps>) {
super(props); super(props);
// set building revision id, default 0
const rev = (props.building)? +props.building.revision_id : 0;
this.state = { this.state = {
user: props.user, user: props.user
building: props.building,
building_like: props.building_like,
revision_id: rev
}; };
this.login = this.login.bind(this); this.login = this.login.bind(this);
this.updateUser = this.updateUser.bind(this); this.updateUser = this.updateUser.bind(this);
this.logout = this.logout.bind(this); this.logout = this.logout.bind(this);
this.selectBuilding = this.selectBuilding.bind(this);
this.colourBuilding = this.colourBuilding.bind(this);
this.increaseRevision = this.increaseRevision.bind(this);
} }
login(user) { login(user) {
if (user.error) { if (user.error) {
this.logout(); this.logout();
return return;
} }
this.setState({user: user}); this.setState({user: user});
} }
@ -80,171 +72,12 @@ class App extends React.Component<any, any> { // TODO: add proper types
this.setState({user: undefined}); this.setState({user: undefined});
} }
increaseRevision(revisionId) {
revisionId = +revisionId;
// bump revision id, only ever increasing
if (revisionId > this.state.revision_id){
this.setState({revision_id: revisionId})
}
}
selectBuilding(building) {
this.increaseRevision(building.revision_id);
// get UPRNs and update
fetch(`/api/buildings/${building.building_id}/uprns.json`, {
method: 'GET',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
if (res.error) {
console.error(res);
} else {
building.uprns = res.uprns;
this.setState({building: building});
}
}).catch((err) => {
console.error(err)
this.setState({building: building});
});
// get if liked and update
fetch(`/api/buildings/${building.building_id}/like.json`, {
method: 'GET',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
if (res.error) {
console.error(res);
} else {
this.setState({building_like: res.like});
}
}).catch((err) => {
console.error(err)
this.setState({building_like: false});
});
}
/**
* Colour building
*
* Used in multi-edit mode to colour buildings on map click
*
* Pulls data from URL to form update
*
* @param {object} building
*/
colourBuilding(building) {
const cat = parseCategoryURL(window.location.pathname);
const q = parse(window.location.search);
const data = (cat === 'like')? {like: true}: JSON.parse(q.data as string); // TODO: verify what happens if data is string[]
if (cat === 'like'){
this.likeBuilding(building.building_id)
} else {
this.updateBuilding(building.building_id, data)
}
}
likeBuilding(buildingId) {
fetch(`/api/buildings/${buildingId}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: true})
}).then(
res => res.json()
).then(function(res){
if (res.error) {
console.error({error: res.error})
} else {
this.increaseRevision(res.revision_id);
}
}.bind(this)).catch(
(err) => console.error({error: err})
);
}
updateBuilding(buildingId, data){
fetch(`/api/buildings/${buildingId}.json`, {
method: 'POST',
body: JSON.stringify(data),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(res => {
if (res.error) {
console.error({error: res.error})
} else {
this.increaseRevision(res.revision_id);
}
}).catch(
(err) => console.error({error: err})
);
}
render() { render() {
const building_id = (this.state.building)?
this.state.building.building_id
: 2503371 // Default to UCL main building. TODO use last selected if any
const building = this.state.building;
const building_like = this.state.building_like;
return ( return (
<Fragment> <Fragment>
<Header user={this.state.user} /> <Header user={this.state.user} />
<main> <main>
<Switch> <Switch>
<Route exact path="/">
<Welcome />
</Route>
<Route exact path="/view/categories.html">
<Sidebar>
<Categories mode="view" building_id={building_id} />
</Sidebar>
</Route>
<Route exact path="/edit/categories.html">
<Sidebar>
<Categories mode="edit" building_id={building_id} />
</Sidebar>
</Route>
<Route exact path="/multi-edit/:cat.html" render={(props) => (
<MultiEdit
{...props}
user={this.state.user}
/>
) } />
<Route exact path="/:mode/:cat/building/:building.html" render={(props) => (
<BuildingView
mode={props.match.params.mode}
cat={props.match.params.cat}
building={this.state.building}
building_like={this.state.building_like}
selectBuilding={this.selectBuilding}
user={this.state.user}
/>
) } />
</Switch>
<Switch>
<Route exact path="/(multi-edit.*|edit.*|view.*)?" render={(props) => (
<ColouringMap
{...props}
building={this.state.building}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}
colourBuilding={this.colourBuilding}
/>
) } />
<Route exact path="/about.html" component={AboutPage} /> <Route exact path="/about.html" component={AboutPage} />
<Route exact path="/login.html"> <Route exact path="/login.html">
<Login user={this.state.user} login={this.login} /> <Login user={this.state.user} login={this.login} />
@ -263,6 +96,14 @@ class App extends React.Component<any, any> { // TODO: add proper types
</Route> </Route>
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} /> <Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} /> <Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
<Route exact path={["/", "/:mode(view|edit|multi-edit)/:category/:building(\\d+)?"]} render={(props) => (
<MapApp
{...props}
building={this.props.building}
building_like={this.props.building_like}
user={this.state.user}
/>
)} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
</main> </main>

View File

@ -1,8 +1,7 @@
import React from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Sidebar from './sidebar';
import InfoBox from '../components/info-box'; import InfoBox from '../components/info-box';
@ -11,12 +10,12 @@ interface BuildingNotFoundProps {
} }
const BuildingNotFound: React.FunctionComponent<BuildingNotFoundProps> = (props) => ( const BuildingNotFound: React.FunctionComponent<BuildingNotFoundProps> = (props) => (
<Sidebar> <Fragment>
<InfoBox msg="We can't find that one anywhere - try the map again?" /> <InfoBox msg="We can't find that one anywhere - try the map again?" />
<div className="buttons-container ml-3 mr-3"> <div className="buttons-container ml-3 mr-3">
<Link to={`/${props.mode}/categories.html`} className="btn btn-secondary">Back to categories</Link> <Link to={`/${props.mode}/categories`} className="btn btn-secondary">Back to categories</Link>
</div> </div>
</Sidebar> </Fragment>
); );
BuildingNotFound.propTypes = { BuildingNotFound.propTypes = {

View File

@ -21,15 +21,11 @@ import LikeContainer from './data-containers/like';
* @param props * @param props
*/ */
const BuildingView = (props) => { const BuildingView = (props) => {
if (typeof(props.building) === "undefined"){
return <BuildingNotFound mode="view" />
}
switch (props.cat) { switch (props.cat) {
case 'location': case 'location':
return <LocationContainer return <LocationContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Location" title="Location"
help="https://pages.colouring.london/location" help="https://pages.colouring.london/location"
intro="Where are the buildings? Address, location and cross-references." intro="Where are the buildings? Address, location and cross-references."
@ -37,7 +33,7 @@ const BuildingView = (props) => {
case 'use': case 'use':
return <UseContainer return <UseContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
inactive={true} inactive={true}
title="Land Use" title="Land Use"
intro="How are buildings used, and how does use change over time? Coming soon…" intro="How are buildings used, and how does use change over time? Coming soon…"
@ -46,7 +42,7 @@ const BuildingView = (props) => {
case 'type': case 'type':
return <TypeContainer return <TypeContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
inactive={true} inactive={true}
title="Type" title="Type"
intro="How were buildings previously used? Coming soon…" intro="How were buildings previously used? Coming soon…"
@ -55,7 +51,7 @@ const BuildingView = (props) => {
case 'age': case 'age':
return <AgeContainer return <AgeContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Age" title="Age"
help="https://pages.colouring.london/age" help="https://pages.colouring.london/age"
intro="Building age data can support energy analysis and help predict long-term change." intro="Building age data can support energy analysis and help predict long-term change."
@ -63,7 +59,7 @@ const BuildingView = (props) => {
case 'size': case 'size':
return <SizeContainer return <SizeContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Size &amp; Shape" title="Size &amp; Shape"
intro="How big are buildings?" intro="How big are buildings?"
help="https://pages.colouring.london/shapeandsize" help="https://pages.colouring.london/shapeandsize"
@ -71,7 +67,7 @@ const BuildingView = (props) => {
case 'construction': case 'construction':
return <ConstructionContainer return <ConstructionContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Construction" title="Construction"
intro="How are buildings built? Coming soon…" intro="How are buildings built? Coming soon…"
help="https://pages.colouring.london/construction" help="https://pages.colouring.london/construction"
@ -80,7 +76,7 @@ const BuildingView = (props) => {
case 'team': case 'team':
return <TeamContainer return <TeamContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Team" title="Team"
intro="Who built the buildings? Coming soon…" intro="Who built the buildings? Coming soon…"
help="https://pages.colouring.london/team" help="https://pages.colouring.london/team"
@ -89,7 +85,7 @@ const BuildingView = (props) => {
case 'sustainability': case 'sustainability':
return <SustainabilityContainer return <SustainabilityContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Sustainability" title="Sustainability"
intro="Are buildings energy efficient? Coming soon…" intro="Are buildings energy efficient? Coming soon…"
help="https://pages.colouring.london/sustainability" help="https://pages.colouring.london/sustainability"
@ -98,7 +94,7 @@ const BuildingView = (props) => {
case 'greenery': case 'greenery':
return <GreeneryContainer return <GreeneryContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Greenery" title="Greenery"
intro="Is there greenery nearby? Coming soon…" intro="Is there greenery nearby? Coming soon…"
help="https://pages.colouring.london/greenery" help="https://pages.colouring.london/greenery"
@ -107,7 +103,7 @@ const BuildingView = (props) => {
case 'community': case 'community':
return <CommunityContainer return <CommunityContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Community" title="Community"
intro="How does this building work for the local community?" intro="How does this building work for the local community?"
help="https://pages.colouring.london/community" help="https://pages.colouring.london/community"
@ -116,7 +112,7 @@ const BuildingView = (props) => {
case 'planning': case 'planning':
return <PlanningContainer return <PlanningContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Planning" title="Planning"
intro="Planning controls relating to protection and reuse." intro="Planning controls relating to protection and reuse."
help="https://pages.colouring.london/planning" help="https://pages.colouring.london/planning"
@ -124,7 +120,7 @@ const BuildingView = (props) => {
case 'like': case 'like':
return <LikeContainer return <LikeContainer
{...props} {...props}
key={props.building.building_id} key={props.building && props.building.building_id}
title="Like Me!" title="Like Me!"
intro="Do you like the building and think it contributes to the city?" intro="Do you like the building and think it contributes to the city?"
help="https://pages.colouring.london/likeme" help="https://pages.colouring.london/likeme"

View File

@ -9,10 +9,11 @@
text-align: center; text-align: center;
} }
.data-category-list li { .data-category-list li {
position: relative;
display: inline-block; display: inline-block;
vertical-align: bottom; vertical-align: bottom;
width: 11rem; width: 10rem;
height: 11rem; height: 10rem;
margin: 0.375rem; margin: 0.375rem;
box-shadow: 0 0 2px 5px #ffffff; box-shadow: 0 0 2px 5px #ffffff;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
@ -28,7 +29,7 @@
display: block; display: block;
padding: 0.1em; padding: 0.1em;
width: 100%; width: 100%;
height: 7rem; height: 100%;
} }
.data-category-list .category-link:hover, .data-category-list .category-link:hover,
.data-category-list .category-link:active, .data-category-list .category-link:active,
@ -36,18 +37,24 @@
color: #222; color: #222;
} }
.data-category-list .help { .data-category-list .help {
height: 4rem; position: absolute;
padding: 1.5em 0; bottom: 0.75rem;
width: 100%; right: 0.75rem;
background-color: rgba(255,255,255,0.2); color: #222;
transition: background-color 0.2s;
} }
.data-category-list .help:hover, .data-category-list .help:hover,
.data-category-list .help:active, .data-category-list .help:active,
.data-category-list .help:focus { .data-category-list .help:focus {
color: #222; color: #000;
background-color: rgba(255,255,255,0.3); background-color: rgba(37, 10, 10, 0.3);
} }
.data-category-list .help::before {
content: "\f05a";
font-family: FontAwesome;
margin-right: 0.25rem;
}
.data-category-list .category { .data-category-list .category {
text-align: center; text-align: center;
font-size: 1.5em; font-size: 1.5em;

View File

@ -2,8 +2,6 @@ import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Sidebar from './sidebar';
import './categories.css' import './categories.css'
const Categories = (props) => ( const Categories = (props) => (
@ -124,11 +122,15 @@ Categories.propTypes = {
building_id: PropTypes.number building_id: PropTypes.number
} }
const Category = (props) => ( const Category = (props) => {
let categoryLink = `/${props.mode}/${props.slug}`;
if (props.building_id != undefined) categoryLink += `/${props.building_id}`;
return (
<li className={`category-block ${props.slug} background-${props.slug}`}> <li className={`category-block ${props.slug} background-${props.slug}`}>
<NavLink <NavLink
className="category-link" className="category-link"
to={`/${props.mode}/${props.slug}/building/${props.building_id}.html`} to={categoryLink}
title={ title={
(props.inactive)? (props.inactive)?
'Coming soon… Click more info for details.' 'Coming soon… Click more info for details.'
@ -138,10 +140,11 @@ const Category = (props) => (
<p className="description">{props.desc}</p> <p className="description">{props.desc}</p>
</NavLink> </NavLink>
<a className="icon-button help" href={props.help}> <a className="icon-button help" href={props.help}>
More info More
</a> </a>
</li> </li>
) );
}
Category.propTypes = { Category.propTypes = {
title: PropTypes.string, title: PropTypes.string,

View File

@ -6,17 +6,17 @@ import { BackIcon, EditIcon, ViewIcon }from '../components/icons';
const ContainerHeader: React.FunctionComponent<any> = (props) => ( const ContainerHeader: React.FunctionComponent<any> = (props) => (
<header className={`section-header view ${props.cat} background-${props.cat}`}> <header className={`section-header view ${props.cat} background-${props.cat}`}>
<Link className="icon-button back" to="/view/categories.html"> <Link className="icon-button back" to={`/${props.mode}/categories${props.building != undefined ? `/${props.building.building_id}` : ''}`}>
<BackIcon /> <BackIcon />
</Link> </Link>
<h2 className="h2">{props.title}</h2> <h2 className="h2">{props.title}</h2>
<nav className="icon-buttons"> <nav className="icon-buttons">
{ {
(!props.inactive)? props.building != undefined && !props.inactive ?
props.copy.copying? props.copy.copying?
<Fragment> <Fragment>
<NavLink <NavLink
to={`/multi-edit/${props.cat}.html?data=${props.data_string}`} to={`/multi-edit/${props.cat}?data=${props.data_string}`}
className="icon-button copy"> className="icon-button copy">
Copy selected Copy selected
</NavLink> </NavLink>
@ -45,19 +45,19 @@ const ContainerHeader: React.FunctionComponent<any> = (props) => (
: null : null
} }
{ {
!props.inactive && !props.copy.copying? props.building != undefined && !props.inactive && !props.copy.copying?
(props.mode === 'edit')? (props.mode === 'edit')?
<NavLink <NavLink
className="icon-button view" className="icon-button view"
title="View data" title="View data"
to={`/view/${props.cat}/building/${props.building.building_id}.html`}> to={`/view/${props.cat}/${props.building.building_id}`}>
View View
<ViewIcon /> <ViewIcon />
</NavLink> </NavLink>
: <NavLink : <NavLink
className="icon-button edit" className="icon-button edit"
title="Edit data" title="Edit data"
to={`/edit/${props.cat}/building/${props.building.building_id}.html`}> to={`/edit/${props.cat}/${props.building.building_id}`}>
Edit Edit
<EditIcon /> <EditIcon />
</NavLink> </NavLink>

View File

@ -12,7 +12,7 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
<Tooltip text="People who like the building and think it contributes to the city." /> <Tooltip text="People who like the building and think it contributes to the city." />
<div className="icon-buttons"> <div className="icon-buttons">
<NavLink <NavLink
to={`/multi-edit/like.html?data=${data_string}`} to={`/multi-edit/like?data=${data_string}`}
className="icon-button like"> className="icon-button like">
Like more Like more
</NavLink> </NavLink>

View File

@ -2,9 +2,7 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import BuildingNotFound from './building-not-found';
import ContainerHeader from './container-header'; import ContainerHeader from './container-header';
import Sidebar from './sidebar';
import ErrorBox from '../components/error-box'; import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box'; import InfoBox from '../components/info-box';
@ -198,8 +196,7 @@ const withCopyEdit = (WrappedComponent) => {
toggleCopyAttribute: this.toggleCopyAttribute, toggleCopyAttribute: this.toggleCopyAttribute,
copyingKey: (key) => this.state.keys_to_copy[key] copyingKey: (key) => this.state.keys_to_copy[key]
} }
return this.props.building? return (
<Sidebar>
<section <section
id={this.props.slug} id={this.props.slug}
className="data-section"> className="data-section">
@ -207,51 +204,54 @@ const withCopyEdit = (WrappedComponent) => {
{...this.props} {...this.props}
data_string={data_string} data_string={data_string}
copy={copy} copy={copy}
/> />
<form {
action={`/edit/${this.props.slug}/building/${this.props.building.building_id}.html`} this.props.building != undefined ?
method="POST" <form
onSubmit={this.handleSubmit}> action={`/edit/${this.props.slug}/${this.props.building.building_id}`}
<ErrorBox msg={this.state.error} /> method="POST"
{ onSubmit={this.handleSubmit}>
(this.props.mode === 'edit' && this.props.inactive)? <ErrorBox msg={this.state.error} />
<InfoBox {
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`} (this.props.mode === 'edit' && this.props.inactive) ?
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/> />
: null : null
} }
<WrappedComponent <WrappedComponent
building={this.state.building} building={this.state.building}
mode={this.props.mode} mode={this.props.mode}
copy={copy} copy={copy}
onChange={this.handleChange} onChange={this.handleChange}
onCheck={this.handleCheck} onCheck={this.handleCheck}
onLike={this.handleLike} onLike={this.handleLike}
onUpdate={this.handleUpdate} onUpdate={this.handleUpdate}
/> />
{ {
(this.props.mode === 'edit' && !this.props.inactive)? (this.props.mode === 'edit' && !this.props.inactive) ?
<Fragment> <Fragment>
<InfoBox <InfoBox
msg="Colouring may take a few seconds - try zooming the map or hitting refresh after saving (we're working on making this smoother)." /> msg="Colouring may take a few seconds - try zooming the map or hitting refresh after saving (we're working on making this smoother)." />
{ {
this.props.slug === 'like'? // special-case for likes this.props.slug === 'like' ? // special-case for likes
null : null :
<div className="buttons-container"> <div className="buttons-container">
<button <button
type="submit" type="submit"
className="btn btn-primary"> className="btn btn-primary">
Save Save
</button> </button>
</div> </div>
} }
</Fragment> </Fragment>
: null : null
} }
</form> </form>
: <InfoBox msg="Select a building to view data"></InfoBox>
}
</section> </section>
</Sidebar> );
: <BuildingNotFound mode="view" />
} }
} }
} }

View File

@ -25,8 +25,8 @@ const MultiEdit = (props) => {
<form className='buttons-container'> <form className='buttons-container'>
<InfoBox msg='Click all the buildings that you like and think contribute to the city!' /> <InfoBox msg='Click all the buildings that you like and think contribute to the city!' />
<Link to='/view/like.html' className='btn btn-secondary'>Back to view</Link> <Link to='/view/like' className='btn btn-secondary'>Back to view</Link>
<Link to='/edit/like.html' className='btn btn-secondary'>Back to edit</Link> <Link to='/edit/like' className='btn btn-secondary'>Back to edit</Link>
</form> </form>
</section> </section>
</Sidebar> </Sidebar>
@ -53,8 +53,8 @@ const MultiEdit = (props) => {
<form className='buttons-container'> <form className='buttons-container'>
<InfoBox msg='Click buildings to colour using the data above' /> <InfoBox msg='Click buildings to colour using the data above' />
<Link to={`/view/${cat}.html`} className='btn btn-secondary'>Back to view</Link> <Link to={`/view/${cat}`} className='btn btn-secondary'>Back to view</Link>
<Link to={`/edit/${cat}.html`} className='btn btn-secondary'>Back to edit</Link> <Link to={`/edit/${cat}`} className='btn btn-secondary'>Back to edit</Link>
</form> </form>
</section> </section>
</Sidebar> </Sidebar>

View File

@ -2,51 +2,25 @@
* Sidebar layout * Sidebar layout
*/ */
.info-container { .info-container {
position: absolute; order: 1;
top: 50%;
left: 0;
right: 0;
bottom: 3rem;
padding: 0 0 2em; padding: 0 0 2em;
background: #fff; background: #fff;
z-index: 1000; overflow-y: scroll;
overflow-y: auto; height: 40%;
} }
.info-container h2:first-child { .info-container h2:first-child {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
margin-top: 0.5rem; margin-top: 0.5rem;
margin-left: -0.1em; margin-left: -0.1em;
padding: 0 0.75rem; padding: 0 0.75rem;
} }
#root .leaflet-container .leaflet-control-attribution {
width: 100%;
height: 3rem;
background: #fff;
background: rgba(255, 255, 255, 0.7);
}
.leaflet-right{
left: 0;
}
@media (min-width: 380px){
.info-container {
bottom: 2rem;
}
#root .leaflet-container .leaflet-control-attribution {
height: 2rem;
}
}
@media (min-width: 768px){ @media (min-width: 768px){
.info-container { .info-container {
top: 0; order: 0;
left: 0; height: unset;
width: 25rem; width: 23rem;
bottom: 0;
}
.leaflet-right{
left: 25rem;
}
#root .leaflet-container .leaflet-control-attribution {
height: auto;
} }
} }

View File

@ -5,7 +5,7 @@ import React from 'react'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble, import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble,
faAngleLeft, faCaretDown, faSearch, faEye } from '@fortawesome/free-solid-svg-icons' faAngleLeft, faCaretDown, faSearch, faEye, faCaretUp } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
faQuestionCircle, faQuestionCircle,
@ -16,6 +16,7 @@ library.add(
faCheckDouble, faCheckDouble,
faAngleLeft, faAngleLeft,
faCaretDown, faCaretDown,
faCaretUp,
faSearch, faSearch,
faEye faEye
); );
@ -56,6 +57,10 @@ const DownIcon = () => (
<FontAwesomeIcon icon="caret-down" /> <FontAwesomeIcon icon="caret-down" />
); );
const UpIcon = () => (
<FontAwesomeIcon icon="caret-up" />
);
const SearchIcon = () => ( const SearchIcon = () => (
<FontAwesomeIcon icon="search" /> <FontAwesomeIcon icon="search" />
); );
@ -70,5 +75,6 @@ export {
SaveDoneIcon, SaveDoneIcon,
BackIcon, BackIcon,
DownIcon, DownIcon,
UpIcon,
SearchIcon SearchIcon
}; };

View File

@ -5,8 +5,7 @@
font-family: 'glacial_cl', sans-serif; font-family: 'glacial_cl', sans-serif;
text-transform: uppercase; text-transform: uppercase;
} }
.logo, .logo {
.logo.navbar-brand {
display: block; display: block;
padding: 0; padding: 0;
color: #000; color: #000;
@ -27,19 +26,7 @@
font-size: 0.625em; font-size: 0.625em;
letter-spacing: 0; letter-spacing: 0;
} }
.map-legend .logo {
padding: 0 0.5rem 0.5rem;
}
.map-legend .logo .logotype {
font-size: 1.9rem;
margin-bottom: -2px;
}
.map-legend .logo .row .cell {
background-color: #ccc;
height: 12px;
width: 12px;
animation: none !important;
}
.logo .grid { .logo .grid {
position: relative; position: relative;
top: -1px; top: -1px;
@ -60,51 +47,56 @@
height: 14px; height: 14px;
margin: 0 3px 0 0; margin: 0 3px 0 0;
} }
.logo .row:nth-child(1) .cell:nth-child(1) {
.logo.gray .cell {
background-color: #ccc;
}
.logo.animated .row:nth-child(1) .cell:nth-child(1) {
animation: pulse 87s infinite; animation: pulse 87s infinite;
animation-delay: -1.5s; animation-delay: -1.5s;
} }
.logo .row:nth-child(1) .cell:nth-child(2) { .logo.animated .row:nth-child(1) .cell:nth-child(2) {
animation: pulse 52s infinite; animation: pulse 52s infinite;
animation-delay: -0.5s; animation-delay: -0.5s;
} }
.logo .row:nth-child(1) .cell:nth-child(3) { .logo.animated .row:nth-child(1) .cell:nth-child(3) {
animation: pulse 79s infinite; animation: pulse 79s infinite;
animation-delay: -6s; animation-delay: -6s;
} }
.logo .row:nth-child(1) .cell:nth-child(4) { .logo.animated .row:nth-child(1) .cell:nth-child(4) {
animation: pulse 55s infinite; animation: pulse 55s infinite;
animation-delay: -10s; animation-delay: -10s;
} }
.logo .row:nth-child(2) .cell:nth-child(1) { .logo.animated .row:nth-child(2) .cell:nth-child(1) {
animation: pulse 64s infinite; animation: pulse 64s infinite;
animation-delay: -7.2s; animation-delay: -7.2s;
} }
.logo .row:nth-child(2) .cell:nth-child(2) { .logo.animated .row:nth-child(2) .cell:nth-child(2) {
animation: pulse 98s infinite; animation: pulse 98s infinite;
animation-delay: -25s; animation-delay: -25s;
} }
.logo .row:nth-child(2) .cell:nth-child(3) { .logo.animated .row:nth-child(2) .cell:nth-child(3) {
animation: pulse 51s infinite; animation: pulse 51s infinite;
animation-delay: -35s; animation-delay: -35s;
} }
.logo .row:nth-child(2) .cell:nth-child(4) { .logo.animated .row:nth-child(2) .cell:nth-child(4) {
animation: pulse 76s infinite; animation: pulse 76s infinite;
animation-delay: -20s; animation-delay: -20s;
} }
.logo .row:nth-child(3) .cell:nth-child(1) { .logo.animated .row:nth-child(3) .cell:nth-child(1) {
animation: pulse 52s infinite; animation: pulse 52s infinite;
animation-delay: -3.5s; animation-delay: -3.5s;
} }
.logo .row:nth-child(3) .cell:nth-child(2) { .logo.animated .row:nth-child(3) .cell:nth-child(2) {
animation: pulse 79s infinite; animation: pulse 79s infinite;
animation-delay: -8.5s; animation-delay: -8.5s;
} }
.logo .row:nth-child(3) .cell:nth-child(3) { .logo.animated .row:nth-child(3) .cell:nth-child(3) {
animation: pulse 65s infinite; animation: pulse 65s infinite;
animation-delay: -4s; animation-delay: -4s;
} }
.logo .row:nth-child(3) .cell:nth-child(4) { .logo.animated .row:nth-child(3) .cell:nth-child(4) {
animation: pulse 54s infinite; animation: pulse 54s infinite;
animation-delay: -17s; animation-delay: -17s;
} }

View File

@ -1,36 +1,27 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import './logo.css'; import './logo.css';
interface LogoProps {
variant: 'default' | 'animated' | 'gray';
}
/** /**
* Logo * Logo
* *
* As link to homepage, used in top header * As link to homepage, used in top header
*/ */
const Logo: React.FunctionComponent = () => ( const Logo: React.FunctionComponent<LogoProps> = (props) => {
<Link to="/" className="logo navbar-brand" id="top"> const variantClass = props.variant === 'default' ? '' : props.variant;
<LogoGrid /> return (
<h1 className="logotype"> <div className={`logo ${variantClass}`} >
<span>Colouring</span> <LogoGrid />
<span>London</span> <h1 className="logotype">
</h1> <span>Colouring</span>
</Link> <span>London</span>
); </h1>
</div>
/** );
* MinorLogo };
*
* As plain logo, used in legend
*/
const MinorLogo: React.FunctionComponent = () => (
<div className="logo">
<LogoGrid />
<h3 className="h3 logotype">
<span>Colouring</span>
<span>London</span>
</h3>
</div>
)
const LogoGrid: React.FunctionComponent = () => ( const LogoGrid: React.FunctionComponent = () => (
<div className="grid"> <div className="grid">
@ -55,4 +46,4 @@ const LogoGrid: React.FunctionComponent = () => (
</div> </div>
) )
export { Logo, MinorLogo }; export { Logo };

View File

@ -2,14 +2,27 @@
* Main header * Main header
*/ */
.main-header { .main-header {
display: block;
min-height: 79px;
text-decoration: none; text-decoration: none;
border-bottom: 3px solid #222; border-bottom: 2px solid #222;
} }
.main-header .navbar { .main-header .navbar {
padding: 0.75em 0.5em 0.75em; padding: 0.5em 0.5em 0.5em;
} }
.main-header .navbar-brand { .main-header .navbar-brand {
margin: 0 1em 0 0; margin: 0 1em 0 0;
} }
.main-header .shorten-username {
text-overflow: '…)';
white-space: nowrap;
overflow: hidden;
display: inline-block;
vertical-align: bottom;
max-width: 70vw;
}
@media (min-width: 768px) {
.main-header .shorten-username {
max-width: 5vw;
}
}

View File

@ -19,6 +19,7 @@ class Header extends React.Component<any, any> { // TODO: add proper types
super(props); super(props);
this.state = {collapseMenu: true}; this.state = {collapseMenu: true};
this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this);
this.handleNavigate = this.handleNavigate.bind(this);
} }
handleClick() { handleClick() {
@ -27,22 +28,35 @@ class Header extends React.Component<any, any> { // TODO: add proper types
})); }));
} }
handleNavigate() {
this.setState({
collapseMenu: true
});
}
render() { render() {
return ( return (
<header className="main-header"> <header className="main-header">
<nav className="navbar navbar-light navbar-expand-md"> <nav className="navbar navbar-light navbar-expand-lg">
<span className="navbar-brand"> <span className="navbar-brand align-self-start">
<Logo/> <NavLink to="/">
<Logo variant='animated'/>
</NavLink>
</span> </span>
<button className="navbar-toggler navbar-toggler-right" type="button" <button className="navbar-toggler navbar-toggler-right" type="button"
onClick={this.handleClick} aria-expanded="false" aria-label="Toggle navigation"> onClick={this.handleClick} aria-expanded={!this.state.collapseMenu} aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span> <span className="navbar-toggler-icon"></span>
</button> </button>
<div className={this.state.collapseMenu ? 'collapse navbar-collapse' : 'navbar-collapse'}> <div className={this.state.collapseMenu ? 'collapse navbar-collapse' : 'navbar-collapse'}>
<ul className="navbar-nav ml-auto"> <ul className="navbar-nav ml-auto">
<li className="nav-item">
<NavLink to="/view/categories" className="nav-link" onClick={this.handleNavigate}>
View/Edit Maps
</NavLink>
</li>
<li className="nav-item"> <li className="nav-item">
<a className="nav-link" href="https://pages.colouring.london"> <a className="nav-link" href="https://pages.colouring.london">
Hello About
</a> </a>
</li> </li>
<li className="nav-item"> <li className="nav-item">
@ -50,23 +64,13 @@ class Header extends React.Component<any, any> { // TODO: add proper types
Data Categories Data Categories
</a> </a>
</li> </li>
<li className="nav-item">
<NavLink to="/view/categories.html" className="nav-link">
View/Edit Maps
</NavLink>
</li>
<li className="nav-item">
<a className="nav-link" href="https://pages.colouring.london/about">
More about
</a>
</li>
<li className="nav-item"> <li className="nav-item">
<a className="nav-link" href="https://pages.colouring.london/whoisinvolved"> <a className="nav-link" href="https://pages.colouring.london/whoisinvolved">
Who&rsquo;s Involved? Who&rsquo;s Involved?
</a> </a>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<a className="nav-link" href="https://www.pages.colouring.london/data-ethics"> <a className="nav-link" href="https://pages.colouring.london/data-ethics">
Data Ethics Data Ethics
</a> </a>
</li> </li>
@ -79,20 +83,20 @@ class Header extends React.Component<any, any> { // TODO: add proper types
this.props.user? this.props.user?
( (
<li className="nav-item"> <li className="nav-item">
<NavLink to="/my-account.html" className="nav-link"> <NavLink to="/my-account.html" className="nav-link" onClick={this.handleNavigate}>
My account (Logged in as {this.props.user.username}) Account <span className="shorten-username">({this.props.user.username})</span>
</NavLink> </NavLink>
</li> </li>
): ):
( (
<Fragment> <Fragment>
<li className="nav-item"> <li className="nav-item">
<NavLink to="/login.html" className="nav-link"> <NavLink to="/login.html" className="nav-link" onClick={this.handleNavigate}>
Log in Log in
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<NavLink to="/sign-up.html" className="nav-link"> <NavLink to="/sign-up.html" className="nav-link" onClick={this.handleNavigate}>
Sign up Sign up
</NavLink> </NavLink>
</li> </li>

View File

@ -0,0 +1,250 @@
import React, { Fragment } from 'react';
import { Switch, Route, RouteComponentProps, Redirect } from 'react-router-dom';
import PropTypes from 'prop-types';
import Welcome from './pages/welcome';
import Sidebar from './building/sidebar';
import Categories from './building/categories';
import MultiEdit from './building/multi-edit';
import BuildingView from './building/building-view';
import ColouringMap from './map/map';
import { parse } from 'query-string';
interface MapAppRouteParams {
mode: 'view' | 'edit' | 'multi-edit';
category: string;
building?: string;
}
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building: any;
building_like: boolean;
user: any;
}
interface MapAppState {
category: string;
revision_id: number;
building: any;
building_like: boolean;
}
class MapApp extends React.Component<MapAppProps, MapAppState> {
static propTypes = {
category: PropTypes.string,
revision_id: PropTypes.number,
building: PropTypes.object,
building_like: PropTypes.bool,
user: PropTypes.object
};
constructor(props: Readonly<MapAppProps>) {
super(props);
// set building revision id, default 0
const rev = props.building != undefined ? +props.building.revision_id : 0;
this.state = {
category: this.getCategory(props.match.params.category),
revision_id: rev,
building: props.building,
building_like: props.building_like
};
this.selectBuilding = this.selectBuilding.bind(this);
this.colourBuilding = this.colourBuilding.bind(this);
this.increaseRevision = this.increaseRevision.bind(this);
}
componentWillReceiveProps(props: Readonly<MapAppProps>) {
const newCategory = this.getCategory(props.match.params.category);
if (newCategory != undefined) {
this.setState({ category: newCategory });
}
}
getCategory(category: string) {
if (category === 'categories') return undefined;
return category;
}
increaseRevision(revisionId) {
revisionId = +revisionId;
// bump revision id, only ever increasing
if (revisionId > this.state.revision_id) {
this.setState({ revision_id: revisionId })
}
}
selectBuilding(building) {
const mode = this.props.match.params.mode || 'view';
const category = this.props.match.params.category || 'age';
if (building == undefined ||
(this.state.building != undefined &&
building.building_id === this.state.building.building_id)) {
this.setState({ building: undefined });
this.props.history.push(`/${mode}/${category}`);
return;
}
this.increaseRevision(building.revision_id);
// get UPRNs and update
fetch(`/api/buildings/${building.building_id}/uprns.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
if (res.error) {
console.error(res);
} else {
building.uprns = res.uprns;
this.setState({ building: building });
}
}).catch((err) => {
console.error(err)
this.setState({ building: building });
});
// get if liked and update
fetch(`/api/buildings/${building.building_id}/like.json`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
if (res.error) {
console.error(res);
} else {
this.setState({ building_like: res.like });
this.props.history.push(`/${mode}/${category}/${building.building_id}`);
}
}).catch((err) => {
console.error(err)
this.setState({ building_like: false });
});
}
/**
* Colour building
*
* Used in multi-edit mode to colour buildings on map click
*
* Pulls data from URL to form update
*
* @param {object} building
*/
colourBuilding(building) {
const cat = this.props.match.params.category;
const q = parse(window.location.search);
const data = (cat === 'like') ? { like: true } : JSON.parse(q.data as string); // TODO: verify what happens if data is string[]
if (cat === 'like') {
this.likeBuilding(building.building_id)
} else {
this.updateBuilding(building.building_id, data)
}
}
likeBuilding(buildingId) {
fetch(`/api/buildings/${buildingId}/like.json`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({ like: true })
}).then(
res => res.json()
).then(function (res) {
if (res.error) {
console.error({ error: res.error })
} else {
this.increaseRevision(res.revision_id);
}
}.bind(this)).catch(
(err) => console.error({ error: err })
);
}
updateBuilding(buildingId, data) {
fetch(`/api/buildings/${buildingId}.json`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(res => {
if (res.error) {
console.error({ error: res.error })
} else {
this.increaseRevision(res.revision_id);
}
}).catch(
(err) => console.error({ error: err })
);
}
render() {
const mode = this.props.match.params.mode || 'basic';
let category = this.state.category || 'age';
const building_id = this.state.building && this.state.building.building_id;
return (
<Fragment>
<Switch>
<Route exact path="/">
<Welcome />
</Route>
<Route exact path="/:mode/categories/:building?">
<Sidebar>
<Categories mode={mode} building_id={building_id} />
</Sidebar>
</Route>
<Route exact path="/multi-edit/:cat" render={(props) => (
<MultiEdit
{...props}
user={this.props.user}
/>
)} />
<Route exact path="/:mode/:cat/:building?">
<Sidebar>
<BuildingView
mode={mode}
cat={category}
building={this.state.building}
building_like={this.state.building_like}
selectBuilding={this.selectBuilding}
user={this.props.user}
/>
</Sidebar>
</Route>
<Route exact path="/(view|edit|multi-edit)">
<Redirect to="/view/categories" />
</Route>
</Switch>
<ColouringMap
building={this.state.building}
mode={mode}
category={category}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}
colourBuilding={this.colourBuilding}
/>
</Fragment>
);
}
}
export default MapApp;

View File

@ -2,28 +2,53 @@
* Map legend * Map legend
*/ */
.map-legend { .map-legend {
z-index: 1000;
position: absolute; position: absolute;
bottom: 50%; bottom: 2.5rem;
right: 10px; right: 10px;
z-index: 1000;
min-width: 12rem; min-width: 12rem;
float: right; max-height: 50%;
display: flex;
flex-direction: column;
background: white; background: white;
border-radius: 4px; border-radius: 4px;
padding: 0.5rem 0rem 0.25rem; padding: 0.5rem 0rem 0.25rem;
border: 1px solid #fff; border: 1px solid #fff;
box-shadow: 0px 0px 1px 1px #222222; box-shadow: 0px 0px 1px 1px #222222;
} }
@media (min-width: 768px){
.map-legend { .map-legend * {
bottom: 40px; flex: 0;
}
.map-legend .logo {
display: none;
}
@media (min-width: 768px) {
.map-legend .logo {
display: block;
} }
} }
@media (min-width: 1020px){
/* Prevent legend from overlapping with attribution */
@media (min-width: 706px){
.map-legend { .map-legend {
bottom: 24px; bottom: 1.5rem;
} }
} }
@media (min-width: 768px) {
.map-legend {
bottom: 2.5rem;
}
}
@media (min-width: 1072px){
.map-legend {
bottom: 1.5rem;
}
}
.map-legend .h4, .map-legend .h4,
.map-legend p, .map-legend p,
.data-legend { .data-legend {
@ -33,30 +58,12 @@
margin: 0.25rem 0 0.5rem; margin: 0.25rem 0 0.5rem;
} }
.data-legend { .data-legend {
max-height: 80px; overflow: auto;
max-height: 20vh;
overflow-y: auto;
list-style: none; list-style: none;
margin-bottom: 0; margin-bottom: 0;
flex: 1;
} }
@media (min-height: 470px) {
.data-legend {
max-height: 150px;
max-height: 30vh;
}
}
@media (min-height: 550px) {
.data-legend {
max-height: 220px;
max-height: 40vh;
}
}
@media (min-height: 670px) {
.data-legend {
max-height: 330px;
max-height: 50vh;
}
}
.data-legend .key { .data-legend .key {
display: inline-block; display: inline-block;
width: 1.3rem; width: 1.3rem;
@ -71,10 +78,35 @@
opacity: 0.5; opacity: 0.5;
} }
.expander-button { .expander-button {
float: right; display: inline-block;
position: absolute;
bottom: 1rem;
right: 0.5rem;
height: 1rem;
width: 1rem;
line-height: 0.5;
padding: 0;
} }
@media (min-height: 670px) and (min-width: 768px) { .expander-button:focus,
.expander-button:active,
.expander-button:hover {
box-shadow: none;
}
@media (min-height: 670px) and (min-width: 880px) {
.expander-button { .expander-button {
visibility: hidden; visibility: hidden;
} }
} }
.map-legend .logo {
padding: 0 0.5rem 0.5rem;
}
.map-legend .logo .logotype {
font-size: 1.9rem;
margin-bottom: -2px;
}
.map-legend .logo .cell {
height: 12px;
width: 12px;
}

View File

@ -2,7 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './legend.css'; import './legend.css';
import { MinorLogo } from '../components/logo'; import { Logo } from '../components/logo';
import { DownIcon, UpIcon, BackIcon } from '../components/icons';
const LEGEND_CONFIG = { const LEGEND_CONFIG = {
location: { location: {
@ -146,8 +147,21 @@ class Legend extends React.Component<any, any> { // TODO: add proper types
return ( return (
<div className="map-legend"> <div className="map-legend">
<MinorLogo /> <Logo variant='gray' />
<h4 className="h4">{ title } {elements.length?<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >^</button>:null}</h4> <h4 className="h4">
{ title }
</h4>
{
elements.length > 0 ?
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >
{
this.state.collapseList ?
<UpIcon /> :
<DownIcon />
}
</button> :
null
}
{ {
details.description? details.description?
<p>{details.description} </p> <p>{details.description} </p>

View File

@ -8,25 +8,34 @@
box-shadow: 0px 0px 1px 1px #222; box-shadow: 0px 0px 1px 1px #222;
visibility: hidden; visibility: hidden;
} }
.map-container {
flex: 1;
position: relative;
}
.leaflet-container { .leaflet-container {
position: absolute; height: 100%;
top: 0; width: 100%;
bottom: 0;
left: 0;
right: 0;
} }
.leaflet-container .leaflet-control-zoom { .leaflet-container .leaflet-control-zoom {
border: 1px solid #fff; border: 1px solid #fff;
box-shadow: 0 0 1px 1px #222; box-shadow: 0 0 1px 1px #222;
} }
.leaflet-container .leaflet-control-attribution {
width: 100%;
background: #fff;
background: rgba(255, 255, 255, 0.7);
}
.leaflet-grab { .leaflet-grab {
cursor: crosshair; cursor: crosshair;
} }
@media (min-width: 768px){ @media (min-width: 768px){
/* Only show the "Click a building ..." notice for larger screens */ /* Only show the "Click a building ..." notice for larger screens */
.map-notice { .map-notice {
left: 25.5rem; left: 0.5rem;
top: 0.5rem; top: 3.5rem;
visibility: visible; visibility: visible;
} }
} }

View File

@ -1,5 +1,6 @@
import React, { Component, Fragment } from 'react'; import { LatLngExpression } from 'leaflet';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal'; import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
import '../../../node_modules/leaflet/dist/leaflet.css' import '../../../node_modules/leaflet/dist/leaflet.css'
@ -13,17 +14,32 @@ import ThemeSwitcher from './theme-switcher';
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9'; const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
interface ColouringMapProps {
building: any;
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
category: string;
revision_id: number;
selectBuilding: any;
colourBuilding: any;
}
interface ColouringMapState {
theme: 'light' | 'night';
lat: number;
lng: number;
zoom: number;
}
/** /**
* Map area * Map area
*/ */
class ColouringMap extends Component<any, any> { // TODO: add proper types class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS static propTypes = { // TODO: generate propTypes from TS
building: PropTypes.object, building: PropTypes.object,
mode: PropTypes.string,
category: PropTypes.string,
revision_id: PropTypes.number, revision_id: PropTypes.number,
selectBuilding: PropTypes.func, selectBuilding: PropTypes.func,
colourBuilding: PropTypes.func, colourBuilding: PropTypes.func
match: PropTypes.object,
history: PropTypes.object
}; };
constructor(props) { constructor(props) {
@ -37,7 +53,6 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this);
this.handleLocate = this.handleLocate.bind(this); this.handleLocate = this.handleLocate.bind(this);
this.themeSwitch = this.themeSwitch.bind(this); this.themeSwitch = this.themeSwitch.bind(this);
this.getMode = this.getMode.bind(this);
} }
handleLocate(lat, lng, zoom){ handleLocate(lat, lng, zoom){
@ -45,21 +60,13 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
lat: lat, lat: lat,
lng: lng, lng: lng,
zoom: zoom zoom: zoom
}) });
}
getMode() {
const isEdit = this.props.match.url.match('edit')
const isMulti = this.props.match.url.match('multi')
return isEdit? (isMulti? 'multi' : 'edit') : 'view';
} }
handleClick(e) { handleClick(e) {
const mode = this.getMode() const mode = this.props.mode;
const lat = e.latlng.lat const lat = e.latlng.lat;
const lng = e.latlng.lng const lng = e.latlng.lng;
const newCat = parseCategoryURL(this.props.match.url);
const mapCat = newCat || 'age';
fetch( fetch(
'/api/buildings/locate?lat='+lat+'&lng='+lng '/api/buildings/locate?lat='+lat+'&lng='+lng
).then( ).then(
@ -67,17 +74,15 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
).then(function(data){ ).then(function(data){
if (data && data.length){ if (data && data.length){
const building = data[0]; const building = data[0];
if (mode === 'multi') { if (mode === 'multi-edit') {
// colour building directly // colour building directly
this.props.colourBuilding(building); this.props.colourBuilding(building);
} else { } else {
this.props.selectBuilding(building); this.props.selectBuilding(building);
this.props.history.push(`/${mode}/${mapCat}/building/${building.building_id}.html`);
} }
} else { } else {
// deselect but keep/return to expected colour theme // deselect but keep/return to expected colour theme
this.props.selectBuilding(undefined); this.props.selectBuilding(undefined);
this.props.history.push(`/${mode}/${mapCat}.html`);
} }
}.bind(this)).catch( }.bind(this)).catch(
(err) => console.error(err) (err) => console.error(err)
@ -91,52 +96,56 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
} }
render() { render() {
const position = [this.state.lat, this.state.lng]; const position: LatLngExpression = [this.state.lat, this.state.lng];
// baselayer // baselayer
const key = OS_API_KEY const key = OS_API_KEY;
const tilematrixSet = 'EPSG:3857' const tilematrixSet = 'EPSG:3857';
const layer = (this.state.theme === 'light')? 'Light 3857' : 'Night 3857'; const layer = (this.state.theme === 'light')? 'Light 3857' : 'Night 3857';
const url = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}` const baseUrl = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}`;
const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.' const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.';
const baseLayer = <TileLayer
url={baseUrl}
attribution={attribution}
/>;
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}.png`;
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
// colour-data tiles // colour-data tiles
const isBuilding = /building/.test(this.props.match.url); const cat = this.props.category;
const isEdit = /edit/.test(this.props.match.url);
const cat = parseCategoryURL(this.props.match.url);
const tilesetByCat = { const tilesetByCat = {
age: 'date_year', age: 'date_year',
size: 'size_storeys', size: 'size_storeys',
location: 'location', location: 'location',
like: 'likes', like: 'likes',
planning: 'conservation_area', planning: 'conservation_area',
} };
const tileset = tilesetByCat[cat]; const tileset = tilesetByCat[cat];
// pick revision id to bust browser cache // pick revision id to bust browser cache
const rev = this.props.revision_id; const rev = this.props.revision_id;
const dataLayer = tileset? const dataLayer = tileset != undefined ?
<TileLayer <TileLayer
key={tileset} key={tileset}
url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`} url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`}
minZoom={9} /> minZoom={9}
/>
: null; : null;
// highlight // highlight
const geometryId = (this.props.building) ? this.props.building.geometry_id : undefined; const highlightLayer = this.props.building != undefined ?
const highlight = `/tiles/highlight/{z}/{x}/{y}.png?highlight=${geometryId}`
const highlightLayer = (isBuilding && this.props.building) ?
<TileLayer <TileLayer
key={this.props.building.building_id} key={this.props.building.building_id}
url={highlight} url={`/tiles/highlight/{z}/{x}/{y}.png?highlight=${this.props.building.geometry_id}`}
minZoom={14} /> minZoom={14}
zIndex={100}
/>
: null; : null;
const baseUrl = (this.state.theme === 'light')? const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
'/tiles/base_light/{z}/{x}/{y}.png'
: '/tiles/base_night/{z}/{x}/{y}.png'
return ( return (
<Fragment> <div className="map-container">
<Map <Map
center={position} center={position}
zoom={this.state.zoom} zoom={this.state.zoom}
@ -147,30 +156,30 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
attributionControl={false} attributionControl={false}
onClick={this.handleClick} onClick={this.handleClick}
> >
<TileLayer url={url} attribution={attribution} /> { baseLayer }
<TileLayer url={baseUrl} minZoom={14} /> { buildingBaseLayer }
{ dataLayer } { dataLayer }
{ highlightLayer } { highlightLayer }
<ZoomControl position="topright" /> <ZoomControl position="topright" />
<AttributionControl prefix="" /> <AttributionControl prefix=""/>
</Map> </Map>
{ {
!isBuilding && this.props.match.url !== '/'? ( this.props.mode !== 'basic'? (
<div className="map-notice">
<HelpIcon /> {isEdit? 'Click a building to edit' : 'Click a building for details'}
</div>
) : null
}
{
this.props.match.url !== '/'? (
<Fragment> <Fragment>
{
this.props.building == undefined ?
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
: null
}
<Legend slug={cat} /> <Legend slug={cat} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} /> <ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} isBuilding={isBuilding} /> <SearchBox onLocate={this.handleLocate} />
</Fragment> </Fragment>
) : null ) : null
} }
</Fragment> </div>
); );
} }
} }

View File

@ -0,0 +1,9 @@
/**
* Export all type declarations available for react-leaflet as types for react-leaflet-universal.
* This is because the latter doesn't have type declarations published as of 2019-09-09
* but we can re-use types from react-leaflet as universal is mostly a wrapper, so the types
* still apply.
*/
declare module 'react-leaflet-universal' {
export * from 'react-leaflet';
}

View File

@ -5,9 +5,6 @@
z-index: 1000; z-index: 1000;
max-width:80%; max-width:80%;
} }
.building.search-box {
top: 0.5rem;
}
.search-box form, .search-box form,
.search-box .search-box-results { .search-box .search-box-results {
border-radius: 4px; border-radius: 4px;
@ -46,11 +43,6 @@
margin-right: 0.1rem; margin-right: 0.1rem;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
/* for large screens adopt the conventional search box position */
.search-box{
top: 3.625rem;
left: 25.5rem;
}
/* The following is a fix (?) for the truncation of the "Search for postcode" text */ /* The following is a fix (?) for the truncation of the "Search for postcode" text */
.form-inline .form-control { .form-inline .form-control {
width: 200px; width: 200px;

View File

@ -8,8 +8,7 @@ import { SearchIcon } from '../components/icons';
*/ */
class SearchBox extends Component<any, any> { // TODO: add proper types class SearchBox extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS static propTypes = { // TODO: generate propTypes from TS
onLocate: PropTypes.func, onLocate: PropTypes.func
isBuilding: PropTypes.bool
}; };
constructor(props) { constructor(props) {
@ -159,7 +158,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
</ul> </ul>
: null; : null;
return ( return (
<div className={`search-box ${this.props.isBuilding? 'building' : ''}`} onKeyDown={this.handleKeyPress}> <div className="search-box" onKeyDown={this.handleKeyPress}>
<form onSubmit={this.search} className="form-inline"> <form onSubmit={this.search} className="form-inline">
<div onClick={this.state.smallScreen ? this.expandSearch : null}> <div onClick={this.state.smallScreen ? this.expandSearch : null}>
<SearchIcon/> <SearchIcon/>

View File

@ -6,8 +6,10 @@
z-index: 10000; z-index: 10000;
top: 0; top: 0;
width: 100%; width: 100%;
max-height: 100%;
border-radius: 0; border-radius: 0;
padding: 1.5em 2.5em 2.5em; padding: 1.5em 2.5em 2.5em;
overflow-y: scroll;
} }
.welcome-float.jumbotron { .welcome-float.jumbotron {
background: #fff; background: #fff;

View File

@ -17,7 +17,7 @@ const Welcome = () => (
volunteers of all ages and abilities to test and provide feedback on the site as we volunteers of all ages and abilities to test and provide feedback on the site as we
build it. build it.
</p> </p>
<Link to="/view/categories.html" <Link to="/view/categories"
className="btn btn-outline-dark btn-lg btn-block"> className="btn btn-outline-dark btn-lg btn-block">
Start Colouring Here! Start Colouring Here!
</Link> </Link>

View File

@ -1,20 +1,34 @@
/** /**
* Main Layout * Main Layout
*/ */
main {
html, body, #root {
height: 100%;
}
body {
margin: 0;
}
#root {
display: flex;
flex-direction: column;
overflow: hidden;
}
main {
position: relative; position: relative;
min-height: 35rem; flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
} }
@media (min-width: 768px){
@media(min-width: 768px) {
main { main {
position: absolute; flex-direction: row;
top: 79px; /* matches 79px .main-header */
bottom: 0;
left: 0;
right: 0;
min-height: auto;
} }
} }
/** /**
* Text pages * Text pages
*/ */

View File

@ -116,7 +116,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
<ErrorBox msg={this.state.error} /> <ErrorBox msg={this.state.error} />
<form onSubmit={this.handleLogout}> <form onSubmit={this.handleLogout}>
<div className="buttons-container"> <div className="buttons-container">
<Link to="/edit/age.html" className="btn btn-warning">Start colouring</Link> <Link to="/edit/age" className="btn btn-warning">Start colouring</Link>
<input className="btn btn-secondary" type="submit" value="Log out"/> <input className="btn btn-secondary" type="submit" value="Log out"/>
</div> </div>
</form> </form>

110
app/src/frontendRoute.tsx Normal file
View File

@ -0,0 +1,110 @@
import express from 'express';
import React from 'react';
import { StaticRouter } from 'react-router-dom';
import { renderToString } from 'react-dom/server';
import serialize from 'serialize-javascript';
import App from './frontend/app';
import { parseBuildingURL } from './parse';
import { getUserById } from './api/services/user';
import {
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById
} from './api/services/building';
// reference packed assets
const assets = require(process.env.RAZZLE_ASSETS_MANIFEST);
function frontendRoute(req: express.Request, res: express.Response) {
const context: any = {}; // TODO: remove any
const data: any = {}; // TODO: remove any
context.status = 200;
const userId = req.session.user_id;
const buildingId = parseBuildingURL(req.url);
const isBuilding = (typeof (buildingId) !== 'undefined');
if (isBuilding && isNaN(buildingId)) {
context.status = 404;
}
Promise.all([
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false
]).then(function ([user, building, uprns, buildingLike]) {
if (isBuilding && typeof (building) === 'undefined') {
context.status = 404;
}
data.user = user;
data.building = building;
data.building_like = buildingLike;
if (data.building != null) {
data.building.uprns = uprns;
}
renderHTML(context, data, req, res);
}).catch(error => {
console.error(error);
data.user = undefined;
data.building = undefined;
data.building_like = undefined;
context.status = 500;
renderHTML(context, data, req, res);
});
}
function renderHTML(context, data, req, res) {
const markup = renderToString(
<StaticRouter context={context} location={req.url}>
<App user={data.user} building={data.building} building_like={data.building_like} />
</StaticRouter>
);
if (context.url) {
res.redirect(context.url);
} else {
res.status(context.status).send(
`<!doctype html>
<html lang="">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta charset="utf-8" />
<title>Colouring London</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
@font-face {
font-family: 'glacial_cl';
src: url('/fonts/glacialindifference-regular-webfont.woff2') format('woff2'),
url('/fonts/glacialindifference-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
</style>
${
assets.client.css
? `<link rel="stylesheet" href="${assets.client.css}">`
: ''
}
${
process.env.NODE_ENV === 'production'
? `<script src="${assets.client.js}" defer></script>`
: `<script src="${assets.client.js}" defer crossorigin></script>`
}
</head>
<body>
<div id="root">${markup}</div>
<script>
window.__PRELOADED_STATE__ = ${serialize(data)}
</script>
</body>
</html>`
);
}
}
export default frontendRoute;

View File

@ -23,7 +23,7 @@ function strictParseInt(value) {
* @returns {number|undefined} * @returns {number|undefined}
*/ */
function parseBuildingURL(url) { function parseBuildingURL(url) {
const re = /\/building\/([^/]+).html/; const re = /\/(\d+)$/;
const matches = re.exec(url); const matches = re.exec(url);
if (matches && matches.length >= 2) { if (matches && matches.length >= 2) {

View File

@ -4,40 +4,25 @@
* - entry-point to shared React App * - entry-point to shared React App
* *
*/ */
import React from 'react';
import { StaticRouter } from 'react-router-dom';
import express from 'express'; import express from 'express';
import { renderToString } from 'react-dom/server';
import serialize from 'serialize-javascript';
import session from 'express-session'; import session from 'express-session';
import pgConnect from 'connect-pg-simple'; import pgConnect from 'connect-pg-simple';
import App from './frontend/app';
import db from './db'; import db from './db';
import { getUserById } from './api/services/user';
import {
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById
} from './api/services/building';
import tileserver from './tiles/tileserver'; import tileserver from './tiles/tileserver';
import apiServer from './api/api'; import apiServer from './api/api';
import { parseBuildingURL } from './parse'; import frontendRoute from './frontendRoute';
// create server // create server
const server = express(); const server = express();
// reference packed assets
const assets = require(process.env.RAZZLE_ASSETS_MANIFEST);
// disable header // disable header
server.disable('x-powered-by'); server.disable('x-powered-by');
// serve static files // serve static files
server.use(express.static(process.env.RAZZLE_PUBLIC_DIR)); server.use(express.static(process.env.RAZZLE_PUBLIC_DIR));
// handle user sessions // handle user sessions
const pgSession = pgConnect(session); const pgSession = pgConnect(session);
const sess: any = { // TODO: remove any const sess: any = { // TODO: remove any
@ -59,106 +44,8 @@ if (server.get('env') === 'production') {
} }
server.use(session(sess)); server.use(session(sess));
// handle HTML routes (server-side rendered React)
server.get('/*.html', frontendRoute);
server.get('/', frontendRoute);
function frontendRoute(req, res) {
const context: any = {}; // TODO: remove any
const data: any = {}; // TODO: remove any
context.status = 200;
const userId = req.session.user_id;
const buildingId = parseBuildingURL(req.url);
const isBuilding = (typeof (buildingId) !== 'undefined');
if (isBuilding && isNaN(buildingId)) {
context.status = 404;
}
Promise.all([
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false
]).then(function (values) {
const user = values[0];
const building = values[1];
const uprns = values[2];
const buildingLike = values[3];
if (isBuilding && typeof (building) === 'undefined') {
context.status = 404
}
data.user = user;
data.building = building;
data.building_like = buildingLike;
if (data.building != null) {
data.building.uprns = uprns;
}
renderHTML(context, data, req, res)
}).catch(error => {
console.error(error);
data.user = undefined;
data.building = undefined;
data.building_like = undefined;
context.status = 500;
renderHTML(context, data, req, res);
});
}
function renderHTML(context, data, req, res) {
const markup = renderToString(
<StaticRouter context={context} location={req.url}>
<App user={data.user} building={data.building} building_like={data.building_like} />
</StaticRouter>
);
if (context.url) {
res.redirect(context.url);
} else {
res.status(context.status).send(
`<!doctype html>
<html lang="">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta charset="utf-8" />
<title>Colouring London</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
@font-face {
font-family: 'glacial_cl';
src: url('/fonts/glacialindifference-regular-webfont.woff2') format('woff2'),
url('/fonts/glacialindifference-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
</style>
${
assets.client.css
? `<link rel="stylesheet" href="${assets.client.css}">`
: ''
}
${
process.env.NODE_ENV === 'production'
? `<script src="${assets.client.js}" defer></script>`
: `<script src="${assets.client.js}" defer crossorigin></script>`
}
</head>
<body>
<div id="root">${markup}</div>
<script>
window.__PRELOADED_STATE__ = ${serialize(data)}
</script>
</body>
</html>`
);
}
}
server.use('/tiles', tileserver); server.use('/tiles', tileserver);
server.use('/api', apiServer); server.use('/api', apiServer);
// use the frontend route for anything else - will presumably show the 404 page
server.use(frontendRoute); server.use(frontendRoute);
export default server; export default server;