Merge branch 'develop' into feature/342-bulk-extract

This commit is contained in:
Maciej Ziarkowski 2019-09-30 15:06:01 +01:00
commit 6783a00e21
111 changed files with 7754 additions and 7094 deletions

View File

@ -35,6 +35,12 @@
</Style>
<Style name="highlight">
<Rule>
<Filter>[base_layer] = 'location' or [base_layer] = 'conservation_area'</Filter>
<LineSymbolizer stroke="#ff0000aa" stroke-width="4.5" />
<LineSymbolizer stroke="#ff0000ff" stroke-width="2.5" />
</Rule>
<Rule>
<ElseFilter />
<LineSymbolizer stroke="#00ffffaa" stroke-width="4.5" />
<LineSymbolizer stroke="#00ffffff" stroke-width="2.5" />
</Rule>

7048
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
"@mapbox/sphericalmercator": "^1.1.0",
"body-parser": "^1.19.0",
"bootstrap": "^4.3.1",
"connect-pg-simple": "^5.0.0",
"connect-pg-simple": "^6.0.1",
"express": "^4.17.1",
"express-session": "^1.16.2",
"leaflet": "^1.5.1",
@ -32,20 +32,23 @@
"react-dom": "^16.9.0",
"react-leaflet": "^1.0.1",
"react-leaflet-universal": "^1.2.0",
"react-router-dom": "^4.3.1",
"react-router-dom": "^5.0.1",
"serialize-javascript": "^1.7.0",
"sharp": "^0.21.3"
"sharp": "^0.22.1"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/express-session": "^1.15.13",
"@types/jest": "^24.0.17",
"@types/mapbox__sphericalmercator": "^1.1.3",
"@types/node": "^8.10.52",
"@types/nodemailer": "^6.2.1",
"@types/prop-types": "^15.7.1",
"@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5",
"@types/react-leaflet": "^2.4.0",
"@types/react-router-dom": "^4.3.4",
"@types/sharp": "^0.22.2",
"@types/webpack-env": "^1.14.0",
"babel-eslint": "^10.0.2",
"eslint": "^5.16.0",

BIN
app/public/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,14 @@
{
"short_name": "Colouring London",
"name": "Colouring London",
"icons": [
{
"src": "icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": "/",
"background_color": "#ffffff",
"theme_color": "#fffff"
}

View File

@ -1,7 +1,7 @@
import express from 'express';
import bodyParser from 'body-parser';
import { authUser, createUser, getUserById, getNewUserAPIKey, deleteUser, logout } from './services/user';
import { authUser, getNewUserAPIKey, logout } from './services/user';
import { queryLocation } from './services/search';
import buildingsRouter from './routes/buildingsRouter';
@ -18,35 +18,6 @@ server.use('/buildings', buildingsRouter);
server.use('/users', usersRouter);
server.use('/extracts', extractsRouter);
// GET own user info
server.route('/users/me')
.get(function (req, res) {
if (!req.session.user_id) {
res.send({ error: 'Must be logged in' });
return
}
getUserById(req.session.user_id).then(function (user) {
res.send(user);
}).catch(function (error) {
res.send(error);
});
})
.delete((req, res) => {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
console.log(`Deleting user ${req.session.user_id}`);
deleteUser(req.session.user_id).then(
() => logout(req.session)
).then(() => {
res.send({ success: true });
}).catch(err => {
res.send({ error: err });
});
})
// POST user auth
server.post('/login', function (req, res) {
authUser(req.body.username, req.body.password).then(function (user: any) { // TODO: remove any

View File

@ -6,6 +6,7 @@ import * as userService from '../services/user';
import * as passwordResetService from '../services/passwordReset';
import { TokenVerificationError } from '../services/passwordReset';
import asyncController from '../routes/asyncController';
import { ValidationError } from '../validation';
function createUser(req, res) {
const user = req.body;
@ -73,8 +74,7 @@ const resetPassword = asyncController(async function(req: express.Request, res:
if(req.body.email != undefined) {
// first stage: send reset token to email address
// this relies on the API being on the same hostname as the frontend
const { origin } = new URL(req.protocol + '://' + req.headers.host);
const origin = getWebAppOrigin();
await passwordResetService.sendPasswordResetToken(req.body.email, origin);
return res.status(202).send({ success: true });
@ -88,6 +88,8 @@ const resetPassword = asyncController(async function(req: express.Request, res:
} catch (err) {
if (err instanceof TokenVerificationError) {
return res.send({ error: 'Could not verify token' });
} else if (err instanceof ValidationError) {
return res.send({ error: err.message});
}
throw err;
@ -97,6 +99,14 @@ const resetPassword = asyncController(async function(req: express.Request, res:
}
});
function getWebAppOrigin() : string {
const origin = process.env.WEBAPP_ORIGIN;
if (origin == undefined) {
throw new Error('WEBAPP_ORIGIN not defined');
}
return origin;
}
export default {
createUser,
getCurrentUser,

View File

@ -3,7 +3,8 @@
*
*/
import db from '../../db';
import { removeAllAtBbox } from '../../tiles/cache';
import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types';
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
// JavaScript numerics are 64-bit double, giving only partial coverage.
@ -314,8 +315,8 @@ function privateQueryBuildingBBOX(buildingId){
function expireBuildingTileCache(buildingId) {
privateQueryBuildingBBOX(buildingId).then((bbox) => {
const buildingBbox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin]
removeAllAtBbox(buildingBbox);
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
tileCache.removeAllAtBbox(buildingBbox);
})
}

View File

@ -5,6 +5,7 @@ import nodemailer from 'nodemailer';
import db from '../../db';
import * as userService from './user';
import { transporter } from './email';
import { validatePassword } from '../validation';
/**
@ -24,6 +25,7 @@ async function sendPasswordResetToken(email: string, siteOrigin: string): Promis
}
async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
validatePassword(newPassword);
const userId = await verifyPasswordResetToken(passwordResetToken);
if (userId != undefined) {
await updatePasswordForUser(userId, newPassword);

View File

@ -2,15 +2,21 @@
* User data access
*
*/
import { errors } from 'pg-promise';
import db from '../../db';
import { validateUsername, ValidationError, validatePassword } from '../validation';
function createUser(user) {
if (!user.password || user.password.length < 8) {
return Promise.reject({ error: 'Password must be at least 8 characters' })
}
if (user.password.length > 70) {
return Promise.reject({ error: 'Password must be at most 70 characters' })
try {
validateUsername(user.username);
validatePassword(user.password);
} catch(err) {
if (err instanceof ValidationError) {
return Promise.reject({ error: err.message });
} else throw err;
}
return db.one(
`INSERT
INTO users (
@ -64,8 +70,12 @@ function authUser(username, password) {
return { error: 'Username or password not recognised' }
}
}).catch(function (err) {
console.error(err);
return { error: 'Username or password not recognised' };
if (err instanceof errors.QueryResultError) {
console.error(`Authentication failed for user ${username}`);
return { error: 'Username or password not recognised' };
}
console.error('Error:', err);
return { error: 'Database error' };
})
}

25
app/src/api/validation.ts Normal file
View File

@ -0,0 +1,25 @@
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
function validateUsername(username: string): void {
if (username == undefined) throw new ValidationError('Username is required');
if (!username.match(/^\w+$/)) throw new ValidationError('Username can only contain alphanumeric characters and underscore');
if (username.length < 4) throw new ValidationError('Username must be at least 4 characters long');
if (username.length > 30) throw new ValidationError('Username must be at most 30 characters long');
}
function validatePassword(password: string): void {
if (password == undefined) throw new ValidationError('Password is required');
if (password.length < 8) throw new ValidationError('Password must be at least 8 characters long');
if (password.length > 128) throw new ValidationError('Password must be at most 128 characters long');
}
export {
ValidationError,
validateUsername,
validatePassword
};

View File

@ -2,7 +2,7 @@
* 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 { hydrate } from 'react-dom';

View File

@ -1,29 +1,31 @@
import React, { Fragment } from 'react';
import { Route, Switch, Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { parse } from 'query-string';
import '../../node_modules/bootstrap/dist/css/bootstrap.min.css';
import './app.css';
import AboutPage from './about';
import BuildingEdit from './building-edit';
import BuildingView from './building-view';
import MultiEdit from './multi-edit';
import ColouringMap from './map';
import Header from './header';
import Overview from './overview';
import Login from './login';
import MyAccountPage from './my-account';
import SignUp from './signup';
import Welcome from './welcome';
import { parseCategoryURL } from '../parse';
import PrivacyPolicyPage from './privacy-policy';
import ContributorAgreementPage from './contributor-agreement';
import ForgottenPassword from './forgotten-password';
import PasswordReset from './password-reset';
import AboutPage from './pages/about';
import ContributorAgreementPage from './pages/contributor-agreement';
import PrivacyPolicyPage from './pages/privacy-policy';
import DataExtracts from './data-extracts';
import Login from './user/login';
import MyAccountPage from './user/my-account';
import SignUp from './user/signup';
import ForgottenPassword from './user/forgotten-password';
import PasswordReset from './user/password-reset';
import MapApp from './map-app';
interface AppProps {
user?: any;
building?: any;
building_like?: boolean;
}
/**
* App component
*
@ -36,35 +38,28 @@ import DataExtracts from './data-extracts';
* 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.
*/
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
user: PropTypes.object,
building: PropTypes.object,
building_like: PropTypes.bool
}
};
constructor(props) {
constructor(props: Readonly<AppProps>) {
super(props);
// set building revision id, default 0
const rev = (props.building)? +props.building.revision_id : 0;
this.state = {
user: props.user,
building: props.building,
building_like: props.building_like,
revision_id: rev
user: props.user
};
this.login = this.login.bind(this);
this.updateUser = this.updateUser.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) {
if (user.error) {
this.logout();
return
return;
}
this.setState({user: user});
}
@ -77,197 +72,42 @@ class App extends React.Component<any, any> { // TODO: add proper types
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() {
return (
<Fragment>
<Header user={this.state.user} />
<main>
<Switch>
<Route exact path="/">
<Welcome />
</Route>
<Route exact path="/view/:cat.html" render={(props) => (
<Overview
{...props}
mode='view' user={this.state.user}
/>
) } />
<Route exact path="/edit/:cat.html" render={(props) => (
<Overview
{...props}
mode='edit' user={this.state.user}
/>
) } />
<Route exact path="/multi-edit/:cat.html" render={(props) => (
<MultiEdit
{...props}
user={this.state.user}
/>
) } />
<Route exact path="/view/:cat/building/:building.html" render={(props) => (
<BuildingView
{...props}
{...this.state.building}
user={this.state.user}
building_like={this.state.building_like}
/>
) } />
<Route exact path="/edit/:cat/building/:building.html" render={(props) => (
<BuildingEdit
{...props}
{...this.state.building}
user={this.state.user}
building_like={this.state.building_like}
selectBuilding={this.selectBuilding}
/>
) } />
</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="/login.html">
<Login user={this.state.user} login={this.login} />
</Route>
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
<Route exact path="/password-reset.html" component={PasswordReset} />
<Route exact path="/sign-up.html">
<SignUp user={this.state.user} login={this.login} />
</Route>
<Route exact path="/my-account.html">
<MyAccountPage
user={this.state.user}
updateUser={this.updateUser}
logout={this.logout}
/>
</Route>
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
<Route exact path="/data-extracts.html" component={DataExtracts} />
<Route component={NotFound} />
</Switch>
</main>
<Header user={this.state.user} />
<main>
<Switch>
<Route exact path="/about.html" component={AboutPage} />
<Route exact path="/login.html">
<Login user={this.state.user} login={this.login} />
</Route>
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
<Route exact path="/password-reset.html" component={PasswordReset} />
<Route exact path="/sign-up.html">
<SignUp user={this.state.user} login={this.login} />
</Route>
<Route exact path="/my-account.html">
<MyAccountPage
user={this.state.user}
updateUser={this.updateUser}
logout={this.logout}
/>
</Route>
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
<Route exact path="/data-extracts.html" component={DataExtracts} />
<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} />
</Switch>
</main>
</Fragment>
);
}

View File

@ -1,709 +0,0 @@
import React, { Component, Fragment } from 'react';
import { Link, NavLink, Redirect } from 'react-router-dom';
import PropTypes from 'prop-types';
import ErrorBox from './error-box';
import InfoBox from './info-box';
import Sidebar from './sidebar';
import Tooltip from './tooltip';
import { SaveIcon } from './icons';
import CONFIG from './fields-config.json';
const BuildingEdit = (props) => {
if (!props.user){
return <Redirect to="/sign-up.html" />
}
const cat = props.match.params.cat;
if (!props.building_id){
return (
<Sidebar title="Building Not Found" back={`/edit/${cat}.html`}>
<InfoBox msg="We can't find that one anywhere - try the map again?" />
<div className="buttons-container ml-3 mr-3">
<Link to={`/edit/${cat}.html`} className="btn btn-secondary">Back to maps</Link>
</div>
</Sidebar>
);
}
return (
<Sidebar
key={props.building_id}
title={'You are editing'}
back={`/edit/${cat}.html`}>
{
CONFIG.map((section) => {
return <EditForm
{...section} {...props}
cat={cat} key={section.slug} />
})
}
</Sidebar>
);
}
BuildingEdit.propTypes = {
user: PropTypes.object,
match: PropTypes.object,
building_id: PropTypes.number
}
class EditForm extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
slug: PropTypes.string,
cat: PropTypes.string,
help: PropTypes.string,
error: PropTypes.object,
like: PropTypes.bool,
building_like: PropTypes.bool,
selectBuilding: PropTypes.func,
building_id: PropTypes.number,
inactive: PropTypes.bool,
fields: PropTypes.array
};
constructor(props) {
super(props);
// create object and spread into state to avoid TS complaining about modifying readonly state
let fieldsObj = {};
for (const field of props.fields) {
fieldsObj[field.slug] = props[field.slug];
}
this.state = {
error: this.props.error || undefined,
like: this.props.like || undefined,
copying: false,
keys_to_copy: {},
...fieldsObj
}
this.handleChange = this.handleChange.bind(this);
this.handleCheck = this.handleCheck.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleUpdate = this.handleUpdate.bind(this);
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
toggleCopying() {
this.setState({
copying: !this.state.copying
})
}
/**
* Keep track of data to copy (accumulate while in "copying" state)
*
* Note that we track keys only - values are already held in state
*
* @param {string} key
*/
toggleCopyAttribute(key) {
const keys = this.state.keys_to_copy;
if(this.state.keys_to_copy[key]){
delete keys[key];
} else {
keys[key] = true;
}
this.setState({
keys_to_copy: keys
})
}
/**
* Handle changes on typical inputs
* - e.g. input[type=text], radio, select, textare
*
* @param {DocumentEvent} event
*/
handleChange(event) {
const target = event.target;
let value = (target.value === '')? null : target.value;
const name = target.name;
// special transform - consider something data driven before adding 'else if's
if (name === 'location_postcode' && value !== null) {
value = value.toUpperCase();
}
this.setState({
[name]: value
});
}
/**
* Handle changes on checkboxes
* - e.g. input[type=checkbox]
*
* @param {DocumentEvent} event
*/
handleCheck(event) {
const target = event.target;
const value = target.checked;
const name = target.name;
this.setState({
[name]: value
});
}
/**
* Handle update directly
* - e.g. as callback from MultiTextInput where we set a list of strings
*
* @param {String} key
* @param {*} value
*/
handleUpdate(key, value) {
this.setState({
[key]: value
});
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {DocumentEvent} event
*/
handleLike(event) {
event.preventDefault();
const like = event.target.checked;
fetch(`/api/buildings/${this.props.building_id}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: like})
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
this.setState({
likes_total: res.likes_total
})
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined})
fetch(`/api/buildings/${this.props.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
render() {
const match = this.props.cat === this.props.slug;
const cat = this.props.cat;
const buildingLike = this.props.building_like;
const values_to_copy = {}
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = this.state[key]
}
const data_string = JSON.stringify(values_to_copy);
return (
<section className={(this.props.inactive)? 'data-section inactive': 'data-section'}>
<header className={`section-header edit ${this.props.slug} ${(match? 'active' : '')}`}>
<NavLink
to={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}
title={(this.props.inactive)? 'Coming soon… Click the ? for more info.' :
(match)? 'Hide details' : 'Show details'}
isActive={() => match}>
<h3 className="h3">{this.props.title}</h3>
</NavLink>
<nav className="icon-buttons">
{
(match && !this.props.inactive && this.props.slug !== 'like')?
this.state.copying?
<Fragment>
<NavLink
to={`/multi-edit/${this.props.cat}.html?data=${data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a className="icon-button copy" onClick={this.toggleCopying}>Cancel</a>
</Fragment>
:
<a className="icon-button copy" onClick={this.toggleCopying}>Copy</a>
: null
}
{
(match && this.props.slug === 'like')?
<NavLink
to={`/multi-edit/${this.props.cat}.html`}
className="icon-button copy">
Copy
</NavLink>
: null
}
{
this.props.help && !this.state.copying?
<a className="icon-button help" title="Find out more" href={this.props.help}>
Info
</a>
: null
}
{
(match && !this.state.copying && !this.props.inactive && this.props.slug !== 'like')? // special-case for likes
<NavLink className="icon-button save" title="Save Changes"
onClick={this.handleSubmit}
to={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}>
Save
<SaveIcon />
</NavLink>
: null
}
</nav>
</header>
{
match? (
!this.props.inactive?
<form action={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}
method="GET" onSubmit={this.handleSubmit}>
{
this.props.slug === 'location'?
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
: null
}
<ErrorBox msg={this.state.error} />
{
this.props.fields.map((props) => {
switch (props.type) {
case 'text':
return <TextInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_list':
return <TextListInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_long':
return <LongTextInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'number':
return <NumberInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'year_estimator':
return <YearEstimator {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_multi':
return <MultiTextInput {...props} handleChange={this.handleUpdate}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'checkbox':
return <CheckboxInput {...props} handleChange={this.handleCheck}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'like':
return <LikeButton {...props} handleLike={this.handleLike}
building_like={buildingLike}
value={this.state[props.slug]} key={props.slug} cat={cat} />
default:
return null
}
})
}
<InfoBox 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
null :
<div className="buttons-container">
<button type="submit" className="btn btn-primary">Save</button>
</div>
}
</form>
: <form>
<InfoBox msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`} />
</form>
) : null
}
</section>
)
}
}
const TextInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy}
cat={props.cat}
disabled={props.disabled} />
<input className="form-control" type="text"
id={props.slug} name={props.slug}
value={props.value || ''}
maxLength={props.max_length}
disabled={props.disabled}
placeholder={props.placeholder}
onChange={props.handleChange}
/>
</Fragment>
);
TextInput.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.string,
max_length: PropTypes.number,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
handleChange: PropTypes.func
}
const LongTextInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} cat={props.cat}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy}
disabled={props.disabled} tooltip={props.tooltip} />
<textarea className="form-control"
id={props.slug} name={props.slug}
disabled={props.disabled}
placeholder={props.placeholder}
onChange={props.handleChange}
value={props.value || ''}></textarea>
</Fragment>
)
LongTextInput.propTypes = {
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.string,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
handleChange: PropTypes.func
}
class MultiTextInput extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.string),
placeholder: PropTypes.string,
disabled: PropTypes.bool,
handleChange: PropTypes.func,
copy: PropTypes.bool,
toggleCopyAttribute: PropTypes.func,
copying: PropTypes.bool
};
constructor(props) {
super(props);
this.edit = this.edit.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
this.getValues = this.getValues.bind(this);
}
getValues() {
return (this.props.value && this.props.value.length)? this.props.value : [null];
}
edit(event) {
const editIndex = +event.target.dataset.index;
const editItem = event.target.value;
const oldValues = this.getValues();
const values = oldValues.map((item, i) => {
return i === editIndex ? editItem : item;
});
this.props.handleChange(this.props.slug, values);
}
add(event) {
event.preventDefault();
const values = this.getValues().concat('');
this.props.handleChange(this.props.slug, values);
}
remove(event){
const removeIndex = +event.target.dataset.index;
const values = this.getValues().filter((_, i) => {
return i !== removeIndex;
});
this.props.handleChange(this.props.slug, values);
}
render() {
const values = this.getValues();
return (
<Fragment>
<Label slug={this.props.slug} title={this.props.title} tooltip={this.props.tooltip}
cat={this.props.cat}
copying={this.props.copying}
disabled={this.props.disabled}
toggleCopyAttribute={this.props.toggleCopyAttribute}
copy={this.props.copy} />
{
values.map((item, i) => (
<div className="input-group" key={i}>
<input className="form-control" type="text"
key={`${this.props.slug}-${i}`} name={`${this.props.slug}-${i}`}
data-index={i}
value={item || ''}
placeholder={this.props.placeholder}
disabled={this.props.disabled}
onChange={this.edit}
/>
<div className="input-group-append">
<button type="button" onClick={this.remove}
title="Remove"
data-index={i} className="btn btn-outline-dark"></button>
</div>
</div>
))
}
<button type="button" title="Add" onClick={this.add}
className="btn btn-outline-dark">+</button>
</Fragment>
)
}
}
const TextListInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
cat={props.cat} disabled={props.disabled}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy} />
<select className="form-control"
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.disabled}
// list={`${props.slug}_suggestions`} TODO: investigate whether this was needed
onChange={props.handleChange}>
<option value="">Select a source</option>
{
props.options.map(option => (
<option key={option} value={option}>{option}</option>
))
}
</select>
</Fragment>
)
TextListInput.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.string),
value: PropTypes.string,
disabled: PropTypes.bool,
handleChange: PropTypes.func
}
const NumberInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
cat={props.cat} disabled={props.disabled}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy} />
<input className="form-control" type="number" step={props.step}
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.disabled}
onChange={props.handleChange}
/>
</Fragment>
);
NumberInput.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
step: PropTypes.number,
value: PropTypes.number,
disabled: PropTypes.bool,
handleChange: PropTypes.func
}
class YearEstimator extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
date_year: PropTypes.number,
date_upper: PropTypes.number,
date_lower: PropTypes.number,
value: PropTypes.number,
disabled: PropTypes.bool,
handleChange: PropTypes.func,
copy: PropTypes.bool,
toggleCopyAttribute: PropTypes.func,
copying: PropTypes.bool
};
constructor(props) {
super(props);
this.state = {
year: props.date_year,
upper: props.date_upper,
lower: props.date_lower,
decade: Math.floor(props.date_year / 10) * 10,
century: Math.floor(props.date_year / 100) * 100
}
}
// TODO add dropdown for decade, century
// TODO roll in first/last year estimate
// TODO handle changes internally, reporting out date_year, date_upper, date_lower
render() {
return (
<NumberInput {...this.props} handleChange={this.props.handleChange}
value={this.props.value} key={this.props.slug} />
)
}
}
const CheckboxInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
cat={props.cat} disabled={props.disabled}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy} />
<div className="form-check">
<input className="form-check-input" type="checkbox"
id={props.slug} name={props.slug}
checked={!!props.value}
disabled={props.disabled}
onChange={props.handleChange}
/>
<label htmlFor={props.slug} className="form-check-label">
{props.title}
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</label>
</div>
</Fragment>
)
CheckboxInput.propTypes = {
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.bool,
disabled: PropTypes.bool,
handleChange: PropTypes.func
}
const LikeButton = (props) => (
<Fragment>
<p className="likes">{(props.value)? props.value : 0} likes</p>
<div className="form-check">
<input className="form-check-input" type="checkbox"
id={props.slug} name={props.slug}
checked={!!props.building_like}
disabled={props.disabled}
onChange={props.handleLike}
/>
<label htmlFor={props.slug} className="form-check-label">
I like this building and think it contributes to the city!
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</label>
</div>
<p>
<NavLink
to={`/multi-edit/${props.cat}.html`}>
Like more buildings
</NavLink>
</p>
</Fragment>
);
LikeButton.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.number,
building_like: PropTypes.bool,
disabled: PropTypes.bool,
handleLike: PropTypes.func
}
const Label: React.SFC<any> = (props) => { // TODO: remove any
return (
<label htmlFor={props.slug}>
{props.title}
{ (props.copying && props.cat && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input type="checkbox" checked={props.copy}
onChange={() => props.toggleCopyAttribute(props.slug)}/>
</label>
</div> : null
}
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</label>
);
}
Label.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
disabled: PropTypes.bool,
tooltip: PropTypes.string
}
export default BuildingEdit;

View File

@ -1,368 +0,0 @@
import React, { Fragment } from 'react';
import { Link, NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import Sidebar from './sidebar';
import Tooltip from './tooltip';
import InfoBox from './info-box';
import { EditIcon } from './icons';
import { sanitiseURL } from './helpers';
import CONFIG from './fields-config.json';
const BuildingView = (props) => {
if (!props.building_id){
return (
<Sidebar title="Building Not Found">
<InfoBox msg="We can't find that one anywhere - try the map again?" />
<div className="buttons-container with-space">
<Link to="/view/age.html" className="btn btn-secondary">Back to maps</Link>
</div>
</Sidebar>
);
}
const cat = props.match.params.cat;
return (
<Sidebar title={'Data available for this building'} back={`/view/${cat}.html`}>
{
CONFIG.map(section => (
<DataSection
key={section.slug} cat={cat}
building_id={props.building_id}
{...section} {...props} />
))
}
</Sidebar>
);
}
BuildingView.propTypes = {
building_id: PropTypes.number,
match: PropTypes.object,
uprns: PropTypes.arrayOf(PropTypes.shape({
uprn: PropTypes.string.isRequired,
parent_uprn: PropTypes.string
})),
building_like: PropTypes.bool
}
class DataSection extends React.Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
cat: PropTypes.string,
slug: PropTypes.string,
intro: PropTypes.string,
help: PropTypes.string,
inactive: PropTypes.bool,
building_id: PropTypes.number,
children: PropTypes.node
};
constructor(props) {
super(props);
this.state = {
copying: false,
values_to_copy: {}
};
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
toggleCopying() {
this.setState({
copying: !this.state.copying
})
}
/**
* Keep track of data to copy (accumulate while in "copying" state)
*
* @param {string} key
*/
toggleCopyAttribute(key) {
const value = this.props[key];
const values = this.state.values_to_copy;
if(Object.keys(this.state.values_to_copy).includes(key)){
delete values[key];
} else {
values[key] = value;
}
this.setState({
values_to_copy: values
})
}
render() {
const props = this.props;
const match = props.cat === props.slug;
const data_string = JSON.stringify(this.state.values_to_copy);
return (
<section id={props.slug} className={(props.inactive)? 'data-section inactive': 'data-section'}>
<header className={`section-header view ${props.slug} ${(match? 'active' : '')}`}>
<NavLink
to={`/view/${props.slug}/building/${props.building_id}.html`}
title={(props.inactive)? 'Coming soon… Click the ? for more info.' :
(match)? 'Hide details' : 'Show details'}
isActive={() => match}>
<h3 className="h3">{props.title}</h3>
</NavLink>
<nav className="icon-buttons">
{
(match && !props.inactive)?
this.state.copying?
<Fragment>
<NavLink
to={`/multi-edit/${props.cat}.html?data=${data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a className="icon-button copy" onClick={this.toggleCopying}>Cancel</a>
</Fragment>
:
<a className="icon-button copy" onClick={this.toggleCopying}>Copy</a>
: null
}
{
props.help && !this.state.copying?
<a className="icon-button help" title="Find out more" href={props.help}>
Info
</a>
: null
}
{
!props.inactive && !this.state.copying?
<NavLink className="icon-button edit" title="Edit data"
to={`/edit/${props.slug}/building/${props.building_id}.html`}>
Edit
<EditIcon />
</NavLink>
: null
}
</nav>
</header>
{
match?
!props.inactive?
<dl className="data-list">
{
props.fields.map(field => {
switch (field.type) {
case 'uprn_list':
return <UPRNsDataEntry
key={field.slug}
title={field.title}
value={props.uprns}
tooltip={field.tooltip} />
case 'text_multi':
return <MultiDataEntry
key={field.slug}
slug={field.slug}
disabled={field.disabled}
cat={props.cat}
title={field.title}
value={props[field.slug]}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={Object.keys(this.state.values_to_copy).includes(field.slug)}
tooltip={field.tooltip} />
case 'like':
return <LikeDataEntry
key={field.slug}
title={field.title}
value={props[field.slug]}
user_building_like={props.building_like}
tooltip={field.tooltip} />
default:
return <DataEntry
key={field.slug}
slug={field.slug}
disabled={field.disabled}
cat={props.cat}
title={field.title}
value={props[field.slug]}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={Object.keys(this.state.values_to_copy).includes(field.slug)}
tooltip={field.tooltip} />
}
})
}
</dl>
: <p className="data-intro">{props.intro}</p>
: null
}
</section>
);
}
}
const DataEntry: React.SFC<any> = (props) => { // TODO: remove any
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
{ (props.copying && props.cat && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input type="checkbox" checked={props.copy}
onChange={() => props.toggleCopyAttribute(props.slug)}/>
</label>
</div>
: null
}
</dt>
<dd>{
(props.value != null && props.value !== '')?
(typeof(props.value) === 'boolean')?
(props.value)? 'Yes' : 'No'
: props.value
: '\u00A0'}</dd>
</Fragment>
);
}
DataEntry.propTypes = {
title: PropTypes.string,
cat: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any
}
const LikeDataEntry: React.SFC<any> = (props) => { // TODO: remove any
const data_string = JSON.stringify({like: true});
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
<div className="icon-buttons">
<NavLink
to={`/multi-edit/${props.cat}.html?data=${data_string}`}
className="icon-button copy">
Copy
</NavLink>
</div>
</dt>
<dd>
{
(props.value != null)?
(props.value === 1)?
`${props.value} person likes this building`
: `${props.value} people like this building`
: '\u00A0'
}
</dd>
{
(props.user_building_like)? <dd>&hellip;including you!</dd> : ''
}
</Fragment>
);
}
LikeDataEntry.propTypes = {
title: PropTypes.string,
cat: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.any,
user_building_like: PropTypes.bool
}
const MultiDataEntry: React.SFC<any> = (props) => { // TODO: remove any
let content;
if (props.value && props.value.length) {
content = <ul>{
props.value.map((item, index) => {
return <li key={index}><a href={sanitiseURL(item)}>{item}</a></li>
})
}</ul>
} else {
content = '\u00A0'
}
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
{ (props.copying && props.cat && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input type="checkbox" checked={props.copy}
onChange={() => props.toggleCopyAttribute(props.slug)}/>
</label>
</div>
: null
}
</dt>
<dd>{ content }</dd>
</Fragment>
);
}
MultiDataEntry.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.string)
}
const UPRNsDataEntry = (props) => {
const uprns = props.value || [];
const noParent = uprns.filter(uprn => uprn.parent_uprn == null);
const withParent = uprns.filter(uprn => uprn.parent_uprn != null);
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</dt>
<dd><ul className="uprn-list">
<Fragment>{
noParent.length?
noParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn}</li>
))
: '\u00A0'
}</Fragment>
{
withParent.length?
<details>
<summary>Children</summary>
{
withParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn} (child of {uprn.parent_uprn})</li>
))
}
</details>
: null
}
</ul></dd>
</Fragment>
)
}
UPRNsDataEntry.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.shape({
uprn: PropTypes.string.isRequired,
parent_uprn: PropTypes.string
}))
}
export default BuildingView;

