Merge branch 'develop'

This commit is contained in:
Maciej Ziarkowski 2019-10-01 17:12:55 +01:00
commit fb0358e5b1
124 changed files with 8032 additions and 7060 deletions

View File

@ -35,6 +35,12 @@
</Style> </Style>
<Style name="highlight"> <Style name="highlight">
<Rule> <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="#00ffffaa" stroke-width="4.5" />
<LineSymbolizer stroke="#00ffffff" stroke-width="2.5" /> <LineSymbolizer stroke="#00ffffff" stroke-width="2.5" />
</Rule> </Rule>

6930
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,20 +32,23 @@
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-leaflet": "^1.0.1", "react-leaflet": "^1.0.1",
"react-leaflet-universal": "^1.2.0", "react-leaflet-universal": "^1.2.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^5.0.1",
"serialize-javascript": "^1.7.0", "serialize-javascript": "^1.7.0",
"sharp": "^0.21.3" "sharp": "^0.22.1"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/express-session": "^1.15.13", "@types/express-session": "^1.15.13",
"@types/jest": "^24.0.17", "@types/jest": "^24.0.17",
"@types/mapbox__sphericalmercator": "^1.1.3",
"@types/node": "^8.10.52", "@types/node": "^8.10.52",
"@types/nodemailer": "^6.2.1", "@types/nodemailer": "^6.2.1",
"@types/prop-types": "^15.7.1", "@types/prop-types": "^15.7.1",
"@types/react": "^16.9.1", "@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5", "@types/react-dom": "^16.8.5",
"@types/react-leaflet": "^2.4.0",
"@types/react-router-dom": "^4.3.4", "@types/react-router-dom": "^4.3.4",
"@types/sharp": "^0.22.2",
"@types/webpack-env": "^1.14.0", "@types/webpack-env": "^1.14.0",
"babel-eslint": "^10.0.2", "babel-eslint": "^10.0.2",
"eslint": "^5.16.0", "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,11 +1,12 @@
import express from 'express'; import express from 'express';
import bodyParser from 'body-parser'; 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 { queryLocation } from './services/search';
import buildingsRouter from './routes/buildingsRouter'; import buildingsRouter from './routes/buildingsRouter';
import usersRouter from './routes/usersRouter'; import usersRouter from './routes/usersRouter';
import extractsRouter from './routes/extractsRouter';
const server = express.Router(); const server = express.Router();
@ -15,35 +16,7 @@ server.use(bodyParser.json());
server.use('/buildings', buildingsRouter); server.use('/buildings', buildingsRouter);
server.use('/users', usersRouter); 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 // POST user auth
server.post('/login', function (req, res) { server.post('/login', function (req, res) {

View File

@ -0,0 +1,28 @@
import express from 'express';
import * as dataExtractService from '../services/dataExtract';
import asyncController from '../routes/asyncController';
const getAllDataExtracts = asyncController(async function(req: express.Request, res: express.Response) {
try {
const dataExtracts = await dataExtractService.listDataExtracts();
res.send({ extracts: dataExtracts });
} catch (err) {
res.send({ error: 'Database error' });
}
});
const getDataExtract = asyncController(async function(req: express.Request, res: express.Response) {
try {
const extractId = req.params.extract_id;
const extract = await dataExtractService.getDataExtractById(extractId);
res.send({ extract: extract });
} catch (err) {
res.send({ error: 'Database error' });
}
});
export default {
getAllDataExtracts,
getDataExtract
};

View File

@ -5,6 +5,8 @@ import express from 'express';
import * as userService from '../services/user'; import * as userService from '../services/user';
import * as passwordResetService from '../services/passwordReset'; import * as passwordResetService from '../services/passwordReset';
import { TokenVerificationError } from '../services/passwordReset'; import { TokenVerificationError } from '../services/passwordReset';
import asyncController from '../routes/asyncController';
import { ValidationError } from '../validation';
function createUser(req, res) { function createUser(req, res) {
const user = req.body; const user = req.body;
@ -64,7 +66,7 @@ function deleteCurrentUser(req, res) {
}); });
} }
async function resetPassword(req: express.Request, res: express.Response) { const resetPassword = asyncController(async function(req: express.Request, res: express.Response) {
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) { if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) {
return res.send({ error: 'Expected an email address or password reset token in the request body' }); return res.send({ error: 'Expected an email address or password reset token in the request body' });
} }
@ -86,6 +88,8 @@ async function resetPassword(req: express.Request, res: express.Response) {
} catch (err) { } catch (err) {
if (err instanceof TokenVerificationError) { if (err instanceof TokenVerificationError) {
return res.send({ error: 'Could not verify token' }); return res.send({ error: 'Could not verify token' });
} else if (err instanceof ValidationError) {
return res.send({ error: err.message});
} }
throw err; throw err;
@ -93,7 +97,7 @@ async function resetPassword(req: express.Request, res: express.Response) {
return res.send({ success: true }); return res.send({ success: true });
} }
} });
function getWebAppOrigin() : string { function getWebAppOrigin() : string {
const origin = process.env.WEBAPP_ORIGIN; const origin = process.env.WEBAPP_ORIGIN;

View File

@ -0,0 +1,17 @@
import { Request, Response, NextFunction } from 'express';
/**
* A wrapper for controller functions that return a Promise, enabling them to be used with Express
* Without this wrapper, Promise rejections caused by an error in the controller will not be passed properly
* to subsequent middleware layers.
* @param fn the async controller function to be wrapped
* @returns controller function which handles async errors correctly
*/
function asyncController(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next))
.catch(next);
};
}
export default asyncController;