View File

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

View File

@ -0,0 +1,133 @@
import React from 'react';
import BuildingNotFound from './building-not-found';
import LocationContainer from './data-containers/location';
import UseContainer from './data-containers/use';
import TypeContainer from './data-containers/type';
import AgeContainer from './data-containers/age';
import SizeContainer from './data-containers/size';
import ConstructionContainer from './data-containers/construction';
import TeamContainer from './data-containers/team';
import SustainabilityContainer from './data-containers/sustainability';
import StreetscapeContainer from './data-containers/streetscape';
import CommunityContainer from './data-containers/community';
import PlanningContainer from './data-containers/planning';
import LikeContainer from './data-containers/like';
/**
* Top-level container for building view/edit form
*
* @param props
*/
const BuildingView = (props) => {
switch (props.cat) {
case 'location':
return <LocationContainer
{...props}
key={props.building && props.building.building_id}
title="Location"
help="https://pages.colouring.london/location"
intro="Where are the buildings? Address, location and cross-references."
/>
case 'use':
return <UseContainer
{...props}
key={props.building && props.building.building_id}
inactive={true}
title="Land Use"
intro="How are buildings used, and how does use change over time? Coming soon…"
help="https://pages.colouring.london/use"
/>
case 'type':
return <TypeContainer
{...props}
key={props.building && props.building.building_id}
inactive={true}
title="Type"
intro="How were buildings previously used? Coming soon…"
help="https://www.pages.colouring.london/buildingtypology"
/>
case 'age':
return <AgeContainer
{...props}
key={props.building && props.building.building_id}
title="Age"
help="https://pages.colouring.london/age"
intro="Building age data can support energy analysis and help predict long-term change."
/>
case 'size':
return <SizeContainer
{...props}
key={props.building && props.building.building_id}
title="Size &amp; Shape"
intro="How big are buildings?"
help="https://pages.colouring.london/shapeandsize"
/>
case 'construction':
return <ConstructionContainer
{...props}
key={props.building && props.building.building_id}
title="Construction"
intro="How are buildings built? Coming soon…"
help="https://pages.colouring.london/construction"
inactive={true}
/>
case 'team':
return <TeamContainer
{...props}
key={props.building && props.building.building_id}
title="Team"
intro="Who built the buildings? Coming soon…"
help="https://pages.colouring.london/team"
inactive={true}
/>
case 'sustainability':
return <SustainabilityContainer
{...props}
key={props.building && props.building.building_id}
title="Sustainability"
intro="Are buildings energy efficient? Coming soon…"
help="https://pages.colouring.london/sustainability"
inactive={true}
/>
case 'streetscape':
return <StreetscapeContainer
{...props}
key={props.building && props.building.building_id}
title="Streetscape"
intro="What's the building's context? Coming soon…"
help="https://pages.colouring.london/streetscape"
inactive={true}
/>
case 'community':
return <CommunityContainer
{...props}
key={props.building && props.building.building_id}
title="Community"
intro="How does this building work for the local community?"
help="https://pages.colouring.london/community"
inactive={true}
/>
case 'planning':
return <PlanningContainer
{...props}
key={props.building && props.building.building_id}
title="Planning"
intro="Planning controls relating to protection and reuse."
help="https://pages.colouring.london/planning"
/>
case 'like':
return <LikeContainer
{...props}
key={props.building && props.building.building_id}
title="Like Me!"
intro="Do you like the building and think it contributes to the city?"
help="https://pages.colouring.london/likeme"
/>
default:
return <BuildingNotFound mode="view" />
}
}
export default BuildingView;