View File

@ -0,0 +1,10 @@
import express from 'express';
import extractController from '../controllers/extractController';
const router = express.Router();
router.get('/', extractController.getAllDataExtracts);
router.get('/:extract_id', extractController.getDataExtract);
export default router;

View File

@ -2,12 +2,6 @@ import express from 'express';
import userController from '../controllers/userController'; import userController from '../controllers/userController';
const asyncMiddleware = fn =>
(req, res, next) => {
Promise.resolve(fn(req, res, next))
.catch(next);
};
const router = express.Router(); const router = express.Router();
router.post('/', userController.createUser); router.post('/', userController.createUser);
@ -16,6 +10,6 @@ router.route('/me')
.get(userController.getCurrentUser) .get(userController.getCurrentUser)
.delete(userController.deleteCurrentUser); .delete(userController.deleteCurrentUser);
router.put('/password', asyncMiddleware(userController.resetPassword)); router.put('/password', userController.resetPassword);
export default router; export default router;

View File

@ -3,7 +3,8 @@
* *
*/ */
import db from '../../db'; 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 // 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. // JavaScript numerics are 64-bit double, giving only partial coverage.
@ -314,8 +315,8 @@ function privateQueryBuildingBBOX(buildingId){
function expireBuildingTileCache(buildingId) { function expireBuildingTileCache(buildingId) {
privateQueryBuildingBBOX(buildingId).then((bbox) => { privateQueryBuildingBBOX(buildingId).then((bbox) => {
const buildingBbox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin] const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
removeAllAtBbox(buildingBbox); tileCache.removeAllAtBbox(buildingBbox);
}) })
} }

View File

@ -0,0 +1,64 @@
import path from 'path';
import db from '../../db';
interface DataExtractRow {
extract_id: number;
extracted_on: Date;
extract_path: string;
}
interface DataExtract {
extract_id: number;
extracted_on: Date;
download_path: string;
}
async function listDataExtracts(): Promise<DataExtract[]> {
try {
const extractRecords = await db.manyOrNone<DataExtractRow>(
`SELECT
extract_id, extracted_on, extract_path
FROM bulk_extracts`
);
return extractRecords.map(getDataExtractFromRow);
} catch (err) {
console.error('Error:', err);
return undefined;
}
}
async function getDataExtractById(extractId: number): Promise<DataExtract> {
try {
const extractRecord = await db.one<DataExtractRow>(
`SELECT
extract_id, extracted_on, extract_path
FROM bulk_extracts
WHERE extract_id = $1
`, [extractId]);
return getDataExtractFromRow(extractRecord);
} catch (err) {
console.error('Error:', err);
return undefined;
}
}
function getDataExtractFromRow(er: DataExtractRow): DataExtract {
return {
extract_id: er.extract_id,
extracted_on: er.extracted_on,
download_path: getDownloadLinkForExtract(er)
}
}
function getDownloadLinkForExtract(extract: DataExtractRow): string {
const file_name = path.basename(extract.extract_path);
return `/downloads/${file_name}` // TODO: potentially move base path to env var
}
export {
listDataExtracts,
getDataExtractById
};

View File