View File

@ -0,0 +1,67 @@
/**
* Data categories
*/
.data-category-list {
padding: 0 0 0.75rem;
text-align: center;
list-style: none;
margin: 0 0 0 0.2rem;
text-align: center;
}
.data-category-list li {
position: relative;
display: inline-block;
vertical-align: bottom;
width: 10rem;
height: 10rem;
margin: 0.375rem;
box-shadow: 0 0 2px 5px #ffffff;
transition: box-shadow 0.2s;
}
.data-category-list li:hover {
box-shadow: 0 0 2px 5px #00ffff;
}
.data-category-list a {
color: #222;
text-decoration: none;
}
.data-category-list .category-link {
display: block;
padding: 0.1em;
width: 100%;
height: 100%;
}
.data-category-list .category-link:hover,
.data-category-list .category-link:active,
.data-category-list .category-link:focus {
color: #222;
}
.data-category-list .help {
position: absolute;
bottom: 0.75rem;
right: 0.75rem;
color: #222;
}
.data-category-list .help:hover,
.data-category-list .help:active,
.data-category-list .help:focus {
color: #000;
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 {
text-align: center;
font-size: 1.5em;
margin: 1.4em 0 0.5em;
}
.data-category-list .description {
text-align: center;
font-size: 1em;
margin: 0 0 1em;
}

View File

@ -0,0 +1,159 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import './categories.css'
const Categories = (props) => (
<ol className="data-category-list">
<Category
title="Location"
desc="Where's the building?"
slug="location"
help="https://pages.colouring.london/location"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Land Use"
desc="What's it used for?"
slug="use"
help="https://pages.colouring.london/use"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Type"
desc="Building type"
slug="type"
help="https://pages.colouring.london/buildingtypology"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Age"
desc="Age and history"
slug="age"
help="https://pages.colouring.london/age"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Size &amp; Shape"
desc="Form and scale"
slug="size"
help="https://pages.colouring.london/shapeandsize"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Construction"
desc="Methods and materials"
slug="construction"
help="https://pages.colouring.london/construction"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Team"
desc="Builder and designer"
slug="team"
help="https://pages.colouring.london/team"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Streetscape"
desc="Environment"
slug="streetscape"
help="https://pages.colouring.london/streetscape"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Sustainability"
desc="Performance"
slug="sustainability"
help="https://pages.colouring.london/sustainability"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Community"
desc="Public asset?"
slug="community"
help="https://pages.colouring.london/community"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Planning"
desc="Special controls?"
slug="planning"
help="https://pages.colouring.london/planning"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Like Me?"
desc="Adds to the city?"
slug="like"
help="https://pages.colouring.london/likeme"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
</ol>
)
Categories.propTypes = {
mode: PropTypes.string,
building_id: PropTypes.number
}
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}`}>
<NavLink
className="category-link"
to={categoryLink}
title={
(props.inactive)?
'Coming soon… Click more info for details.'
: 'View/Edit Map'
}>
<h3 className="category">{props.title}</h3>
<p className="description">{props.desc}</p>
</NavLink>
<a className="icon-button help" href={props.help}>
More
</a>
</li>
);
}
Category.propTypes = {
title: PropTypes.string,
desc: PropTypes.string,
slug: PropTypes.string,
help: PropTypes.string,
inactive: PropTypes.bool,
mode: PropTypes.string,
building_id: PropTypes.number
}
export default Categories;

View File

@ -0,0 +1,70 @@
import React, { Fragment } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { BackIcon, EditIcon, ViewIcon }from '../components/icons';
const ContainerHeader: React.FunctionComponent<any> = (props) => (
<header className={`section-header view ${props.cat} background-${props.cat}`}>
<Link className="icon-button back" to={`/${props.mode}/categories${props.building != undefined ? `/${props.building.building_id}` : ''}`}>
<BackIcon />
</Link>
<h2 className="h2">{props.title}</h2>
<nav className="icon-buttons">
{
props.building != undefined && !props.inactive ?
props.copy.copying?
<Fragment>
<NavLink
to={`/multi-edit/${props.cat}?data=${props.data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a
className="icon-button copy"
onClick={props.copy.toggleCopying}>
Cancel
</a>
</Fragment>
:
<a
className="icon-button copy"
onClick={props.copy.toggleCopying}>
Copy
</a>
: null
}
{
props.help && !props.copy.copying?
<a
className="icon-button help"
title="Find out more"
href={props.help}>
Info
</a>
: null
}
{
props.building != undefined && !props.inactive && !props.copy.copying?
(props.mode === 'edit')?
<NavLink
className="icon-button view"
title="View data"
to={`/view/${props.cat}/${props.building.building_id}`}>
View
<ViewIcon />
</NavLink>
: <NavLink
className="icon-button edit"
title="Edit data"
to={`/edit/${props.cat}/${props.building.building_id}`}>
Edit
<EditIcon />
</NavLink>
: null
}
</nav>
</header>
)
export default ContainerHeader;

View File

@ -0,0 +1,50 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
copy={props.copy}
/>
<div className="form-check">
<input className="form-check-input" type="checkbox"
id={props.slug}
name={props.slug}
checked={!!props.value}
disabled={props.mode === 'view' || props.disabled}
onChange={props.onChange}
/>
<label
htmlFor={props.slug}
className="form-check-label">
{props.title}
</label>
</div>
</Fragment>
);
}
DataEntry.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any,
placeholder: PropTypes.string,
maxLength: PropTypes.number,
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
})
}
export default DataEntry;

View File

@ -0,0 +1,45 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
copy={props.copy}
/>
<input className="form-control" type="text"
id={props.slug}
name={props.slug}
value={props.value || ''}
maxLength={props.maxLength}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
/>
</Fragment>
);
}
DataEntry.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any,
placeholder: PropTypes.string,
maxLength: PropTypes.number,
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
})
}
export default DataEntry;

View File

@ -0,0 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip';
const DataTitle: React.FunctionComponent<any> = (props) => {
return (
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</dt>
)
}
DataTitle.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string
}
const DataTitleCopyable: React.FunctionComponent<any> = (props) => { // TODO: remove any
return (
<div className="data-title">
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
{ (props.copy && props.copy.copying && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input
type="checkbox"
checked={props.copy.copyingKey(props.slug)}
onChange={() => props.copy.toggleCopyAttribute(props.slug)}/>
</label>
</div>
: null
}
<label htmlFor={props.slug}>
{ props.title }
</label>
</div>
);
}
DataTitleCopyable.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string,
slug: PropTypes.string,
disabled: PropTypes.bool,
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
})
}
export default DataTitle;
export { DataTitleCopyable }

View File

@ -0,0 +1,49 @@
import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip';
const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
const data_string = JSON.stringify({like: true});
return (
<Fragment>
<div className="data-title">
<Tooltip text="People who like the building and think it contributes to the city." />
<div className="icon-buttons">
<NavLink
to={`/multi-edit/like?data=${data_string}`}
className="icon-button like">
Like more
</NavLink>
</div>
<label>Number of likes</label>
</div>
<p>
{
(props.value != null)?
(props.value === 1)?
`${props.value} person likes this building`
: `${props.value} people like this building`
: "0 people like this building so far - you could be the first!"
}
</p>
<input className="form-check-input" type="checkbox"
id="like" name="like"
checked={!!props.building_like}
disabled={props.mode === 'view'}
onChange={props.handleLike}
/>
<label htmlFor="like" className="form-check-label">
I like this building and think it contributes to the city!
</label>
</Fragment>
);
}
LikeDataEntry.propTypes = {
value: PropTypes.any,
user_building_like: PropTypes.bool
}
export default LikeDataEntry;

View File

@ -0,0 +1,111 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { sanitiseURL } from '../../helpers';
import { DataTitleCopyable } from './data-title';
class MultiDataEntry extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.string),
placeholder: PropTypes.string,
disabled: PropTypes.bool,
handleChange: PropTypes.func,
copy: PropTypes.bool,
toggleCopyAttribute: PropTypes.func,
copying: PropTypes.bool
};
constructor(props) {
super(props);
this.edit = this.edit.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
this.getValues = this.getValues.bind(this);
}
getValues() {
return (this.props.value && this.props.value.length)? this.props.value : [null];
}
edit(event) {
const editIndex = +event.target.dataset.index;
const editItem = event.target.value;
const oldValues = this.getValues();
const values = oldValues.map((item, i) => {
return i === editIndex ? editItem : item;
});
this.props.onChange(this.props.slug, values);
}
add(event) {
event.preventDefault();
const values = this.getValues().concat('');
this.props.onChange(this.props.slug, values);
}
remove(event){
const removeIndex = +event.target.dataset.index;
const values = this.getValues().filter((_, i) => {
return i !== removeIndex;
});
this.props.onChange(this.props.slug, values);
}
render() {
const values = this.getValues();
const props = this.props;
return <Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
/>
{
(props.mode === 'view')?
(props.value && props.value.length)?
<ul className="data-link-list">
{
props.value.map((item, index) => {
return <li
key={index}
className="form-control">
<a href={sanitiseURL(item)}>{item}</a>
</li>
})
}
</ul>
:'\u00A0'
: values.map((item, i) => (
<div className="input-group" key={i}>
<input className="form-control" type="text"
key={`${props.slug}-${i}`} name={`${props.slug}-${i}`}
data-index={i}
value={item || ''}
placeholder={props.placeholder}
disabled={props.disabled}
onChange={this.edit}
/>
<div className="input-group-append">
<button type="button" onClick={this.remove}
title="Remove"
data-index={i} className="btn btn-outline-dark"></button>
</div>
</div>
))
}
<button
type="button"
title="Add"
onClick={this.add}
disabled={props.mode === 'view'}
className="btn btn-outline-dark">+</button>
</Fragment>
}
}
export default MultiDataEntry;

View File

@ -0,0 +1,51 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
copy={props.copy}
/>
<input
className="form-control"
type="number"
id={props.slug}
name={props.slug}
value={props.value || ''}
step={props.step || 1}
max={props.max}
min={props.min || 0}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
/>
</Fragment>
);
}
NumericDataEntry.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any,
placeholder: PropTypes.string,
max: PropTypes.number,
min: PropTypes.number,
step: PropTypes.number,
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
})
}
export default NumericDataEntry;

View File

@ -0,0 +1,48 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
copy={props.copy}
/>
<select className="form-control"
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.mode === 'view' || props.disabled}
onChange={props.handleChange}>
<option value="">{props.placeholder}</option>
{
props.options.map(option => (
<option key={option} value={option}>{option}</option>
))
}
</select>
</Fragment>
);
}
SelectDataEntry.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any,
placeholder: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
})
}
export default SelectDataEntry;

View File

@ -0,0 +1,47 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled}
copy={props.copy}
/>
<textarea
className="form-control"
id={props.slug}
name={props.slug}
value={props.value || ''}
maxLength={props.max_length}
rows={5}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
></textarea>
</Fragment>
);
}
TextboxDataEntry.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any,
placeholder: PropTypes.string,
maxLength: PropTypes.number,
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
})
}
export default TextboxDataEntry;

View File

@ -0,0 +1,60 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip';
import DataTitle from './data-title';
const UPRNsDataEntry = (props) => {
const uprns = props.value || [];
const noParent = uprns.filter(uprn => uprn.parent_uprn == null);
const withParent = uprns.filter(uprn => uprn.parent_uprn != null);
return (
<Fragment>
<DataTitle
title={props.title}
tooltip={props.tooltip}
/>
<dd>
{
noParent.length?
<ul className="uprn-list">
{
noParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn}</li>
))
}
</ul>
: '\u00A0'
}
{
withParent.length?
<details>
<summary>Children</summary>
<ul className="uprn-list">
{
withParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn} (child of {uprn.parent_uprn})</li>
))
}
</ul>
</details>
: null
}
</dd>
</Fragment>
)
}
UPRNsDataEntry.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.shape({
uprn: PropTypes.string.isRequired,
parent_uprn: PropTypes.string
}))
}
export default UPRNsDataEntry;

View File

@ -0,0 +1,71 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import NumericDataEntry from './numeric-data-entry';
class YearDataEntry extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
year: PropTypes.number,
upper: PropTypes.number,
lower: PropTypes.number,
mode: PropTypes.string,
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
})
};
constructor(props) {
super(props);
this.state = {
year: props.year,
upper: props.upper,
lower: props.lower,
decade: Math.floor(props.year / 10) * 10,
century: Math.floor(props.year / 100) * 100
}
}
// TODO add dropdown for decade, century
// TODO roll in first/last year estimate
// TODO handle changes internally, reporting out date_year, date_upper, date_lower
render() {
const props = this.props;
return (
<Fragment>
<NumericDataEntry
title="Year built (best estimate)"
slug="date_year"
value={props.year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
// "type": "year_estimator"
/>
<NumericDataEntry
title="Latest possible start year"
slug="date_upper"
value={props.upper}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
tooltip="This should be the latest year in which building could have started."
/>
<NumericDataEntry
title="Earliest possible start date"
slug="date_lower"
value={props.lower}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
tooltip="This should be the earliest year in which building could have started."
/>
</Fragment>
)
}
}
export default YearDataEntry;

View File