@ -1,6 +1,6 @@
import * as nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
export const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.MAIL_SERVER_HOST, host: process.env.MAIL_SERVER_HOST,
port: parseInt(process.env.MAIL_SERVER_PORT), port: parseInt(process.env.MAIL_SERVER_PORT),
secure: false, secure: false,
@ -9,3 +9,7 @@ export const transporter = nodemailer.createTransport({
pass: process.env.MAIL_SERVER_PASSWORD pass: process.env.MAIL_SERVER_PASSWORD
} }
}); });
export {
transporter
};

View File

@ -5,6 +5,7 @@ import nodemailer from 'nodemailer';
import db from '../../db'; import db from '../../db';
import * as userService from './user'; import * as userService from './user';
import { transporter } from './email'; import { transporter } from './email';
import { validatePassword } from '../validation';
/** /**
@ -12,7 +13,7 @@ import { transporter } from './email';
* @param email the email address for which to generate a password reset token * @param email the email address for which to generate a password reset token
* @param siteOrigin the origin of the website, without a path element - e.g. https://beta.colouring.london * @param siteOrigin the origin of the website, without a path element - e.g. https://beta.colouring.london
*/ */
export async function sendPasswordResetToken(email: string, siteOrigin: string): Promise<void> { async function sendPasswordResetToken(email: string, siteOrigin: string): Promise<void> {
const user = await userService.getUserByEmail(email); const user = await userService.getUserByEmail(email);
// if no user found for email, do nothing // if no user found for email, do nothing
@ -23,7 +24,8 @@ export async function sendPasswordResetToken(email: string, siteOrigin: string):
await transporter.sendMail(message); await transporter.sendMail(message);
} }
export async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> { async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
validatePassword(newPassword);
const userId = await verifyPasswordResetToken(passwordResetToken); const userId = await verifyPasswordResetToken(passwordResetToken);
if (userId != undefined) { if (userId != undefined) {
await updatePasswordForUser(userId, newPassword); await updatePasswordForUser(userId, newPassword);
@ -33,7 +35,7 @@ export async function resetPassword(passwordResetToken: string, newPassword: str
} }
} }
export class TokenVerificationError extends Error { class TokenVerificationError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = "TokenVerificationError"; this.name = "TokenVerificationError";
@ -119,3 +121,8 @@ async function updatePasswordForUser(userId: string, newPassword: string): Promi
`, [newPassword, userId]); `, [newPassword, userId]);
} }
export {
sendPasswordResetToken,
resetPassword,
TokenVerificationError
};

View File