@ -0,0 +1,259 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import ContainerHeader from './container-header';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
/**
* Shared functionality for view/edit forms
*
* See React Higher-order-component docs for the pattern
* - https://reactjs.org/docs/higher-order-components.html
*
* @param WrappedComponent
*/
const withCopyEdit = (WrappedComponent) => {
return class extends React.Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
slug: PropTypes.string,
intro: PropTypes.string,
help: PropTypes.string,
inactive: PropTypes.bool,
building_id: PropTypes.number,
children: PropTypes.node
};
constructor(props) {
super(props);
this.state = {
error: this.props.error || undefined,
like: this.props.like || undefined,
copying: false,
keys_to_copy: {},
building: this.props.building
};
this.handleChange = this.handleChange.bind(this);
this.handleCheck = this.handleCheck.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleUpdate = this.handleUpdate.bind(this);
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
toggleCopying() {
this.setState({
copying: !this.state.copying
})
}
/**
* Keep track of data to copy (accumulate while in "copying" state)
*
* @param {string} key
*/
toggleCopyAttribute(key: string) {
const keys = this.state.keys_to_copy;
if(this.state.keys_to_copy[key]){
delete keys[key];
} else {
keys[key] = true;
}
this.setState({
keys_to_copy: keys
})
}
updateBuildingState(key, value) {
const building = {...this.state.building};
building[key] = value;
this.setState({
building: building
});
}
/**
* Handle changes on typical inputs
* - e.g. input[type=text], radio, select, textare
*
* @param {*} event
*/
handleChange(event) {
const target = event.target;
let value = (target.value === '')? null : target.value;
const name = target.name;
// special transform - consider something data driven before adding 'else if's
if (name === 'location_postcode' && value !== null) {
value = value.toUpperCase();
}
this.updateBuildingState(name, value);
}
/**
* Handle changes on checkboxes
* - e.g. input[type=checkbox]
*
* @param {*} event
*/
handleCheck(event) {
const target = event.target;
const value = target.checked;
const name = target.name;
this.updateBuildingState(name, value);
}
/**
* Handle update directly
* - e.g. as callback from MultiTextInput where we set a list of strings
*
* @param {String} name
* @param {*} value
*/
handleUpdate(name: string, value: any) {
this.updateBuildingState(name, value);
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {*} event
*/
handleLike(event) {
event.preventDefault();
const like = event.target.checked;
fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: like})
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
this.updateBuildingState('likes_total', res.likes_total);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined})
fetch(`/api/buildings/${this.props.building.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state.building),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
render() {
if (this.state.mode === 'edit' && !this.props.user){
return <Redirect to="/sign-up.html" />
}
const values_to_copy = {}
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = this.state.building[key]
}
const data_string = JSON.stringify(values_to_copy);
const copy = {
copying: this.state.copying,
toggleCopying: this.toggleCopying,
toggleCopyAttribute: this.toggleCopyAttribute,
copyingKey: (key) => this.state.keys_to_copy[key]
}
return (
<section
id={this.props.slug}
className="data-section">
<ContainerHeader
{...this.props}
data_string={data_string}
copy={copy}
/>
{
this.props.building != undefined ?
<form
action={`/edit/${this.props.slug}/${this.props.building.building_id}`}
method="POST"
onSubmit={this.handleSubmit}>
<ErrorBox msg={this.state.error} />
{
(this.props.mode === 'edit' && this.props.inactive) ?
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/>
: null
}
<WrappedComponent
building={this.state.building}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onCheck={this.handleCheck}
onLike={this.handleLike}
onUpdate={this.handleUpdate}
/>
{
(this.props.mode === 'edit' && !this.props.inactive) ?
<Fragment>
<InfoBox
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
null :
<div className="buttons-container">
<button
type="submit"
className="btn btn-primary">
Save
</button>
</div>
}
</Fragment>
: null
}
</form>
: <InfoBox msg="Select a building to view data"></InfoBox>
}
</section>
);
}
}
}
export default withCopyEdit;

View File

@ -0,0 +1,77 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import MultiDataEntry from '../data-components/multi-data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import TextboxDataEntry from '../data-components/textbox-data-entry';
import YearDataEntry from '../data-components/year-data-entry';
/**
* Age view/edit section
*/
const AgeView = (props) => (
<Fragment>
<YearDataEntry
year={props.building.date_year}
upper={props.building.date_upper}
lower={props.building.date_lower}
mode={props.mode}
onChange={props.onChange}
/>
<NumericDataEntry
title="Facade year"
slug="facade_year"
value={props.building.facade_year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
tooltip="Best estimate"
/>
<SelectDataEntry
title="Source of information"
slug="date_source"
value={props.building.date_source}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip="Source for the main start date"
placeholder=""
options={[
"Survey of London",
"Pevsner Guides",
"Local history publication",
"National Heritage List for England",
"Historical map",
"Archive research",
"Expert knowledge of building",
"Other book",
"Other website",
"Other"
]}
/>
<TextboxDataEntry
title="Source details"
slug="date_source_detail"
value={props.building.date_source_detail}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip="References for date source (max 500 characters)"
/>
<MultiDataEntry
title="Text and Image Links"
slug="date_link"
value={props.building.date_link}
mode={props.mode}
copy={props.copy}
onChange={props.onUpdate}
tooltip="URL for age and date reference"
placeholder="https://..."
/>
</Fragment>
)
const AgeContainer = withCopyEdit(AgeView);
export default AgeContainer;

View File

@ -0,0 +1,32 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
/**
* Community view/edit section
*/
const CommunityView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">
<li>Is this a publicly owned building?</li>
{
// "slug": "community_publicly_owned",
// "type": "checkbox"
}
<li>Has this building ever been used for community or public services activities?</li>
{
// "slug": "community_past_public",
// "type": "checkbox"
}
<li>Would you describe this building as a community asset?</li>
{
// "slug": "community_asset",
// "type": "checkbox"
}
</ul>
</Fragment>
)
const CommunityContainer = withCopyEdit(CommunityView);
export default CommunityContainer;

View File

@ -0,0 +1,56 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry';
/**
* Construction view/edit section
*/
const ConstructionView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Construction system</li>
{
// "disabled": true,
// "slug": "construction_system",
// "type": "text"
}
<li>Primary materials</li>
{
// "disabled": true,
// "slug": "construction_primary_material",
// "type": "text"
}
<li>Secondary materials</li>
{
// "disabled": true,
// "slug": "construction_secondary_material",
// "type": "text"
}
<li>Roofing material</li>
{
// "disabled": true,
// "slug": "construction_roofing_material",
// "type": "text"
}
<li>Percentage of facade glazed</li>
{
// "disabled": true,
// "slug": "construction_facade_percentage_glazed",
// "type": "number",
// "step": 5
}
<li>BIM reference or link</li>
{
// "disabled": true,
// "slug": "construction_bim_reference",
// "type": "text",
// "placeholder": "https://..."
}
</ul>
</Fragment>
)
const ConstructionContainer = withCopyEdit(ConstructionView);
export default ConstructionContainer;

View File

@ -0,0 +1,21 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import LikeDataEntry from '../data-components/like-data-entry';
/**
* Like view/edit section
*/
const LikeView = (props) => (
<Fragment>
<LikeDataEntry
value={props.building.likes_total}
mode={props.mode}
onLike={props.onLike}
user_building_like={props.building_like}
/>
</Fragment>
)
const LikeContainer = withCopyEdit(LikeView);
export default LikeContainer;

View File

@ -0,0 +1,116 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry';
import UPRNsDataEntry from '../data-components/uprns-data-entry';
import InfoBox from '../../components/info-box';
const LocationView = (props) => (
<Fragment>
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
<DataEntry
title="Building Name"
slug="location_name"
value={props.building.location_name}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip="May not be needed for many buildings."
placeholder="Building name (if any)"
disabled={true}
/>
<NumericDataEntry
title="Building number"
slug="location_number"
value={props.building.location_number}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
/>
<DataEntry
title="Street"
slug="location_street"
value={props.building.location_street}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title="Address line 2"
slug="location_line_two"
value={props.building.location_line_two}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title="Town"
slug="location_town"
value={props.building.location_town}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Postcode"
slug="location_postcode"
value={props.building.location_postcode}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
maxLength={8}
/>
<DataEntry
title="TOID"
slug="ref_toid"
value={props.building.ref_toid}
mode={props.mode}
copy={props.copy}
tooltip="Ordnance Survey Topography Layer ID (to be filled automatically)"
onChange={props.onChange}
disabled={true}
/>
<UPRNsDataEntry
title="UPRNs"
value={props.building.uprns}
tooltip="Unique Property Reference Numbers (to be filled automatically)"
/>
<DataEntry
title="OSM ID"
slug="ref_osm_id"
value={props.building.ref_osm_id}
mode={props.mode}
copy={props.copy}
tooltip="OpenStreetMap feature ID"
maxLength={20}
onChange={props.onChange}
/>
<NumericDataEntry
title="Latitude"
slug="location_latitude"
value={props.building.location_latitude}
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder={51}
onChange={props.onChange}
/>
<NumericDataEntry
title="Longitude"
slug="location_longitude"
value={props.building.location_longitude}
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder={0}
onChange={props.onChange}
/>
</Fragment>
)
const LocationContainer = withCopyEdit(LocationView);
export default LocationContainer;

View File

@ -0,0 +1,201 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry';
import CheckboxDataEntry from '../data-components/checkbox-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
/**
* Planning view/edit section
*/
const PlanningView = (props) => (
<Fragment>
<DataEntry
title="Planning portal link"
slug="planning_portal_link"
value={props.building.planning_portal_link}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title="In a conservation area?"
slug="planning_in_conservation_area"
value={props.building.planning_in_conservation_area}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Conservation area name"
slug="planning_conservation_area_name"
value={props.building.planning_conservation_area_name}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title="Is listed on the National Heritage List for England?"
slug="planning_in_list"
value={props.building.planning_in_list}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="National Heritage List for England list id"
slug="planning_list_id"
value={props.building.planning_list_id}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title="National Heritage List for England list type"
slug="planning_list_cat"
value={props.building.planning_list_cat}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
options={[
"Listed Building",
"Scheduled Monument",
"World Heritage Site",
"Building Preservation Notice",
"None"
]}
/>
<SelectDataEntry
title="Listing grade"
slug="planning_list_grade"
value={props.building.planning_list_grade}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
options={[
"I",
"II*",
"II",
"None"
]}
/>
<DataEntry
title="Heritage at risk list id"
slug="planning_heritage_at_risk_id"
value={props.building.planning_heritage_at_risk_id}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="World heritage list id"
slug="planning_world_list_id"
value={props.building.planning_world_list_id}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title="In the Greater London Historic Environment Record?"
slug="planning_in_glher"
value={props.building.planning_in_glher}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Greater London Historic Environment Record link"
slug="planning_glher_url"
value={props.building.planning_glher_url}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title="In an Architectural Priority Area?"
slug="planning_in_apa"
value={props.building.planning_in_apa}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Architectural Priority Area name"
slug="planning_apa_name"
value={props.building.planning_apa_name}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Architectural Priority Area tier"
slug="planning_apa_tier"
value={props.building.planning_apa_tier}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title="Is locally listed?"
slug="planning_in_local_list"
value={props.building.planning_in_local_list}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Local list link"
slug="planning_local_list_url"
value={props.building.planning_local_list_url}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title="Within a historic area assessment?"
slug="planning_in_historic_area_assessment"
value={props.building.planning_in_historic_area_assessment}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Historic area assessment link"
slug="planning_historic_area_assessment_url"
value={props.building.planning_historic_area_assessment_url}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title="Is the building proposed for demolition?"
slug="planning_demolition_proposed"
value={props.building.planning_demolition_proposed}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<CheckboxDataEntry
title="Has the building been demolished?"
slug="planning_demolition_complete"
value={props.building.planning_demolition_complete}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title="Dates of construction and demolition of previous buildings on site"
slug="planning_demolition_history"
value={props.building.planning_demolition_history}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
</Fragment>
)
const PlanningContainer = withCopyEdit(PlanningView);
export default PlanningContainer

View File

@ -0,0 +1,141 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
/**
* Size view/edit section
*/
const SizeView = (props) => (
<Fragment>
<NumericDataEntry
title="Core storeys"
slug="size_storeys_core"
value={props.building.size_storeys_core}
mode={props.mode}
copy={props.copy}
tooltip="How many storeys between the pavement and start of roof?"
onChange={props.onChange}
step={1}
/>
<NumericDataEntry
title="Attic storeys"
slug="size_storeys_attic"
value={props.building.size_storeys_attic}
mode={props.mode}
copy={props.copy}
tooltip="How many storeys above start of roof?"
onChange={props.onChange}
step={1}
/>
<NumericDataEntry
title="Basement storeys"
slug="size_storeys_basement"
value={props.building.size_storeys_basement}
mode={props.mode}
copy={props.copy}
tooltip="How many storeys below pavement level?"
onChange={props.onChange}
step={1}
/>
<NumericDataEntry
title="Height to apex (m)"
slug="size_height_apex"
value={props.building.size_height_apex}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
/>
<NumericDataEntry
title="Height to eaves (m)"
slug="size_height_eaves"
value={props.building.size_height_eaves}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
/>
<NumericDataEntry
title="Ground floor area (m²)"
slug="size_floor_area_ground"
value={props.building.size_floor_area_ground}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
/>
<NumericDataEntry
title="Total floor area (m²)"
slug="size_floor_area_total"
value={props.building.size_floor_area_total}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
/>
<NumericDataEntry
title="Frontage Width (m)"
slug="size_width_frontage"
value={props.building.size_width_frontage}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
/>
<NumericDataEntry
title="Total area of plot (m²)"
slug="size_plot_area_total"
value={props.building.size_plot_area_total}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
disabled={true}
/>
<NumericDataEntry
title="FAR ratio (percentage of plot covered by building)"
slug="size_far_ratio"
value={props.building.size_far_ratio}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
disabled={true}
/>
<SelectDataEntry
title="Configuration (semi/detached, end/terrace)"
slug="size_configuration"
value={props.building.size_configuration}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
options={[
"Detached",
"Semi-detached",
"Terrace",
"End terrace",
"Block"
]}
/>
<SelectDataEntry
title="Roof shape"
slug="size_roof_shape"
value={props.building.size_roof_shape}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
options={[
"Flat",
"Pitched",
"Other"
]}
/>
</Fragment>
)
const SizeContainer = withCopyEdit(SizeView);
export default SizeContainer;

View File

@ -0,0 +1,23 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
/**
* Streetscape view/edit section
*/
const StreetscapeView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">
<li>Gardens</li>
<li>Trees</li>
<li>Green walls</li>
<li>Green roof</li>
<li>Proximity to parks and open greenspace</li>
<li>Building shading</li>
</ul>
</Fragment>
)
const StreetscapeContainer = withCopyEdit(StreetscapeView);
export default StreetscapeContainer;

View File

@ -0,0 +1,65 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry';
/**
* Sustainability view/edit section
*/
const SustainabilityView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Energy Performance Certificate (EPC) rating</li>
{
// "disabled": true,
// "slug": "sustainability_epc_band_current",
// "type": "text_list",
// "options": ["A", "B", "C", "D", "E", "F", "G"]
}
<li>Display Energy Certificate (DEC)</li>
{
// "disabled": true,
// "slug": "sustainability_dec_band_current",
// "type": "text_list",
// "options": ["A", "B", "C", "D", "E", "F", "G"]
}
<li>BREEAM Rating</li>
{
// "disabled": true,
// "slug": "sustainability_breeam_rating",
// "type": "number",
// "step": 1
}
<li>Year of last significant retrofit</li>
{
// "disabled": true,
// "slug": "sustainability_last_retrofit_date",
// "type": "number",
// "step": 1
}
<li>Embodied carbon estimation (for discussion)</li>
{
// "slug": "sustainability_embodied_carbon",
// "type": "text",
// "disabled": true
}
<li>Adaptability/repairability rating (for discussion)</li>
{
// "slug": "sustainability_adaptability_rating",
// "type": "text",
// "disabled": true
}
<li>Expected lifespan (for discussion)</li>
{
// "slug": "sustainability_expected_lifespan",
// "type": "number",
// "step": 1,
// "disabled": true
}
</ul>
</Fragment>
)
const SustainabilityContainer = withCopyEdit(SustainabilityView);
export default SustainabilityContainer;

View File

@ -0,0 +1,30 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry';
/**
* Team view/edit section
*/
const TeamView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Construction and design team (original building)</li>
{
// "disabled": true,
// "slug": "team_original",
// "type": "text"
}
<li>Construction and design team (significant additional works)</li>
{
// "disabled": true,
// "slug": "team_after_original",
// "type": "text_multi"
}
</ul>
</Fragment>
)
const TeamContainer = withCopyEdit(TeamView);
export default TeamContainer;

View File

@ -0,0 +1,23 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
/**
* Type view/edit section
*/
const TypeView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Original use (as constructed)</li>
{
// "disabled": true,
// "slug": "use_type_original",
// "type": "text"
}
</ul>
</Fragment>
)
const TypeContainer = withCopyEdit(TypeView);
export default TypeContainer;

View File

@ -0,0 +1,36 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
/**
* Use view/edit section
*/
const UseView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Single or multiple use?</li>
{
// "disabled": true,
// "slug": "use_multi",
// "type": "checkbox"
}
<li>Type of use/s</li>
{
// "disabled": true,
// "slug": "use_type",
// "type": "text_multi"
}
<li>Number of self-contained units</li>
{
// "disabled": true,
// "slug": "use_number_scu",
// "type": "number",
// "step": 1
}
</ul>
</Fragment>
)
const UseContainer = withCopyEdit(UseView);
export default UseContainer;

View File

@ -4,9 +4,10 @@ import { parse } from 'query-string';
import PropTypes from 'prop-types';
import Sidebar from './sidebar';
import CONFIG from './fields-config.json';
import InfoBox from './info-box';
import { sanitiseURL } from './helpers';
import InfoBox from '../components/info-box';
import { sanitiseURL } from '../helpers';
const CONFIG = [];
const MultiEdit = (props) => {
if (!props.user){
@ -16,9 +17,7 @@ const MultiEdit = (props) => {
if (cat === 'like') {
// special case for likes
return (
<Sidebar
title='Quick edit'
back={`/edit/${cat}.html`}>
<Sidebar>
<section className='data-section'>
<header className={`section-header view ${cat} active`}>
<a><h3 className="h3">Like me!</h3></a>
@ -26,8 +25,8 @@ const MultiEdit = (props) => {
<form className='buttons-container'>
<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='/edit/like.html' className='btn btn-secondary'>Back to edit</Link>
<Link to='/view/like' className='btn btn-secondary'>Back to view</Link>
<Link to='/edit/like' className='btn btn-secondary'>Back to edit</Link>
</form>
</section>
</Sidebar>
@ -38,26 +37,24 @@ const MultiEdit = (props) => {
const data = JSON.parse(q.data as string) // TODO: verify what happens when data is string[]
const title = sectionTitleFromCat(cat);
return (
<Sidebar
title='Quick edit'
back={`/edit/${cat}.html`}>
<Sidebar>
<section className='data-section'>
<header className={`section-header view ${cat} active`}>
<a><h3 className="h3">{title}</h3></a>
</header>
<dl className='data-list'>
<Fragment>
{
Object.keys(data).map((key => {
const label = fieldTitleFromSlug(key);
return <DataEntry key={key} label={label} value={data[key]}/>
}))
}
</dl>
</Fragment>
<form className='buttons-container'>
<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={`/edit/${cat}.html`} className='btn btn-secondary'>Back to edit</Link>
<Link to={`/view/${cat}`} className='btn btn-secondary'>Back to view</Link>
<Link to={`/edit/${cat}`} className='btn btn-secondary'>Back to edit</Link>
</form>
</section>
</Sidebar>

View File

@ -0,0 +1,210 @@
/**
* Sidebar layout
*/
.info-container {
order: 1;
padding: 0 0 2em;
background: #fff;
overflow-y: scroll;
height: 40%;
}
.info-container h2:first-child {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
margin-left: -0.1em;
padding: 0 0.75rem;
}
@media (min-width: 768px){
.info-container {
order: 0;
height: unset;
width: 23rem;
}
}
/**
* Data section headers
*/
.section-header {
display: block;
position: relative;
clear: both;
text-decoration: none;
color: #222;
padding: 0.75rem 0.25rem 0.5rem 0;
}
.section-header h2,
.section-header .icon-buttons {
display: inline-block;
}
.section-header .icon-buttons {
position: absolute;
right: 0;
top: 7px;
padding: 0.7rem 0.5rem 0.5rem 0;
}
.icon-buttons .icon-button {
margin-left: 7px;
}
/**
* Icon buttons
*/
.icon-button {
display: inline-block;
background-color: transparent;
font-size: 0.8333rem;
outline: none;
border: none;
color: #222;
vertical-align: top;
}
.icon-button:hover {
color: #222;
text-decoration: none;
cursor: pointer;
}
.icon-button.tooltip-hint {
padding: 0;
}
.icon-button svg {
background-color: transparent;
transition: background-color color 0.2s;
display: inline-block;
color: #222;
margin-top: 2px;
width: 30px;
height: 30px;
padding: 6px;
border-radius: 15px;
margin: 0 0.05rem;
display: inline-block;
vertical-align: middle;
}
.svg-inline--fa.fa-w-11,
.svg-inline--fa.fa-w-16,
.svg-inline--fa.fa-w-18,
.svg-inline--fa.fa-w-8 {
width: 30px;
}
.icon-button.edit:active svg,
.icon-button.edit:hover svg,
.icon-button.view:active svg,
.icon-button.view:hover svg {
color: rgb(11, 225, 225);
}
.icon-button.help,
.icon-button.copy {
margin-top: 4px;
}
.data-section label .icon-buttons .icon-button.copy {
margin-top: 0px;
}
.icon-button.copy:hover,
.icon-button.help:hover {
color: rgb(0, 81, 255)
}
.icon-button.tooltip-hint.active svg,
.icon-button.tooltip-hint:hover svg {
color: rgb(255, 11, 245);
}
.icon-button.close-edit svg {
margin-top: -1px;
}
.icon-button.close-edit:hover svg {
color: rgb(255, 72, 11)
}
.icon-button.save:hover svg {
color: rgb(11, 225, 72);
}
.data-title .icon-buttons {
float: right;
}
/* Back button */
.icon-button.back,
.icon-button.back:hover {
padding: 5px 1px;
background-color: transparent;
}
.icon-button.back:hover svg {
background-color: transparent;
}
/**
* Data list sections
*/
.data-section .h3 {
margin: 0;
}
.data-intro {
padding: 0 0.5rem 0 2.25rem;
margin-top: 0.5rem;
}
.data-section p {
font-size: 1rem;
margin: 0.5rem 0;
}
.data-section ul {
padding-left: 3.333rem;
font-size: 1rem;
}
.data-section li {
margin-bottom: 0.3rem;
}
.data-list {
margin: 0;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.data-section form {
padding: 0 0.75rem;
}
.data-list a {
color: #555;
}
.data-list a:focus,
.data-list a:active,
.data-list a:hover {
color: #222;
}
.data-list dt,
.data-section label {
display: block;
margin: 0.5em 0 0;
font-size: 0.8333rem;
font-weight: normal;
color: #555;
}
.data-section input,
.data-section textarea,
.data-section select {
margin: 0 0 0.5em 0;
}
.data-list dd {
margin: 0 0 0.5rem;
line-height: 1.5;
white-space: pre;
}
.data-list .no-data {
color: #999;
}
.data-list dd ul {
list-style: none;
padding-left: 0;
}
.data-section .data-link-list {
padding: 0;
list-style: none;
margin-bottom: 0;
margin-top: 0.5rem;
}
.data-link-list li {
border-color: #6c757d;
border-radius: 0;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import './sidebar.css';
const Sidebar = (props) => (
<div id="sidebar" className="info-container">
{ props.children }
</div>
);
Sidebar.propTypes = {
children: PropTypes.node
}
export default Sidebar;

View File

@ -5,7 +5,7 @@ import React from 'react'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble,
faAngleLeft, faCaretDown, faSearch } from '@fortawesome/free-solid-svg-icons'
faAngleLeft, faCaretDown, faSearch, faEye, faCaretUp } from '@fortawesome/free-solid-svg-icons'
library.add(
faQuestionCircle,
@ -16,7 +16,9 @@ library.add(
faCheckDouble,
faAngleLeft,
faCaretDown,
faSearch
faCaretUp,
faSearch,
faEye
);
const HelpIcon = () => (
@ -31,6 +33,10 @@ const EditIcon = () => (
<FontAwesomeIcon icon="paint-brush" />
);
const ViewIcon = () => (
<FontAwesomeIcon icon="eye" />
);
const CloseIcon = () => (
<FontAwesomeIcon icon="times" />
);
@ -51,8 +57,24 @@ const DownIcon = () => (
<FontAwesomeIcon icon="caret-down" />
);
const UpIcon = () => (
<FontAwesomeIcon icon="caret-up" />
);
const SearchIcon = () => (
<FontAwesomeIcon icon="search" />
);
export { HelpIcon, InfoIcon, EditIcon, CloseIcon, SaveIcon, SaveDoneIcon, BackIcon, DownIcon, SearchIcon };
export {
HelpIcon,
InfoIcon,
EditIcon,
ViewIcon,
CloseIcon,
SaveIcon,
SaveDoneIcon,
BackIcon,
DownIcon,
UpIcon,
SearchIcon
};

View File

@ -5,8 +5,7 @@
font-family: 'glacial_cl', sans-serif;
text-transform: uppercase;
}
.logo,
.logo.navbar-brand {
.logo {
display: block;
padding: 0;
color: #000;
@ -27,19 +26,7 @@
font-size: 0.625em;
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 {
position: relative;
top: -1px;
@ -60,51 +47,56 @@
height: 14px;
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-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-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-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-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-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-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-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-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-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-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-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-delay: -17s;
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import './logo.css';
interface LogoProps {
variant: 'default' | 'animated' | 'gray';
}
/**
* Logo
*
* As link to homepage, used in top header
*/
const Logo: React.FunctionComponent<LogoProps> = (props) => {
const variantClass = props.variant === 'default' ? '' : props.variant;
return (
<div className={`logo ${variantClass}`} >
<LogoGrid />
<h1 className="logotype">
<span>Colouring</span>
<span>London</span>
</h1>
</div>
);
};
const LogoGrid: React.FunctionComponent = () => (
<div className="grid">
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
</div>
)
export { Logo };

View File

@ -1,611 +0,0 @@
[
{
"title": "Location", "slug": "location",
"help": "https://pages.colouring.london/location",
"intro": "Where are the buildings? Address, location and cross-references.",
"fields": [
{
"title": "Building Name",
"slug": "location_name",
"type": "text",
"placeholder": "Building name (if any)",
"tooltip": "May not be needed for many buildings.",
"disabled": true
},
{
"title": "Building number",
"slug": "location_number",
"type": "number",
"step": 1
},
{
"title": "Street",
"slug": "location_street",
"type": "text",
"disabled": true
},
{
"title": "Address line 2",
"slug": "location_line_two",
"type": "text",
"disabled": true
},
{
"title": "Town",
"slug": "location_town",
"type": "text"
},
{
"title": "Postcode",
"slug": "location_postcode",
"type": "text",
"max_length": 8
},
{
"title": "TOID",
"slug": "ref_toid",
"type": "text",
"tooltip": "Ordnance Survey Topography Layer ID (to be filled automatically)",
"disabled": true
},
{
"title": "UPRNs",
"slug": "uprns",
"type": "uprn_list",
"tooltip": "Unique Property Reference Numbers (to be filled automatically)",
"disabled": true
},
{
"title": "OSM ID",
"slug": "ref_osm_id",
"type": "text",
"tooltip": "OpenStreetMap feature ID",
"max_length": 20
},
{
"title": "Latitude",
"slug": "location_latitude",
"type": "number",
"step": 0.0001, "placeholder": 51
},
{
"title": "Longitude",
"slug": "location_longitude",
"type": "number",
"step": 0.0001, "placeholder": 0
}
]
},
{
"inactive": true,
"title": "Land Use",
"slug": "use",
"intro": "How are buildings used, and how does use change over time? Coming soon…",
"help": "https://pages.colouring.london/use",
"fields": [
{
"title": "Single or multiple use?",
"disabled": true,
"slug": "use_multi",
"type": "checkbox"
},
{
"title": "Type of use/s",
"disabled": true,
"slug": "use_type",
"type": "text_multi"
},
{
"title": "Number of self-contained units",
"disabled": true,
"slug": "use_number_scu",
"type": "number",
"step": 1
}
]
},
{
"inactive": true,
"title": "Type",
"slug": "type",
"intro": "How were buildings previously used? Coming soon…",
"help": "https://www.pages.colouring.london/buildingtypology",
"fields": [
{
"title": "Original use (as constructed)",
"disabled": true,
"slug": "use_type_original",
"type": "text"
}
]
},
{
"title": "Age",
"slug": "age",
"help": "https://pages.colouring.london/age",
"intro": "Building age data can support energy analysis and help predict long-term change.",
"fields": [
{
"title": "Year built (best estimate)",
"slug": "date_year",
"type": "year_estimator"
},
{
"title": "Latest possible start year",
"slug": "date_upper",
"type": "number", "step": 1,
"tooltip": "This should be the latest year in which building could have started."
},
{
"title": "Earliest possible start date",
"slug": "date_lower",
"type": "number", "step": 1,
"tooltip": "This should be the earliest year in which building could have started."
},
{
"title": "Facade year",
"slug": "facade_year",
"type": "number", "step": 1,
"tooltip": "Best estimate"
},
{
"title": "Source of information",
"slug": "date_source",
"type": "text_list",
"tooltip": "Source for the main start date",
"options": [
"Survey of London",
"Pevsner Guides",
"Local history publication",
"National Heritage List for England",
"Historical map",
"Archive research",
"Expert knowledge of building",
"Other book",
"Other website",
"Other"
]
},
{
"title": "Source details",
"slug": "date_source_detail",
"type": "text_long",
"tooltip": "References for date source (max 500 characters)"
},
{
"title": "Text and Image Links",
"slug": "date_link",
"type": "text_multi",
"placeholder": "https://...",
"tooltip": "URL for age and date reference"
}
]
},
{
"title": "Size & Shape",
"slug": "size",
"intro": "How big are buildings?",
"help": "https://pages.colouring.london/shapeandsize",
"fields": [
{
"title": "Core storeys",
"slug": "size_storeys_core",
"type": "number",
"step": 1,
"tooltip": "How many storeys between the pavement and start of roof?"
},
{
"title": "Attic storeys",
"slug": "size_storeys_attic",
"type": "number",
"step": 1,
"tooltip": "How many storeys above start of roof?"
},
{
"title": "Basement storeys",
"slug": "size_storeys_basement",
"type": "number",
"step": 1,
"tooltip": "How many storeys below pavement level?"
},
{
"title": "Height to apex (m)",
"slug": "size_height_apex",
"type": "number",
"step": 0.1
},
{
"title": "Height to eaves (m)",
"slug": "size_height_eaves",
"type": "number",
"step": 0.1
},
{
"title": "Ground floor area (m²)",
"slug": "size_floor_area_ground",
"type": "number",
"step": 0.1
},
{
"title": "Total floor area (m²)",
"slug": "size_floor_area_total",
"type": "number",
"step": 0.1
},
{
"title": "Frontage Width (m)",
"slug": "size_width_frontage",
"type": "number",
"step": 0.1
},
{
"title": "Total area of plot (m²)",
"disabled": true,
"slug": "size_plot_area_total",
"type": "number",
"step": 0.1
},
{
"title": "FAR ratio (percentage of plot covered by building)",
"disabled": true,
"slug": "size_plot_area_total",
"type": "number",
"step": 0.1
},
{
"title": "Configuration (semi/detached, end/terrace)",
"disabled": true,
"slug": "size_configuration",
"type": "text_list",
"options": [
"Detached",
"Semi-detached",
"Terrace",
"End terrace",
"Block"
]
},
{
"title": "Roof shape",
"disabled": true,
"slug": "size_roof_shape",
"type": "text_list",
"options": [
"Flat",
"Pitched",
"Other"
]
}
]
},
{
"inactive": true,
"title": "Construction", "slug": "construction",
"intro": "How are buildings built? Coming soon…",
"help": "https://pages.colouring.london/construction",
"fields": [
{
"title": "Construction system",
"disabled": true,
"slug": "construction_system",
"type": "text"
},
{
"title": "Primary materials",
"disabled": true,
"slug": "construction_primary_material",
"type": "text"
},
{
"title": "Secondary materials",
"disabled": true,
"slug": "construction_secondary_material",
"type": "text"
},
{
"title": "Roofing material",
"disabled": true,
"slug": "construction_roofing_material",
"type": "text"
},
{
"title": "Percentage of facade glazed",
"disabled": true,
"slug": "construction_facade_percentage_glazed",
"type": "number",
"step": 5
},
{
"title": "BIM reference or link",
"disabled": true,
"slug": "construction_bim_reference",
"type": "text",
"placeholder": "https://..."
}
]
},
{
"inactive": true,
"title": "Team",
"slug": "team",
"intro": "Who built the buildings? Coming soon…",
"help": "https://pages.colouring.london/team",
"fields": [
{
"title": "Construction and design team (original building)",
"disabled": true,
"slug": "team_original",
"type": "text"
},
{
"title": "Construction and design team (significant additional works)",
"disabled": true,
"slug": "team_after_original",
"type": "text_multi"
}
]
},
{
"inactive": true,
"title": "Sustainability",
"slug": "sustainability",
"intro": "Are buildings energy efficient? Coming soon…",
"help": "https://pages.colouring.london/sustainability",
"fields": [
{
"title": "Energy Performance Certificate (EPC) rating",
"disabled": true,
"slug": "sustainability_epc_band_current",
"type": "text_list",
"options": ["A", "B", "C", "D", "E", "F", "G"]
},
{
"title": "Display Energy Certificate (DEC)",
"disabled": true,
"slug": "sustainability_dec_band_current",
"type": "text_list",
"options": ["A", "B", "C", "D", "E", "F", "G"]
},
{
"title": "BREEAM Rating",
"disabled": true,
"slug": "sustainability_breeam_rating",
"type": "number",
"step": 1
},
{
"title": "Year of last significant retrofit",
"disabled": true,
"slug": "sustainability_last_retrofit_date",
"type": "number",
"step": 1
},
{
"title": "Embodied carbon estimation (for discussion)",
"slug": "sustainability_embodied_carbon",
"type": "text",
"disabled": true
},
{
"title": "Adaptability/repairability rating (for discussion)",
"slug": "sustainability_adaptability_rating",
"type": "text",
"disabled": true
},
{
"title": "Expected lifespan (for discussion)",
"slug": "sustainability_expected_lifespan",
"type": "number",
"step": 1,
"disabled": true
}
]
},
{
"inactive": true,
"title": "Greenery",
"slug": "greenery",
"intro": "Is there greenery nearby? Coming soon…",
"help": "https://pages.colouring.london/greenery",
"fields": [
{
"title": "Gardens"
},
{
"title": "Trees"
},
{
"title": "Green walls"
},
{
"title": "Green roof"
},
{
"title": "Proximity to parks and open greenspace"
},
{
"title": "Building shading"
}
]
},
{
"inactive": true,
"title": "Community",
"slug": "community",
"intro": "How does this building work for the local community?",
"help": "https://pages.colouring.london/community",
"fields": [
{
"title": "Is this a publicly owned building?",
"slug": "community_publicly_owned",
"type": "checkbox"
},
{
"title": "Has this building ever been used for community or public services activities?",
"slug": "community_past_public",
"type": "checkbox"
},
{
"title": "Would you describe this building as a community asset?",
"slug": "community_asset",
"type": "checkbox"
}
]
},
{
"title": "Planning",
"slug": "planning",
"intro": "Planning controls relating to protection and reuse.",
"help": "https://pages.colouring.london/planning",
"fields": [
{
"title": "Planning portal link",
"slug": "planning_portal_link",
"type": "text",
"tooltip": ""
},
{
"title": "In a conservation area?",
"slug": "planning_in_conservation_area",
"type": "checkbox",
"tooltip": ""
},
{
"title": "Conservation area name",
"slug": "planning_conservation_area_name",
"type": "text",
"tooltip": ""
},
{
"title": "Is listed on the National Heritage List for England?",
"slug": "planning_in_list",
"type": "checkbox",
"tooltip": ""
},
{
"title": "National Heritage List for England list id",
"slug": "planning_list_id",
"type": "text",
"tooltip": ""
},
{
"title": "National Heritage List for England list type",
"slug": "planning_list_cat",
"type": "text_list",
"tooltip": "",
"options": [
"Listed Building",
"Scheduled Monument",
"World Heritage Site",
"Building Preservation Notice",
"None"
]
},
{
"title": "Listing grade",
"slug": "planning_list_grade",
"type": "text_list",
"tooltip": "",
"options": [
"I",
"II*",
"II",
"None"
]
},
{
"title": "Heritage at risk list id",
"slug": "planning_heritage_at_risk_id",
"type": "text",
"tooltip": ""
},
{
"title": "World heritage list id",
"slug": "planning_world_list_id",
"type": "text",
"tooltip": ""
},
{
"title": "In the Greater London Historic Environment Record?",
"slug": "planning_in_glher",
"type": "checkbox",
"tooltip": ""
},
{
"title": "Greater London Historic Environment Record link",
"slug": "planning_glher_url",
"type": "text",
"tooltip": ""
},
{
"title": "In an Architectural Priority Area?", "slug": "planning_in_apa",
"type": "checkbox",
"tooltip": ""
},
{
"title": "Architectural Priority Area name", "slug": "planning_apa_name",
"type": "text",
"tooltip": ""
},
{
"title": "Architectural Priority Area tier", "slug": "planning_apa_tier",
"type": "text",
"tooltip": ""
},
{
"title": "Is locally listed?",
"slug": "planning_in_local_list",
"type": "checkbox",
"tooltip": ""
},
{
"title": "Local list link",
"slug": "planning_local_list_url",
"type": "text",
"tooltip": ""
},
{
"title": "Within a historic area assessment?",
"slug": "planning_in_historic_area_assessment",
"type": "checkbox",
"tooltip": ""
},
{
"title": "Historic area assessment link",
"slug": "planning_historic_area_assessment_url",
"type": "text",
"tooltip": ""
},
{
"title": "Is the building proposed for demolition?",
"disabled": true,
"slug": "planning_demolition_proposed",
"type": "checkbox"
},
{
"title": "Has the building been demolished?",
"disabled": true,
"slug": "planning_demolition_complete",
"type": "checkbox"
},
{
"title": "Dates of construction and demolition of previous buildings on site",
"disabled": true,
"slug": "planning_demolition_history",
"type": "text"
}
]
},
{
"title": "Like Me!", "slug": "like",
"intro": "Do you like the building and think it contributes to the city?",
"help": "https://pages.colouring.london/likeme",
"fields": [
{
"title": "Number of likes",
"slug": "likes_total",
"type": "like"
}
]
}
]

View File

@ -2,14 +2,27 @@
* Main header
*/
.main-header {
display: block;
min-height: 79px;
text-decoration: none;
border-bottom: 3px solid #222;
border-bottom: 2px solid #222;
}
.main-header .navbar {
padding: 0.75em 0.5em 0.75em;
padding: 0.5em 0.5em 0.5em;
}
.main-header .navbar-brand {
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

@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import Logo from './logo';
import { Logo } from './components/logo';
import './header.css';
/**
@ -19,6 +19,7 @@ class Header extends React.Component<any, any> { // TODO: add proper types
super(props);
this.state = {collapseMenu: true};
this.handleClick = this.handleClick.bind(this);
this.handleNavigate = this.handleNavigate.bind(this);
}
handleClick() {
@ -27,22 +28,35 @@ class Header extends React.Component<any, any> { // TODO: add proper types
}));
}
handleNavigate() {
this.setState({
collapseMenu: true
});
}
render() {
return (
<header className="main-header">
<nav className="navbar navbar-light navbar-expand-md">
<span className="navbar-brand">
<Logo/>
<nav className="navbar navbar-light navbar-expand-lg">
<span className="navbar-brand align-self-start">
<NavLink to="/">
<Logo variant='animated'/>
</NavLink>
</span>
<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>
</button>
<div className={this.state.collapseMenu ? 'collapse navbar-collapse' : 'navbar-collapse'}>
<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">
<a className="nav-link" href="https://pages.colouring.london">
Hello
About
</a>
</li>
<li className="nav-item">
@ -50,28 +64,13 @@ class Header extends React.Component<any, any> { // TODO: add proper types
Data Categories
</a>
</li>
<li className="nav-item">
<NavLink to="/view/age.html" className="nav-link">
View Maps
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/edit/age.html" className="nav-link">
Add/Edit Data
</NavLink>
</li>
<li className="nav-item">
<a className="nav-link" href="https://pages.colouring.london/about">
More about
</a>
</li>
<li className="nav-item">
<a className="nav-link" href="https://pages.colouring.london/whoisinvolved">
Who&rsquo;s Involved?
</a>
</li>
<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
</a>
</li>
@ -84,20 +83,20 @@ class Header extends React.Component<any, any> { // TODO: add proper types
this.props.user?
(
<li className="nav-item">
<NavLink to="/my-account.html" className="nav-link">
My account (Logged in as {this.props.user.username})
<NavLink to="/my-account.html" className="nav-link" onClick={this.handleNavigate}>
Account <span className="shorten-username">({this.props.user.username})</span>
</NavLink>
</li>
):
(
<Fragment>
<li className="nav-item">
<NavLink to="/login.html" className="nav-link">
<NavLink to="/login.html" className="nav-link" onClick={this.handleNavigate}>
Log in
</NavLink>
</li>
<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
</NavLink>
</li>

View File

@ -1,80 +0,0 @@
/**
* Map legend
*/
.map-legend {
z-index: 1000;
position: absolute;
bottom: 50%;
right: 10px;
min-width: 12rem;
float: right;
background: white;
border-radius: 4px;
padding: 0.5rem 0rem 0.25rem;
border: 1px solid #fff;
box-shadow: 0px 0px 1px 1px #222222;
}
@media (min-width: 768px){
.map-legend {
bottom: 40px;
}
}
@media (min-width: 1020px){
.map-legend {
bottom: 24px;
}
}
.map-legend .h4,
.map-legend p,
.data-legend {
padding: 0 0.5rem;
}
.map-legend p {
margin: 0.25rem 0 0.5rem;
}
.data-legend {
max-height: 80px;
max-height: 20vh;
overflow-y: auto;
list-style: none;
margin-bottom: 0;
}
@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 {
display: inline-block;
width: 1.3rem;
height: 1.3rem;
text-indent: -30px;
margin: 0.125rem 0.5rem 0.125rem 0;
vertical-align: middle;
overflow: hidden;
}
.map-legend .data-intro {
padding: 0 0.5rem;
opacity: 0.5;
}
.expander-button {
float: right;
}
@media (min-height: 670px) and (min-width: 768px) {
.expander-button {
visibility: hidden;
}
}

View File

@ -1,37 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import './logo.css';
/**
* Logo
*/
const Logo = () => (
<Link to="/" className="logo navbar-brand" id="top">
<div className="grid">
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
</div>
<h1 className="logotype">
<span>Colouring</span>
<span>London</span>
</h1>
</Link>
);
export default Logo;

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

@ -0,0 +1,112 @@
/**
* Map legend
*/
.map-legend {
position: absolute;
bottom: 2.5rem;
right: 10px;
z-index: 1000;
min-width: 12rem;
max-height: 50%;
display: flex;
flex-direction: column;
background: white;
border-radius: 4px;
padding: 0.5rem 0rem 0.25rem;
border: 1px solid #fff;
box-shadow: 0px 0px 1px 1px #222222;
}
.map-legend * {
flex: 0;
}
.map-legend .logo {
display: none;
}
@media (min-width: 768px) {
.map-legend .logo {
display: block;
}
}
/* Prevent legend from overlapping with attribution */
@media (min-width: 706px){
.map-legend {
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 p,
.data-legend {
padding: 0 0.5rem;
}
.map-legend p {
margin: 0.25rem 0 0.5rem;
}
.data-legend {
overflow: auto;
list-style: none;
margin-bottom: 0;
flex: 1;
}
.data-legend .key {
display: inline-block;
width: 1.3rem;
height: 1.3rem;
text-indent: -30px;
margin: 0.125rem 0.5rem 0.125rem 0;
vertical-align: middle;
overflow: hidden;
}
.map-legend .data-intro {
padding: 0 0.5rem;
opacity: 0.5;
}
.expander-button {
display: inline-block;
position: absolute;
bottom: 1rem;
right: 0.5rem;
height: 1rem;
width: 1rem;
line-height: 0.5;
padding: 0;
}
.expander-button:focus,
.expander-button:active,
.expander-button:hover {
box-shadow: none;
}
@media (min-height: 670px) and (min-width: 880px) {
.expander-button {
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,6 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import './legend.css';
import { Logo } from '../components/logo';
import { DownIcon, UpIcon, BackIcon } from '../components/icons';
const LEGEND_CONFIG = {
location: {
@ -69,8 +71,8 @@ const LEGEND_CONFIG = {
title: 'Sustainability',
elements: []
},
greenery: {
title: 'Greenery',
streetscape: {
title: 'Streetscape',
elements: []
},
planning: {
@ -145,33 +147,21 @@ class Legend extends React.Component<any, any> { // TODO: add proper types
return (
<div className="map-legend">
<div className="logo">
<div className="grid">
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
</div>
</div>
<h3 className="h3 logotype">
<span>Colouring</span>
<span>London</span>
</h3>
</div>
<h4 className="h4">{ title } {elements.length?<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >^</button>:null}</h4>
<Logo variant='gray' />
<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?
<p>{details.description} </p>

View File

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

View File

@ -1,29 +1,45 @@
import React, { Component, Fragment } from 'react';
import { LatLngExpression } from 'leaflet';
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
import '../../node_modules/leaflet/dist/leaflet.css'
import '../../../node_modules/leaflet/dist/leaflet.css'
import './map.css'
import { HelpIcon } from './icons';
import { HelpIcon } from '../components/icons';
import Legend from './legend';
import { parseCategoryURL } from '../parse';
import { parseCategoryURL } from '../../parse';
import SearchBox from './search-box';
import ThemeSwitcher from './theme-switcher';
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
*/
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
building: PropTypes.object,
mode: PropTypes.string,
category: PropTypes.string,
revision_id: PropTypes.number,
selectBuilding: PropTypes.func,
colourBuilding: PropTypes.func,
match: PropTypes.object,
history: PropTypes.object
colourBuilding: PropTypes.func
};
constructor(props) {
@ -37,7 +53,6 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
this.handleClick = this.handleClick.bind(this);
this.handleLocate = this.handleLocate.bind(this);
this.themeSwitch = this.themeSwitch.bind(this);
this.getMode = this.getMode.bind(this);
}
handleLocate(lat, lng, zoom){
@ -45,21 +60,13 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
lat: lat,
lng: lng,
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) {
const mode = this.getMode()
const lat = e.latlng.lat
const lng = e.latlng.lng
const newCat = parseCategoryURL(this.props.match.url);
const mapCat = newCat || 'age';
const mode = this.props.mode;
const lat = e.latlng.lat;
const lng = e.latlng.lng;
fetch(
'/api/buildings/locate?lat='+lat+'&lng='+lng
).then(
@ -67,17 +74,15 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
).then(function(data){
if (data && data.length){
const building = data[0];
if (mode === 'multi') {
if (mode === 'multi-edit') {
// colour building directly
this.props.colourBuilding(building);
} else {
this.props.selectBuilding(building);
this.props.history.push(`/${mode}/${mapCat}/building/${building.building_id}.html`);
}
} else {
// deselect but keep/return to expected colour theme
this.props.selectBuilding(undefined);
this.props.history.push(`/${mode}/${mapCat}.html`);
}
}.bind(this)).catch(
(err) => console.error(err)
@ -91,52 +96,56 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
}
render() {
const position = [this.state.lat, this.state.lng];
const position: LatLngExpression = [this.state.lat, this.state.lng];
// baselayer
const key = OS_API_KEY
const tilematrixSet = 'EPSG:3857'
const key = OS_API_KEY;
const tilematrixSet = 'EPSG: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 attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.'
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 baseLayer = <TileLayer
url={baseUrl}
attribution={attribution}
/>;
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
// colour-data tiles
const isBuilding = /building/.test(this.props.match.url);
const isEdit = /edit/.test(this.props.match.url);
const cat = parseCategoryURL(this.props.match.url);
const cat = this.props.category;
const tilesetByCat = {
age: 'date_year',
size: 'size_storeys',
location: 'location',
like: 'likes',
planning: 'conservation_area',
}
};
const tileset = tilesetByCat[cat];
// pick revision id to bust browser cache
const rev = this.props.revision_id;
const dataLayer = tileset?
const dataLayer = tileset != undefined ?
<TileLayer
key={tileset}
url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`}
minZoom={9} />
url={`/tiles/${tileset}/{z}/{x}/{y}{r}.png?rev=${rev}`}
minZoom={9}
/>
: null;
// highlight
const geometryId = (this.props.building) ? this.props.building.geometry_id : undefined;
const highlight = `/tiles/highlight/{z}/{x}/{y}.png?highlight=${geometryId}`
const highlightLayer = (isBuilding && this.props.building) ?
const highlightLayer = this.props.building != undefined ?
<TileLayer
key={this.props.building.building_id}
url={highlight}
minZoom={14} />
url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.building.geometry_id}&base=${tileset}`}
minZoom={14}
zIndex={100}
/>
: null;
const baseUrl = (this.state.theme === 'light')?
'/tiles/base_light/{z}/{x}/{y}.png'
: '/tiles/base_night/{z}/{x}/{y}.png'
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
return (
<Fragment>
<div className="map-container">
<Map
center={position}
zoom={this.state.zoom}
@ -146,31 +155,32 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
zoomControl={false}
attributionControl={false}
onClick={this.handleClick}
detectRetina={true}
>
<TileLayer url={url} attribution={attribution} />
<TileLayer url={baseUrl} minZoom={14} />
{ baseLayer }
{ buildingBaseLayer }
{ dataLayer }
{ highlightLayer }
<ZoomControl position="topright" />
<AttributionControl prefix="" />
<AttributionControl prefix=""/>
</Map>
{
!isBuilding && this.props.match.url !== '/'? (
<div className="map-notice">
<HelpIcon /> {isEdit? 'Click a building to edit' : 'Click a building for details'}
</div>
) : null
}
{
this.props.match.url !== '/'? (
this.props.mode !== 'basic'? (
<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} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} isBuilding={isBuilding} />
<SearchBox onLocate={this.handleLocate} />
</Fragment>
) : 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;
max-width:80%;
}
.building.search-box {
top: 0.5rem;
}
.search-box form,
.search-box .search-box-results {
border-radius: 4px;
@ -46,11 +43,6 @@
margin-right: 0.1rem;
}
@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 */
.form-inline .form-control {
width: 200px;

View File

@ -2,14 +2,13 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './search-box.css';
import { SearchIcon } from './icons';
import { SearchIcon } from '../components/icons';
/**
* Search for location
*/
class SearchBox extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
onLocate: PropTypes.func,
isBuilding: PropTypes.bool
onLocate: PropTypes.func
};
constructor(props) {
@ -159,7 +158,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
</ul>
: null;
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">
<div onClick={this.state.smallScreen ? this.expandSearch : null}>
<SearchIcon/>

View File

@ -1,107 +0,0 @@
import React, { Fragment } from 'react';
import { NavLink, Redirect } from 'react-router-dom';
import PropTypes from 'prop-types';
import Sidebar from './sidebar';
import { EditIcon } from './icons';
import CONFIG from './fields-config.json';
const Overview = (props) => {
var dataLayer = 'age'; // always default
if (props.match && props.match.params && props.match.params.cat) {
dataLayer = props.match.params.cat;
}
if (props.mode === 'edit' && !props.user){
return <Redirect to="/sign-up.html" />
}
const title = (props.mode === 'view')? 'View maps' : 'Add or edit data';
const back = (props.mode === 'edit')? `/view/${dataLayer}.html` : undefined;
return (
<Sidebar title={title} back={back}>
{
CONFIG.map((dataGroup) => (
<OverviewSection {...dataGroup}
dataLayer={dataLayer} key={dataGroup.slug} mode={props.mode} />
))
}
</Sidebar>
);
}
Overview.propTypes = {
match: PropTypes.object,
mode: PropTypes.string,
user: PropTypes.object
}
const OverviewSection = (props) => {
const match = props.dataLayer === props.slug;
const inactive = props.inactive;
return (
<section className={(inactive? 'inactive ': '') + 'data-section legend'}>
<header className={`section-header ${props.mode} ${props.slug} ${(match? 'active' : '')}`}>
<NavLink
to={`/${props.mode}/${props.slug}.html`}
isActive={() => match}
title={(inactive)? 'Coming soon… Click the ? for more info.' :
(match)? '' : 'Show on map'}>
<h3 className="h3">{props.title}</h3>
</NavLink>
<nav className="icon-buttons">
{
props.help?
<a className="icon-button help" href={props.help}>
Info
</a>
: null
}
{
props.mode === 'view'?
<NavLink className="icon-button edit" title="Edit data"
to={`/edit/${props.slug}.html`}>
Edit
<EditIcon />
</NavLink>
: null
}
</nav>
</header>
{
(match && props.intro)?
(
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
{
props.fields.map((field) => {
return (<li key={field.slug}>{field.title}</li>)
})
}
</ul>
</Fragment>
)
: null
}
</section>
)
};
OverviewSection.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
intro: PropTypes.string,
help: PropTypes.string,
dataLayer: PropTypes.string,
mode: PropTypes.string,
inactive: PropTypes.bool,
fields: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
slug: PropTypes.string
}))
}
export default Overview;

View File

@ -33,11 +33,11 @@
}
.carousel-control.next {
right: -1em;
background-image: url('images/arrow-next.png');
background-image: url('../images/arrow-next.png');
}
.carousel-control.back {
left: -1em;
background-image: url('images/arrow-back.png');
background-image: url('../images/arrow-back.png');
}
.carousel-content {
padding: 0;
@ -52,31 +52,3 @@
.carousel.active .carousel-content li.current {
display: block;
}
/**
* Data categories
*/
.data-category-list {
padding: 0;
text-align: center;
list-style: none;
margin: 0 -0.75em;
}
.data-category-list li {
display: inline-block;
vertical-align: bottom;
width: 9em;
height: 9em;
margin: 0.375em;
padding: 0.1em;
}
.data-category-list .category {
text-align: center;
font-size: 1.5em;
margin: 1.4em 0 0.5em;
}
.data-category-list .description {
text-align: center;
font-size: 1em;
margin: 0;
}

View File

@ -1,7 +1,8 @@
import React from 'react';
import SupporterLogos from './supporter-logos';
import SupporterLogos from '../components/supporter-logos';
import './about.css';
import Categories from '../building/categories';
const AboutPage = () => (
<article>
@ -71,56 +72,7 @@ const AboutPage = () => (
research.
</p>
<ol className="data-category-list">
<li className="bold-yellow">
<h3 className="category">Location</h3>
<p className="description">Where is it?</p>
</li>
<li className="bright-yellow">
<h3 className="category">Use</h3>
<p className="description">How is it used?</p>
</li>
<li className="bold-orange">
<h3 className="category">Type</h3>
<p className="description">How was it first used?</p>
</li>
<li className="red">
<h3 className="category">Age</h3>
<p className="description">When was it built?</p>
</li>
<li className="pastel-pink">
<h3 className="category">Size</h3>
<p className="description">How big is it?</p>
</li>
<li className="pastel-purple">
<h3 className="category">Construction</h3>
<p className="description">How is it built?</p>
</li>
<li className="blue-grey">
<h3 className="category">Design/Build</h3>
<p className="description">Who built it?</p>
</li>
<li className="bright-green">
<h3 className="category">Street Front</h3>
<p className="description">How does it relate to the street?</p>
</li>
<li className="pastel-green">
<h3 className="category">Greenery</h3>
<p className="description">Is it near a tree or park?</p>
</li>
<li className="bright-blue">
<h3 className="category">Protection</h3>
<p className="description">Is it designated?</p>
</li>
<li className="pale-grey">
<h3 className="category">Demolitions</h3>
<p className="description">How many rebuilds on the site?</p>
</li>
<li className="pale-brown">
<h3 className="category">Like Me?</h3>
<p className="description">Do you like it?</p>
</li>
</ol>
<Categories building_id={2503371} mode="view" />
</div>
<hr/>
<div className="main-col">

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import InfoBox from './info-box';
import InfoBox from '../components/info-box';
const ContributorAgreementPage : React.SFC<any> = () => (
<article>

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import InfoBox from './info-box';
import InfoBox from '../components/info-box';
const PrivacyPolicyPage: React.SFC<any> = () => (
<article>

View File

@ -6,8 +6,10 @@
z-index: 10000;
top: 0;
width: 100%;
max-height: 100%;
border-radius: 0;
padding: 1.5em 2.5em 2.5em;
overflow-y: scroll;
}
.welcome-float.jumbotron {
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
build it.
</p>
<Link to="/view/age.html"
<Link to="/view/categories"
className="btn btn-outline-dark btn-lg btn-block">
Start Colouring Here!
</Link>

View File

@ -1,399 +0,0 @@
/**
* Sidebar layout
*/
.info-container {
position: absolute;
top: 50%;
left: 0;
right: 0;
bottom: 3rem;
padding: 0.25em 0em 2em;
background: #fff;
z-index: 1000;
overflow-y: auto;
}
.info-container h2:first-child {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
margin-left: -0.1em;
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){
.info-container {
top: 0;
left: 0;
width: 25rem;
bottom: 0;
}
.leaflet-right{
left: 25rem;
}
#root .leaflet-container .leaflet-control-attribution {
height: auto;
}
}
/**
* Sidebar main header
*/
.sidebar-header {
border-bottom: 6px solid;
border-image: linear-gradient(
to right,
#edc40b 0%, #edc40b 8.3%,
#f0ee0c 8.3%, #f0ee0c 16.6%,
#ff9100 16.6%, #ff9100 25%,
#ee5f63 25%, #ee5f63 33.3%,
#ee91bf 33.3%, #ee91bf 41.6%,
#aa7fa7 41.6%, #aa7fa7 50%,
#6f879c 50%, #6f879c 58.3%,
#5ec232 58.3%, #5ec232 66.6%,
#6dbb8b 66.6%, #6dbb8b 75%,
#65b7ff 75%, #65b7ff 83.3%,
#a1a3a9 83.3%, #a1a3a9 91.6%,
#9c896d 91.6%, #9c896d 100%
) 1;
}
.sidebar-header h2 {
margin: 0.45rem 0 0.6rem;
display: inline-block;
}
.sidebar-header .icon-button {
margin-left: 0.25rem;
margin-top: 0.3rem;
}
.sidebar-header .icon-button:hover {
background-color: #eee;
}
/**
* Data list section headers
*/
.section-header {
display: block;
position: relative;
clear: both;
text-decoration: none;
color: #222;
border-style: solid;
border-color: #fff;
border-top-color: #222;
border-width: 1px 0 4px 0;
padding-top: 4px;
}
.section-header h3 {
display: inline-block;
}
.section-header > a {
display: block;
padding: 0.6rem 0.5rem 0.5rem 2.25rem;
color: #222;
outline: none;
}
.section-header.active,
.section-header:hover {
color: #222;
text-decoration: none;
}
.data-section:first-of-type .section-header {
border-top-color: #fff;
}
.section-header.active.location {
border-bottom-color: #edc40b;
}
.section-header.location:hover > a::before,
.section-header.location.active > a::before {
color: #edc40b;
}
.section-header.active.use {
border-bottom-color: #f0ee0c;
}
.section-header.use:hover > a::before,
.section-header.use.active > a::before {
color: #f0ee0c;
}
.section-header.active.type {
border-bottom-color: #ff9100;
}
.section-header.type:hover > a::before,
.section-header.type.active > a::before {
color: #ff9100;
}
.section-header.active.age {
border-bottom-color: #ee5f63;
}
.section-header.age:hover > a::before,
.section-header.age.active > a::before {
color: #ee5f63;
}
.section-header.active.size {
border-bottom-color: #ee91bf;
}
.section-header.size:hover > a::before,
.section-header.size.active > a::before {
color: #ee91bf;
}
.section-header.active.construction {
border-bottom-color: #aa7fa7;
}
.section-header.construction:hover > a::before,
.section-header.construction.active > a::before {
color: #aa7fa7;
}
.section-header.active.team {
border-bottom-color: #6f879c;
}
.section-header.team:hover > a::before,
.section-header.team.active > a::before {
color: #6f879c;
}
.section-header.active.sustainability {
border-bottom-color: #5ec232;
}
.section-header.sustainability:hover > a::before,
.section-header.sustainability.active > a::before {
color: #5ec232;
}
.section-header.active.greenery {
border-bottom-color: #6dbb8b;
}
.section-header.greenery:hover > a::before,
.section-header.greenery.active > a::before {
color: #6dbb8b;
}
.section-header.active.community {
border-bottom-color: #65b7ff;
}
.section-header.community:hover > a::before,
.section-header.community.active > a::before {
color: #65b7ff;
}
.section-header.active.planning {
border-bottom-color: #a1a3a9;
}
.section-header.planning:hover > a::before,
.section-header.planning.active > a::before {
color: #a1a3a9;
}
.section-header.active.like {
border-bottom-color: #9c896d;
}
.section-header.like:hover > a::before,
.section-header.like.active > a::before {
color: #9c896d;
}
.section-header > a::before {
display: block;
position: absolute;
left: 0.55rem;
top: 0.5rem;
width: 1.2rem;
height: 1rem;
text-align: center;
color: #222264;
font-size: 1.2rem;
content: '\25B8';
}
.section-header:hover > a::before,
.section-header.active > a::before {
font-size: 1rem;
}
.section-header:hover > a::before,
.section-header.active > a::before {
top: 0.7rem;
content: '\25BC';
}
.data-section.inactive .section-header,
.data-section.inactive .section-header > a {
color: #777;
}
.data-section.inactive .section-header.active,
.data-section.inactive .section-header.active:hover > a,
.data-section.inactive .section-header.active > a {
color: #222;
}
.section-header .icon-buttons {
position: absolute;
top: 0;
right: 0;
padding: 0.7rem 0.5rem 0.5rem 0;
}
/**
* Icon buttons
*/
.icon-button {
display: inline-block;
background-color: transparent;
font-size: 0.8333rem;
outline: none;
border: none;
color: #222;
vertical-align: top;
}
.icon-button:hover {
color: #222;
text-decoration: none;
}
.icon-button.tooltip-hint {
padding: 0;
}
.icon-button svg {
background-color: transparent;
transition: background-color color 0.2s;
display: inline-block;
color: #222;
margin-top: 2px;
width: 30px;
height: 30px;
padding: 6px;
border-radius: 15px;
margin: 0 0.05rem;
display: inline-block;
vertical-align: middle;
}
.svg-inline--fa.fa-w-11,
.svg-inline--fa.fa-w-16,
.svg-inline--fa.fa-w-8 {
width: 30px;
}
.icon-button:hover svg {
background-color: #fff;
}
.icon-button.edit:hover svg {
color: rgb(11, 225, 225);
}
.icon-button.help,
.section-header .icon-button.help,
.section-header .icon-button.copy {
margin-top: 2px;
}
.section-header .icon-button.copy {
cursor: pointer;
margin-left: 5px;
}
.data-section label .icon-buttons .icon-button.copy {
margin-top: 0px;
}
.icon-button.copy:hover,
.icon-button.help:hover {
color: rgb(0, 81, 255)
}
.icon-button.tooltip-hint.active svg,
.icon-button.tooltip-hint:hover svg {
color: rgb(255, 11, 245);
}
.icon-button.close-edit svg {
margin-top: -1px;
}
.icon-button.close-edit:hover svg {
color: rgb(255, 72, 11)
}
.icon-button.save:hover svg {
color: rgb(11, 225, 72);
}
.section-header .icon-button {
float: right;
margin-top: -2px;
}
label .icon-buttons,
.data-list dt .icon-buttons {
float: right;
}
/* Back button */
.icon-button.back,
.icon-button.back:hover {
padding: 0;
background-color: transparent;
}
.icon-button.back:hover svg {
background-color: transparent;
}
/**
* Data list sections
*/
.data-section .h3 {
margin: 0;
}
.data-intro {
padding: 0 0.5rem 0 2.25rem;
margin-top: 0.5rem;
}
.data-section p {
font-size: 1rem;
margin: 0.5rem 0;
}
.data-section ul {
padding-left: 3.333rem;
font-size: 1rem;
}
.data-section li {
margin-bottom: 0.3rem;
}
.data-list {
margin: 0;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.data-section form {
padding: 0 0.75rem;
}
.data-list a {
color: #555;
}
.data-list a:focus,
.data-list a:active,
.data-list a:hover {
color: #222;
}
.data-list dt,
.data-section label {
display: block;
margin: 0.5em 0 0;
font-size: 0.8333rem;
font-weight: normal;
color: #555;
}
.data-section input,
.data-section textarea,
.data-section select {
margin: 0 0 0.5em 0;
}
.data-list dd {
margin: 0 0 0.5rem;
line-height: 1.5;
white-space: pre;
}
.data-list .no-data {
color: #999;
}
.data-list dd ul {
list-style: none;
padding-left: 0;
}

View File

@ -1,30 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import './sidebar.css';
import { BackIcon } from './icons';
const Sidebar = (props) => (
<div id="legend" className="info-container">
<header className="sidebar-header">
{
props.back?
<Link className="icon-button back" to={props.back}>
<BackIcon />
</Link>
: null
}
<h2 className="h2">{props.title}</h2>
</header>
{props.children}
</div>
);
Sidebar.propTypes = {
back: PropTypes.string,
title: PropTypes.string.isRequired,
children: PropTypes.node
}
export default Sidebar;

View File

@ -52,3 +52,43 @@
.pale-brown {
background-color: #918e6e;
}
/**
* Category colours
*/
.background-location {
background-color: #edc40b;
}
.background-use {
background-color: #f0ee0c;
}
.background-type {
background-color: #ff9100;
}
.background-age {
background-color: #ee5f63;
}
.background-size {
background-color: #ee91bf;
}
.background-construction {
background-color: #aa7fa7;
}
.background-team {
background-color: #6f879c;
}
.background-sustainability {
background-color: #5ec232;
}
.background-streetscape {
background-color: #6dbb8b;
}
.background-community {
background-color: #65b7ff;
}
.background-planning {
background-color: #a1a3a9;
}
.background-like {
background-color: #9c896d;
}

View File

@ -41,17 +41,3 @@ form .btn {
margin-right: 0;
text-align: center;
}
.btn.btn-half {
width: 100%;
margin-bottom: 0.25rem;
}
@media (min-width: 768px) {
.btn.btn-half {
width: 49%;
margin-left: 0;
margin-right: 2%;
}
.btn.btn-half:nth-child(2n) {
margin-right: 0;
}
}

View File

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

View File

@ -1,6 +1,6 @@
import React, { FormEvent, ChangeEvent } from 'react';
import InfoBox from './info-box';
import ErrorBox from './error-box';
import InfoBox from '../components/info-box';
import ErrorBox from '../components/error-box';
interface ForgottenPasswordState {
success: boolean;

View File

@ -2,9 +2,9 @@ import React, { Component } from 'react';
import { Redirect, Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import ErrorBox from './error-box';
import InfoBox from './info-box';
import SupporterLogos from './supporter-logos';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import SupporterLogos from '../components/supporter-logos';
class Login extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS

View File

@ -2,8 +2,8 @@ import React, { Component, FormEvent } from 'react';
import { Link, Redirect } from 'react-router-dom';
import PropTypes from 'prop-types';
import ErrorBox from './error-box';
import ConfirmationModal from './confirmation-modal';
import ConfirmationModal from '../components/confirmation-modal';
import ErrorBox from '../components/error-box';
class MyAccountPage extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
@ -116,7 +116,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
<ErrorBox msg={this.state.error} />
<form onSubmit={this.handleLogout}>
<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"/>
</div>
</form>

View File

@ -1,7 +1,7 @@
import React, { ChangeEvent, FormEvent } from 'react';
import { RouteComponentProps, Redirect } from 'react-router';
import ErrorBox from './error-box';
import ErrorBox from '../components/error-box';
import { Link } from 'react-router-dom';
interface PasswordResetState {

View File

@ -2,9 +2,9 @@ import React, { Component } from 'react';
import { Redirect, Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import ErrorBox from './error-box';
import InfoBox from './info-box';
import SupporterLogos from './supporter-logos';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import SupporterLogos from '../components/supporter-logos';
class SignUp extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
@ -94,6 +94,10 @@ class SignUp extends Component<any, any> { // TODO: add proper types
className="form-control" type="text"
value={this.state.username} onChange={this.handleChange}
placeholder="not-your-real-name" required
minLength={4}
maxLength={30}
pattern="\w+"
title="Usernames can contain only letters, numbers and the underscore"
/>
<label htmlFor="email">Email (optional)</label>
@ -115,6 +119,8 @@ class SignUp extends Component<any, any> { // TODO: add proper types
type={(this.state.show_password)? 'text': 'password'}
value={this.state.password} onChange={this.handleChange}
required
minLength={8}
maxLength={128}
/>
<div className="form-check">

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

@ -0,0 +1,131 @@
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" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@colouringlondon" />
<meta property="og:url" content="https://colouring.london" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Colouring London" />
<meta property="og:description" content="Colouring London is a citizen science platform collecting information on every building in London, to help make the city more sustainable. Were building it at The Bartlett Centre for Advanced Spatial Analysis, University College London." />
<meta property="og:locale" content="en_GB" />
<meta property="og:image" content="https://colouring.london/images/logo-cl.png" />
<link rel="manifest" href="site.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Colouring London">
<link rel="apple-touch-icon" href="icon-192x192.png">
<meta name="mobile-web-app-capable" content="yes">
<link rel="icon" sizes="192x192" href="icon-192x192.png">
<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}
*/
function parseBuildingURL(url) {
const re = /\/building\/([^/]+).html/;
const re = /\/(\d+)$/;
const matches = re.exec(url);
if (matches && matches.length >= 2) {

View File

@ -4,40 +4,25 @@
* - entry-point to shared React App
*
*/
import React from 'react';
import { StaticRouter } from 'react-router-dom';
import express from 'express';
import { renderToString } from 'react-dom/server';
import serialize from 'serialize-javascript';
import session from 'express-session';
import pgConnect from 'connect-pg-simple';
import App from './frontend/app';
import db from './db';
import { getUserById } from './api/services/user';
import {
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById
} from './api/services/building';
import tileserver from './tiles/tileserver';
import apiServer from './api/api';
import { parseBuildingURL } from './parse';
import frontendRoute from './frontendRoute';
// create server
const server = express();
// reference packed assets
const assets = require(process.env.RAZZLE_ASSETS_MANIFEST);
// disable header
server.disable('x-powered-by');
// serve static files
server.use(express.static(process.env.RAZZLE_PUBLIC_DIR));
// handle user sessions
const pgSession = pgConnect(session);
const sess: any = { // TODO: remove any
@ -59,106 +44,8 @@ if (server.get('env') === 'production') {
}
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('/api', apiServer);
// use the frontend route for anything else - will presumably show the 404 page
server.use(frontendRoute);
export default server;

View File

@ -1,171 +0,0 @@
/**
* Cache tiles (PNG images generated from database)
*
* Frequency of change:
* - base layer tiles change rarely - on changes to underlying geometry table
* - visualisation layer tiles change frequently - with almost any edit to the buildings table
*
* Cost of generation and storage:
* - low zoom tiles are more expensive to render, containing more features from the database
* - high zoom tiles are cheaper to rerender, and changes are more visible
* - there are many more high zoom tiles than low: 4 tiles at zoom level n+1 for each tile
* at zoom level n
*
*/
// Using node-fs package to patch fs
// for node >10 we could drop this in favour of fs.mkdir (which has recursive option)
// and then use stdlib `import fs from 'fs';`
import fs from 'node-fs';
import { getXYZ } from './tile';
// Use an environment variable to configure the cache location, somewhere we can read/write to.
const CACHE_PATH = process.env.TILECACHE_PATH
/**
* Get a tile from the cache
*
* @param {String} tileset
* @param {number} z zoom level
* @param {number} x
* @param {number} y
*/
function get(tileset, z, x, y) {
if (!shouldTryCache(tileset, z)) {
return Promise.reject(`Skip cache get ${tileset}/${z}/${x}/${y}`);
}
const location = cacheLocation(tileset, z, x, y);
return new Promise((resolve, reject) => {
fs.readFile(location.fname, (err, data) => {
if (err) {
reject(err)
} else {
resolve(data)
}
})
});
}
/**
* Put a tile in the cache
*
* @param {Buffer} im image data
* @param {String} tileset
* @param {number} z zoom level
* @param {number} x
* @param {number} y
*/
function put(im, tileset, z, x, y) {
if (!shouldTryCache(tileset, z)) {
return Promise.reject(`Skip cache put ${tileset}/${z}/${x}/${y}`);
}
const location = cacheLocation(tileset, z, x, y);
return new Promise((resolve, reject) => {
fs.writeFile(location.fname, im, 'binary', (err) => {
if (err && err.code === 'ENOENT') {
// recursively create tile directory if it didn't previously exist
fs.mkdir(location.dir, 0o755, true, (err) => {
if (err) {
reject(err);
} else {
// then write the file
fs.writeFile(location.fname, im, 'binary', (err) => {
(err)? reject(err): resolve()
});
}
});
} else {
(err)? reject(err): resolve()
}
});
})
}
/**
* Remove a single cached tile
*
* @param {String} tileset
* @param {number} z zoom level
* @param {number} x
* @param {number} y
*/
function remove(tileset, z, x, y) {
const location = cacheLocation(tileset, z, x, y)
return new Promise(resolve => {
fs.unlink(location.fname, (err) => {
if(err){
// pass
} else {
console.log('Expire cache', tileset, z, x, y)
}
resolve()
})
})
}
/**
* Remove all cached data-visualising tiles which intersect a bbox
* - initially called directly after edits; may be better on a worker process?
*
* @param {String} tileset
* @param {Array} bbox [w, s, e, n] in EPSG:3857 coordinates
*/
function removeAllAtBbox(bbox) {
// magic numbers for min/max zoom
const minZoom = 9;
const maxZoom = 18;
// magic list of tilesets - see tileserver, other cache rules
const tilesets = ['date_year', 'size_storeys', 'location', 'likes', 'conservation_area'];
let tileBounds;
const removePromises = [];
for (let ti = 0; ti < tilesets.length; ti++) {
const tileset = tilesets[ti];
for (let z = minZoom; z <= maxZoom; z++) {
tileBounds = getXYZ(bbox, z)
for (let x = tileBounds.minX; x <= tileBounds.maxX; x++){
for (let y = tileBounds.minY; y <= tileBounds.maxY; y++){
removePromises.push(remove(tileset, z, x, y))
}
}
}
}
Promise.all(removePromises)
}
/**
* Cache location for a tile
*
* @param {String} tileset
* @param {number} z zoom level
* @param {number} x
* @param {number} y
* @returns {object} { dir: <directory>, fname: <full filepath> }
*/
function cacheLocation(tileset, z, x, y) {
const dir = `${CACHE_PATH}/${tileset}/${z}/${x}`
const fname = `${dir}/${y}.png`
return {dir, fname}
}
/**
* Check rules for caching tiles
*
* @param {String} tileset
* @param {number} z zoom level
* @returns {boolean} whether to use the cache (or not)
*/
function shouldTryCache(tileset, z) {
if (tileset === 'date_year') {
// cache high zoom because of front page hits
return z <= 16
}
if (tileset === 'base_light' || tileset === 'base_night') {
// cache for higher zoom levels (unlikely to change)
return z <= 17
}
// else cache for lower zoom levels (change slowly)
return z <= 13
}
export { get, put, remove, removeAllAtBbox };

View File

@ -0,0 +1,135 @@
import { strictParseInt } from "../parse";
import { DataConfig } from "./renderers/datasourceRenderer";
const BUILDING_LAYER_DEFINITIONS = {
base_light: `(
SELECT
b.location_number as location_number,
g.geometry_geom
FROM
geometries as g,
buildings as b
WHERE
g.geometry_id = b.geometry_id
) as outline`,
base_night: `(
SELECT
b.location_number as location_number,
g.geometry_geom
FROM
geometries as g,
buildings as b
WHERE
g.geometry_id = b.geometry_id
) as outline`,
date_year: `(
SELECT
b.date_year as date_year,
g.geometry_geom
FROM
geometries as g,
buildings as b
WHERE
g.geometry_id = b.geometry_id
) as outline`,
size_storeys: `(
SELECT
(
coalesce(b.size_storeys_attic, 0) +
coalesce(b.size_storeys_core, 0)
) as size_storeys,
g.geometry_geom
FROM
geometries as g,
buildings as b
WHERE
g.geometry_id = b.geometry_id
) as outline`,
location: `(
SELECT
(
case when b.location_name is null then 0 else 1 end +
case when b.location_number is null then 0 else 1 end +
case when b.location_street is null then 0 else 1 end +
case when b.location_line_two is null then 0 else 1 end +
case when b.location_town is null then 0 else 1 end +
case when b.location_postcode is null then 0 else 1 end +
case when b.location_latitude is null then 0 else 1 end +
case when b.location_longitude is null then 0 else 1 end +
case when b.ref_toid is null then 0 else 1 end +
case when b.ref_osm_id is null then 0 else 1 end
) as location_info_count,
g.geometry_geom
FROM
geometries as g,
buildings as b
WHERE
g.geometry_id = b.geometry_id
) as location`,
likes: `(
SELECT
g.geometry_geom,
b.likes_total as likes
FROM
geometries as g,
buildings as b
WHERE
g.geometry_id = b.geometry_id
AND b.likes_total > 0
) as location`,
conservation_area: `(
SELECT
g.geometry_geom
FROM
geometries as g,
buildings as b
WHERE
g.geometry_id = b.geometry_id
AND b.planning_in_conservation_area = true
) as conservation_area`
};
const GEOMETRY_FIELD = 'geometry_geom';
function getBuildingsDataConfig(tileset: string, dataParams: any): DataConfig {
const table = BUILDING_LAYER_DEFINITIONS[tileset];
if(table == undefined) {
throw new Error('Invalid tileset requested');
}
return {
geometry_field: GEOMETRY_FIELD,
table: table
};
}
function getHighlightDataConfig(tileset: string, dataParams: any): DataConfig {
let { highlight, base } = dataParams;
highlight = strictParseInt(highlight);
base = base || 'default';
if(isNaN(highlight) || base.match(/^\w+$/) == undefined) {
throw new Error('Bad parameters for highlight layer');
}
return {
geometry_field: GEOMETRY_FIELD,
table: `(
SELECT
g.geometry_geom,
'${base}' as base_layer
FROM
geometries as g
WHERE
g.geometry_id = ${highlight}
) as highlight`
};
}
export {
BUILDING_LAYER_DEFINITIONS,
getBuildingsDataConfig,
getHighlightDataConfig
};

View File

@ -0,0 +1,78 @@
import { TileCache } from "./tileCache";
import { BoundingBox, TileParams } from "./types";
import { StitchRenderer } from "./renderers/stitchRenderer";
import { CachedRenderer } from "./renderers/cachedRenderer";
import { BranchingRenderer } from "./renderers/branchingRenderer";
import { WindowedRenderer } from "./renderers/windowedRenderer";
import { BlankRenderer } from "./renderers/blankRenderer";
import { DatasourceRenderer } from "./renderers/datasourceRenderer";
import { getBuildingsDataConfig, getHighlightDataConfig, BUILDING_LAYER_DEFINITIONS } from "./dataDefinition";
/**
* A list of all tilesets handled by the tile server
*/
const allTilesets = ['highlight', ...Object.keys(BUILDING_LAYER_DEFINITIONS)];
const buildingDataRenderer = new DatasourceRenderer(getBuildingsDataConfig);
const stitchRenderer = new StitchRenderer(undefined); // depends recurrently on cache, so parameter will be set later
/**
* Zoom level when we switch from rendering direct from database to instead composing tiles
* from the zoom level below - gets similar effect, with much lower load on Postgres
*/
const STITCH_THRESHOLD = 12;
const renderOrStitchRenderer = new BranchingRenderer(
({ z }) => z <= STITCH_THRESHOLD,
stitchRenderer, // refer to the prepared stitch renderer
buildingDataRenderer
);
const tileCache = new TileCache(
process.env.TILECACHE_PATH,
{
tilesets: ['date_year', 'size_storeys', 'location', 'likes', 'conservation_area'],
minZoom: 9,
maxZoom: 18,
scales: [1, 2]
},
({ tileset, z }: TileParams) => (tileset === 'date_year' && z <= 16) ||
((tileset === 'base_light' || tileset === 'base_night') && z <= 17) ||
z <= 13
);
const cachedRenderer = new CachedRenderer(
tileCache,
renderOrStitchRenderer
);
// set up stitch renderer to use the data renderer with caching
stitchRenderer.tileRenderer = cachedRenderer;
const highlightRenderer = new DatasourceRenderer(getHighlightDataConfig);
const highlightOrBuildingRenderer = new BranchingRenderer(
({ tileset }) => tileset === 'highlight',
highlightRenderer,
cachedRenderer
);
const blankRenderer = new BlankRenderer();
/**
* Hard-code extent so we can short-circuit rendering and return empty/transparent tiles outside the area of interest
* bbox in CRS epsg:3857 in form: [w, s, e, n]
*/
const EXTENT_BBOX: BoundingBox = [-61149.622628, 6667754.851372, 28128.826409, 6744803.375884];
const mainRenderer = new WindowedRenderer(
EXTENT_BBOX,
highlightOrBuildingRenderer,
blankRenderer
);
export {
allTilesets,
mainRenderer,
tileCache
};

View File

@ -0,0 +1,21 @@
import { Image } from "mapnik";
import sharp from 'sharp';
import { TileParams, TileRenderer } from "../types";
class BlankRenderer implements TileRenderer {
getTile(tileParams: TileParams): Promise<Image> {
return sharp({
create: {
width: 1,
height: 1,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
}).png().toBuffer();
}
}
export {
BlankRenderer
};

View File

@ -0,0 +1,23 @@
import { Image } from "mapnik";
import { TileParams, TileRenderer } from "../types";
class BranchingRenderer {
constructor(
public branchTestFn: (tileParams: TileParams) => boolean,
public trueResultTileRenderer: TileRenderer,
public falseResultTileRenderer: TileRenderer
) {}
getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
if(this.branchTestFn(tileParams)) {
return this.trueResultTileRenderer.getTile(tileParams, dataParams);
} else {
return this.falseResultTileRenderer.getTile(tileParams, dataParams);
}
}
}
export {
BranchingRenderer
};

View File

@ -0,0 +1,32 @@
import { Image } from "mapnik";
import { TileParams, TileRenderer } from "../types";
import { TileCache } from "../tileCache";
import { formatParams } from "../util";
class CachedRenderer implements TileRenderer {
constructor(
/** Cache to use for tiles */
public tileCache: TileCache,
/** Renderer to use when tile hasn't been cached yet */
public tileRenderer: TileRenderer
) {}
async getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
try {
const tile = await this.tileCache.get(tileParams);
return tile;
} catch(err) {
const im = await this.tileRenderer.getTile(tileParams, dataParams);
try {
await this.tileCache.put(im, tileParams);
} catch (err) {}
return im;
}
}
}
export {
CachedRenderer
};

View File

@ -0,0 +1,105 @@
import path from 'path';
import mapnik from "mapnik";
import { TileParams, TileRenderer } from "../types";
import { getBbox, TILE_SIZE } from "../util";
import { promisify } from "util";
interface DataConfig {
table: string;
geometry_field: string;
}
const TILE_BUFFER_SIZE = 64;
const PROJ4_STRING = '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over';
// connection details from environment variables
const DATASOURCE_CONFIG = {
'host': process.env.PGHOST,
'dbname': process.env.PGDATABASE,
'user': process.env.PGUSER,
'password': process.env.PGPASSWORD,
'port': process.env.PGPORT,
'extent': '-20005048.4188,-9039211.13765,19907487.2779,17096598.5401',
'srid': 3857,
'type': 'postgis'
};
// register datasource adapters for mapnik database connection
if (mapnik.register_default_input_plugins) {
mapnik.register_default_input_plugins();
}
// register fonts for text rendering
mapnik.register_default_fonts();
class DatasourceRenderer implements TileRenderer {
constructor(private getTableDefinitionFn: (tileset: string, dataParams: any) => DataConfig) {}
async getTile({tileset, z, x, y, scale}: TileParams, dataParams: any): Promise<mapnik.Image> {
const bbox = getBbox(z, x, y);
const tileSize = TILE_SIZE * scale;
let map = new mapnik.Map(tileSize, tileSize, PROJ4_STRING);
map.bufferSize = TILE_BUFFER_SIZE;
const layer = new mapnik.Layer('tile', PROJ4_STRING);
const dataSourceConfig = this.getTableDefinitionFn(tileset, dataParams);
const conf = Object.assign(dataSourceConfig, DATASOURCE_CONFIG);
const postgis = new mapnik.Datasource(conf);
layer.datasource = postgis;
layer.styles = [tileset];
const stylePath = path.join(__dirname, '..', 'map_styles', 'polygon.xml');
map = await promisify(map.load.bind(map))(stylePath, {strict: true});
map.add_layer(layer);
const im = new mapnik.Image(map.width, map.height);
map.extent = bbox;
const rendered = await promisify(map.render.bind(map))(im, {});
return await promisify(rendered.encode.bind(rendered))('png');
}
}
function promiseHandler(resolve, reject) {
return function(err, result) {
if(err) reject(err);
else resolve(result);
}
}
/**
* Utility function which promisifies a method of an object and binds it to the object
* This makes it easier to use callback-based object methods in a promise-based way
* @param obj Object containing the target method
* @param methodName Method name to promisify and return
*/
function promisifyMethod<T, F>(obj: T, methodName: keyof T);
/**
* @param methodGetter accessor function to get the method from the object
*/
function promisifyMethod<T, S>(obj: T, methodGetter: (o: T) => S);
function promisifyMethod<T, S>(obj: T, paramTwo: keyof T | ((o: T) => S)) {
let method;
if (typeof paramTwo === 'string') {
method = obj[paramTwo];
} else if (typeof paramTwo === 'function') {
method = paramTwo(obj);
}
if (typeof method === 'function') {
return promisify(method.bind(obj));
} else {
throw new Error(`Cannot promisify non-function property '${paramTwo}'`);
}
}
export {
DatasourceRenderer,
DataConfig
};

Some files were not shown because too many files have changed in this diff Show More