@ -2,15 +2,21 @@
* User data access * User data access
* *
*/ */
import { errors } from 'pg-promise';
import db from '../../db'; import db from '../../db';
import { validateUsername, ValidationError, validatePassword } from '../validation';
function createUser(user) { function createUser(user) {
if (!user.password || user.password.length < 8) { try {
return Promise.reject({ error: 'Password must be at least 8 characters' }) validateUsername(user.username);
} validatePassword(user.password);
if (user.password.length > 70) { } catch(err) {
return Promise.reject({ error: 'Password must be at most 70 characters' }) if (err instanceof ValidationError) {
return Promise.reject({ error: err.message });
} else throw err;
} }
return db.one( return db.one(
`INSERT `INSERT
INTO users ( INTO users (
@ -64,8 +70,12 @@ function authUser(username, password) {
return { error: 'Username or password not recognised' } return { error: 'Username or password not recognised' }
} }
}).catch(function (err) { }).catch(function (err) {
console.error(err); if (err instanceof errors.QueryResultError) {
console.error(`Authentication failed for user ${username}`);
return { error: 'Username or password not recognised' }; 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 * Client-side entry point to shared frontend React App
* *
*/ */
import BrowserRouter from 'react-router-dom/BrowserRouter'; import { BrowserRouter } from 'react-router-dom';
import React from 'react'; import React from 'react';
import { hydrate } from 'react-dom'; import { hydrate } from 'react-dom';

View File

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

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

View File

@ -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 { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble, import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble,
faAngleLeft, faCaretDown, faSearch } from '@fortawesome/free-solid-svg-icons' faAngleLeft, faCaretDown, faSearch, faEye, faCaretUp } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
faQuestionCircle, faQuestionCircle,
@ -16,7 +16,9 @@ library.add(
faCheckDouble, faCheckDouble,
faAngleLeft, faAngleLeft,
faCaretDown, faCaretDown,
faSearch faCaretUp,
faSearch,
faEye
); );
const HelpIcon = () => ( const HelpIcon = () => (
@ -31,6 +33,10 @@ const EditIcon = () => (
<FontAwesomeIcon icon="paint-brush" /> <FontAwesomeIcon icon="paint-brush" />
); );
const ViewIcon = () => (
<FontAwesomeIcon icon="eye" />
);
const CloseIcon = () => ( const CloseIcon = () => (
<FontAwesomeIcon icon="times" /> <FontAwesomeIcon icon="times" />
); );
@ -51,8 +57,24 @@ const DownIcon = () => (
<FontAwesomeIcon icon="caret-down" /> <FontAwesomeIcon icon="caret-down" />
); );
const UpIcon = () => (
<FontAwesomeIcon icon="caret-up" />
);
const SearchIcon = () => ( const SearchIcon = () => (
<FontAwesomeIcon icon="search" /> <FontAwesomeIcon icon="search" />
); );
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; font-family: 'glacial_cl', sans-serif;
text-transform: uppercase; text-transform: uppercase;
} }
.logo, .logo {
.logo.navbar-brand {
display: block; display: block;
padding: 0; padding: 0;
color: #000; color: #000;
@ -27,19 +26,7 @@
font-size: 0.625em; font-size: 0.625em;
letter-spacing: 0; letter-spacing: 0;
} }
.map-legend .logo {
padding: 0 0.5rem 0.5rem;
}
.map-legend .logo .logotype {
font-size: 1.9rem;
margin-bottom: -2px;
}
.map-legend .logo .row .cell {
background-color: #ccc;
height: 12px;
width: 12px;
animation: none !important;
}
.logo .grid { .logo .grid {
position: relative; position: relative;
top: -1px; top: -1px;
@ -60,51 +47,56 @@
height: 14px; height: 14px;
margin: 0 3px 0 0; margin: 0 3px 0 0;
} }
.logo .row:nth-child(1) .cell:nth-child(1) {
.logo.gray .cell {
background-color: #ccc;
}
.logo.animated .row:nth-child(1) .cell:nth-child(1) {
animation: pulse 87s infinite; animation: pulse 87s infinite;
animation-delay: -1.5s; animation-delay: -1.5s;
} }
.logo .row:nth-child(1) .cell:nth-child(2) { .logo.animated .row:nth-child(1) .cell:nth-child(2) {
animation: pulse 52s infinite; animation: pulse 52s infinite;
animation-delay: -0.5s; animation-delay: -0.5s;
} }
.logo .row:nth-child(1) .cell:nth-child(3) { .logo.animated .row:nth-child(1) .cell:nth-child(3) {
animation: pulse 79s infinite; animation: pulse 79s infinite;
animation-delay: -6s; animation-delay: -6s;
} }
.logo .row:nth-child(1) .cell:nth-child(4) { .logo.animated .row:nth-child(1) .cell:nth-child(4) {
animation: pulse 55s infinite; animation: pulse 55s infinite;
animation-delay: -10s; animation-delay: -10s;
} }
.logo .row:nth-child(2) .cell:nth-child(1) { .logo.animated .row:nth-child(2) .cell:nth-child(1) {
animation: pulse 64s infinite; animation: pulse 64s infinite;
animation-delay: -7.2s; animation-delay: -7.2s;
} }
.logo .row:nth-child(2) .cell:nth-child(2) { .logo.animated .row:nth-child(2) .cell:nth-child(2) {
animation: pulse 98s infinite; animation: pulse 98s infinite;
animation-delay: -25s; animation-delay: -25s;
} }
.logo .row:nth-child(2) .cell:nth-child(3) { .logo.animated .row:nth-child(2) .cell:nth-child(3) {
animation: pulse 51s infinite; animation: pulse 51s infinite;
animation-delay: -35s; animation-delay: -35s;
} }
.logo .row:nth-child(2) .cell:nth-child(4) { .logo.animated .row:nth-child(2) .cell:nth-child(4) {
animation: pulse 76s infinite; animation: pulse 76s infinite;
animation-delay: -20s; animation-delay: -20s;
} }
.logo .row:nth-child(3) .cell:nth-child(1) { .logo.animated .row:nth-child(3) .cell:nth-child(1) {
animation: pulse 52s infinite; animation: pulse 52s infinite;
animation-delay: -3.5s; animation-delay: -3.5s;
} }
.logo .row:nth-child(3) .cell:nth-child(2) { .logo.animated .row:nth-child(3) .cell:nth-child(2) {
animation: pulse 79s infinite; animation: pulse 79s infinite;
animation-delay: -8.5s; animation-delay: -8.5s;
} }
.logo .row:nth-child(3) .cell:nth-child(3) { .logo.animated .row:nth-child(3) .cell:nth-child(3) {
animation: pulse 65s infinite; animation: pulse 65s infinite;
animation-delay: -4s; animation-delay: -4s;
} }
.logo .row:nth-child(3) .cell:nth-child(4) { .logo.animated .row:nth-child(3) .cell:nth-child(4) {
animation: pulse 54s infinite; animation: pulse 54s infinite;
animation-delay: -17s; animation-delay: -17s;
} }

View File

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

View File

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

View File

@ -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 PropTypes from 'prop-types';
import './legend.css'; import './legend.css';
import { Logo } from '../components/logo';
import { DownIcon, UpIcon, BackIcon } from '../components/icons';
const LEGEND_CONFIG = { const LEGEND_CONFIG = {
location: { location: {
@ -69,8 +71,8 @@ const LEGEND_CONFIG = {
title: 'Sustainability', title: 'Sustainability',
elements: [] elements: []
}, },
greenery: { streetscape: {
title: 'Greenery', title: 'Streetscape',
elements: [] elements: []
}, },
planning: { planning: {
@ -145,33 +147,21 @@ class Legend extends React.Component<any, any> { // TODO: add proper types
return ( return (
<div className="map-legend"> <div className="map-legend">
<div className="logo"> <Logo variant='gray' />
<div className="grid"> <h4 className="h4">
<div className="row"> { title }
<div className="cell"></div> </h4>
<div className="cell"></div> {
<div className="cell"></div> elements.length > 0 ?
<div className="cell"></div> <button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >
</div> {
<div className="row"> this.state.collapseList ?
<div className="cell"></div> <UpIcon /> :
<div className="cell"></div> <DownIcon />
<div className="cell"></div> }
<div className="cell"></div> </button> :
</div> null
<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>
{ {
details.description? details.description?
<p>{details.description} </p> <p>{details.description} </p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 { .carousel-control.next {
right: -1em; right: -1em;
background-image: url('images/arrow-next.png'); background-image: url('../images/arrow-next.png');
} }
.carousel-control.back { .carousel-control.back {
left: -1em; left: -1em;
background-image: url('images/arrow-back.png'); background-image: url('../images/arrow-back.png');
} }
.carousel-content { .carousel-content {
padding: 0; padding: 0;
@ -52,31 +52,3 @@
.carousel.active .carousel-content li.current { .carousel.active .carousel-content li.current {
display: block; 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 React from 'react';
import SupporterLogos from './supporter-logos'; import SupporterLogos from '../components/supporter-logos';
import './about.css'; import './about.css';
import Categories from '../building/categories';
const AboutPage = () => ( const AboutPage = () => (
<article> <article>
@ -71,56 +72,7 @@ const AboutPage = () => (
research. research.
</p> </p>
<ol className="data-category-list"> <Categories building_id={2503371} mode="view" />
<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>
</div> </div>
<hr/> <hr/>
<div className="main-col"> <div className="main-col">

View File

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

View File

@ -0,0 +1,90 @@
import React, { FunctionComponent } from 'react';
import { dateReviver } from '../../helpers';
interface ExtractViewModel {
extract_id: number;
extracted_on: Date;
download_path: string;
}
interface DataExtractsState {
extracts: ExtractViewModel[];
latestExtract: ExtractViewModel;
previousExtracts: ExtractViewModel[];
}
export default class DataExtracts extends React.Component<{}, DataExtractsState> {
constructor(props) {
super(props);
this.state = {
extracts: undefined,
latestExtract: undefined,
previousExtracts: undefined
};
}
async componentDidMount() {
const res = await fetch('/api/extracts');
const data = JSON.parse(await res.text(), dateReviver);
const extracts = (data.extracts as ExtractViewModel[])
.sort((a, b) => a.extracted_on.valueOf() - b.extracted_on.valueOf())
.reverse();
this.setState({ extracts: extracts, latestExtract: extracts[0], previousExtracts: extracts.slice(1) });
}
render() {
return (
<article>
<section className="main-col">
<h1 className="h2">Open data extracts</h1>
<p>Choose one of the links below to download an archive containing the open data collected on the Colouring London platform</p>
{
this.state.extracts == undefined ?
<p>Loading extracts...</p> :
(
this.state.extracts.length === 0 ?
<p>No extracts available</p> :
null
)
}
{
this.state.latestExtract != undefined ?
<div>
<h1 className="h3">Latest extract</h1>
<ExtractDownloadLink {...this.state.latestExtract} />
</div> :
null
}
{
this.state.previousExtracts && this.state.previousExtracts.length > 0 ?
(<div>
<h1 className="h3">Older extracts</h1>
<ul>
{
this.state.previousExtracts.map(e =>
<li>
<ExtractDownloadLink {...e} />
</li>
)
}
</ul>
</div>) :
null
}
</section>
</article>
);
}
}
const ExtractDownloadLink: FunctionComponent<ExtractViewModel> = (props) => (
<p><a href={props.download_path}>Extracted on {props.extracted_on.toDateString()}</a></p>
);

View File

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

View File

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

View File

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

View File

@ -1,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 { .pale-brown {
background-color: #918e6e; 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; margin-right: 0;
text-align: center; 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,18 +1,32 @@
/** /**
* Main Layout * Main Layout
*/ */
html, body, #root {
height: 100%;
}
body {
margin: 0;
}
#root {
display: flex;
flex-direction: column;
overflow: hidden;
}
main { main {
position: relative; position: relative;
min-height: 35rem; flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
} }
@media(min-width: 768px) { @media(min-width: 768px) {
main { main {
position: absolute; flex-direction: row;
top: 79px; /* matches 79px .main-header */
bottom: 0;
left: 0;
right: 0;
min-height: auto;
} }
} }
/** /**

View File

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

View File

@ -2,9 +2,9 @@ import React, { Component } from 'react';
import { Redirect, Link } from 'react-router-dom'; import { Redirect, Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ErrorBox from './error-box'; import ErrorBox from '../components/error-box';
import InfoBox from './info-box'; import InfoBox from '../components/info-box';
import SupporterLogos from './supporter-logos'; import SupporterLogos from '../components/supporter-logos';
class Login extends Component<any, any> { // TODO: add proper types class Login extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS 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 { Link, Redirect } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ErrorBox from './error-box'; import ConfirmationModal from '../components/confirmation-modal';
import ConfirmationModal from './confirmation-modal'; import ErrorBox from '../components/error-box';
class MyAccountPage extends Component<any, any> { // TODO: add proper types class MyAccountPage extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS 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} /> <ErrorBox msg={this.state.error} />
<form onSubmit={this.handleLogout}> <form onSubmit={this.handleLogout}>
<div className="buttons-container"> <div className="buttons-container">
<Link to="/edit/age.html" className="btn btn-warning">Start colouring</Link> <Link to="/edit/age" className="btn btn-warning">Start colouring</Link>
<input className="btn btn-secondary" type="submit" value="Log out"/> <input className="btn btn-secondary" type="submit" value="Log out"/>
</div> </div>
</form> </form>

View File

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

View File

@ -2,9 +2,9 @@ import React, { Component } from 'react';
import { Redirect, Link } from 'react-router-dom'; import { Redirect, Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ErrorBox from './error-box'; import ErrorBox from '../components/error-box';
import InfoBox from './info-box'; import InfoBox from '../components/info-box';
import SupporterLogos from './supporter-logos'; import SupporterLogos from '../components/supporter-logos';
class SignUp extends Component<any, any> { // TODO: add proper types class SignUp extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS 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" className="form-control" type="text"
value={this.state.username} onChange={this.handleChange} value={this.state.username} onChange={this.handleChange}
placeholder="not-your-real-name" required 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> <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'} type={(this.state.show_password)? 'text': 'password'}
value={this.state.password} onChange={this.handleChange} value={this.state.password} onChange={this.handleChange}
required required
minLength={8}
maxLength={128}
/> />
<div className="form-check"> <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;

15
app/src/helpers.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* A function to be passed to JSON.parse as a JSON reviver, in order to transform date values
* (which don't have a native JSON representation and therefore are serialized as strings)
* back to a JavaScript Date object.
* This works by first checking if a string value complies with a date format
* and then converting to a Date if and only if that's the case
* @param name name of the JSON field to revive
* @param value value of the JSON field to revive
*/
export function dateReviver(name, value) {
if (typeof value === "string" && /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d.\d\d\dZ$/.test(value)) {
return new Date(value);
}
return value;
}

View File

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

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