Merge branch 'develop' into feature/342-bulk-extract
This commit is contained in:
commit
6783a00e21
@ -35,6 +35,12 @@
|
||||
</Style>
|
||||
<Style name="highlight">
|
||||
<Rule>
|
||||
<Filter>[base_layer] = 'location' or [base_layer] = 'conservation_area'</Filter>
|
||||
<LineSymbolizer stroke="#ff0000aa" stroke-width="4.5" />
|
||||
<LineSymbolizer stroke="#ff0000ff" stroke-width="2.5" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ElseFilter />
|
||||
<LineSymbolizer stroke="#00ffffaa" stroke-width="4.5" />
|
||||
<LineSymbolizer stroke="#00ffffff" stroke-width="2.5" />
|
||||
</Rule>
|
||||
|
7048
app/package-lock.json
generated
7048
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,7 @@
|
||||
"@mapbox/sphericalmercator": "^1.1.0",
|
||||
"body-parser": "^1.19.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"connect-pg-simple": "^5.0.0",
|
||||
"connect-pg-simple": "^6.0.1",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.16.2",
|
||||
"leaflet": "^1.5.1",
|
||||
@ -32,20 +32,23 @@
|
||||
"react-dom": "^16.9.0",
|
||||
"react-leaflet": "^1.0.1",
|
||||
"react-leaflet-universal": "^1.2.0",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-router-dom": "^5.0.1",
|
||||
"serialize-javascript": "^1.7.0",
|
||||
"sharp": "^0.21.3"
|
||||
"sharp": "^0.22.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/express-session": "^1.15.13",
|
||||
"@types/jest": "^24.0.17",
|
||||
"@types/mapbox__sphericalmercator": "^1.1.3",
|
||||
"@types/node": "^8.10.52",
|
||||
"@types/nodemailer": "^6.2.1",
|
||||
"@types/prop-types": "^15.7.1",
|
||||
"@types/react": "^16.9.1",
|
||||
"@types/react-dom": "^16.8.5",
|
||||
"@types/react-leaflet": "^2.4.0",
|
||||
"@types/react-router-dom": "^4.3.4",
|
||||
"@types/sharp": "^0.22.2",
|
||||
"@types/webpack-env": "^1.14.0",
|
||||
"babel-eslint": "^10.0.2",
|
||||
"eslint": "^5.16.0",
|
||||
|
BIN
app/public/icon-192x192.png
Normal file
BIN
app/public/icon-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 KiB |
BIN
app/public/images/logo-cl.png
Normal file
BIN
app/public/images/logo-cl.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
14
app/public/site.webmanifest
Normal file
14
app/public/site.webmanifest
Normal 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"
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
|
||||
import { authUser, createUser, getUserById, getNewUserAPIKey, deleteUser, logout } from './services/user';
|
||||
import { authUser, getNewUserAPIKey, logout } from './services/user';
|
||||
import { queryLocation } from './services/search';
|
||||
|
||||
import buildingsRouter from './routes/buildingsRouter';
|
||||
@ -18,35 +18,6 @@ server.use('/buildings', buildingsRouter);
|
||||
server.use('/users', usersRouter);
|
||||
server.use('/extracts', extractsRouter);
|
||||
|
||||
// GET own user info
|
||||
server.route('/users/me')
|
||||
.get(function (req, res) {
|
||||
if (!req.session.user_id) {
|
||||
res.send({ error: 'Must be logged in' });
|
||||
return
|
||||
}
|
||||
|
||||
getUserById(req.session.user_id).then(function (user) {
|
||||
res.send(user);
|
||||
}).catch(function (error) {
|
||||
res.send(error);
|
||||
});
|
||||
})
|
||||
.delete((req, res) => {
|
||||
if (!req.session.user_id) {
|
||||
return res.send({ error: 'Must be logged in' });
|
||||
}
|
||||
console.log(`Deleting user ${req.session.user_id}`);
|
||||
|
||||
deleteUser(req.session.user_id).then(
|
||||
() => logout(req.session)
|
||||
).then(() => {
|
||||
res.send({ success: true });
|
||||
}).catch(err => {
|
||||
res.send({ error: err });
|
||||
});
|
||||
})
|
||||
|
||||
// POST user auth
|
||||
server.post('/login', function (req, res) {
|
||||
authUser(req.body.username, req.body.password).then(function (user: any) { // TODO: remove any
|
||||
|
@ -6,6 +6,7 @@ import * as userService from '../services/user';
|
||||
import * as passwordResetService from '../services/passwordReset';
|
||||
import { TokenVerificationError } from '../services/passwordReset';
|
||||
import asyncController from '../routes/asyncController';
|
||||
import { ValidationError } from '../validation';
|
||||
|
||||
function createUser(req, res) {
|
||||
const user = req.body;
|
||||
@ -73,8 +74,7 @@ const resetPassword = asyncController(async function(req: express.Request, res:
|
||||
if(req.body.email != undefined) {
|
||||
// first stage: send reset token to email address
|
||||
|
||||
// this relies on the API being on the same hostname as the frontend
|
||||
const { origin } = new URL(req.protocol + '://' + req.headers.host);
|
||||
const origin = getWebAppOrigin();
|
||||
await passwordResetService.sendPasswordResetToken(req.body.email, origin);
|
||||
|
||||
return res.status(202).send({ success: true });
|
||||
@ -88,6 +88,8 @@ const resetPassword = asyncController(async function(req: express.Request, res:
|
||||
} catch (err) {
|
||||
if (err instanceof TokenVerificationError) {
|
||||
return res.send({ error: 'Could not verify token' });
|
||||
} else if (err instanceof ValidationError) {
|
||||
return res.send({ error: err.message});
|
||||
}
|
||||
|
||||
throw err;
|
||||
@ -97,6 +99,14 @@ const resetPassword = asyncController(async function(req: express.Request, res:
|
||||
}
|
||||
});
|
||||
|
||||
function getWebAppOrigin() : string {
|
||||
const origin = process.env.WEBAPP_ORIGIN;
|
||||
if (origin == undefined) {
|
||||
throw new Error('WEBAPP_ORIGIN not defined');
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
export default {
|
||||
createUser,
|
||||
getCurrentUser,
|
||||
|
@ -3,7 +3,8 @@
|
||||
*
|
||||
*/
|
||||
import db from '../../db';
|
||||
import { removeAllAtBbox } from '../../tiles/cache';
|
||||
import { tileCache } from '../../tiles/rendererDefinition';
|
||||
import { BoundingBox } from '../../tiles/types';
|
||||
|
||||
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
|
||||
// JavaScript numerics are 64-bit double, giving only partial coverage.
|
||||
@ -314,8 +315,8 @@ function privateQueryBuildingBBOX(buildingId){
|
||||
|
||||
function expireBuildingTileCache(buildingId) {
|
||||
privateQueryBuildingBBOX(buildingId).then((bbox) => {
|
||||
const buildingBbox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin]
|
||||
removeAllAtBbox(buildingBbox);
|
||||
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
||||
tileCache.removeAllAtBbox(buildingBbox);
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ import nodemailer from 'nodemailer';
|
||||
import db from '../../db';
|
||||
import * as userService from './user';
|
||||
import { transporter } from './email';
|
||||
import { validatePassword } from '../validation';
|
||||
|
||||
|
||||
/**
|
||||
@ -24,6 +25,7 @@ async function sendPasswordResetToken(email: string, siteOrigin: string): Promis
|
||||
}
|
||||
|
||||
async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
|
||||
validatePassword(newPassword);
|
||||
const userId = await verifyPasswordResetToken(passwordResetToken);
|
||||
if (userId != undefined) {
|
||||
await updatePasswordForUser(userId, newPassword);
|
||||
|
@ -2,15 +2,21 @@
|
||||
* User data access
|
||||
*
|
||||
*/
|
||||
import { errors } from 'pg-promise';
|
||||
|
||||
import db from '../../db';
|
||||
import { validateUsername, ValidationError, validatePassword } from '../validation';
|
||||
|
||||
function createUser(user) {
|
||||
if (!user.password || user.password.length < 8) {
|
||||
return Promise.reject({ error: 'Password must be at least 8 characters' })
|
||||
}
|
||||
if (user.password.length > 70) {
|
||||
return Promise.reject({ error: 'Password must be at most 70 characters' })
|
||||
try {
|
||||
validateUsername(user.username);
|
||||
validatePassword(user.password);
|
||||
} catch(err) {
|
||||
if (err instanceof ValidationError) {
|
||||
return Promise.reject({ error: err.message });
|
||||
} else throw err;
|
||||
}
|
||||
|
||||
return db.one(
|
||||
`INSERT
|
||||
INTO users (
|
||||
@ -64,8 +70,12 @@ function authUser(username, password) {
|
||||
return { error: 'Username or password not recognised' }
|
||||
}
|
||||
}).catch(function (err) {
|
||||
console.error(err);
|
||||
return { error: 'Username or password not recognised' };
|
||||
if (err instanceof errors.QueryResultError) {
|
||||
console.error(`Authentication failed for user ${username}`);
|
||||
return { error: 'Username or password not recognised' };
|
||||
}
|
||||
console.error('Error:', err);
|
||||
return { error: 'Database error' };
|
||||
})
|
||||
}
|
||||
|
||||
|
25
app/src/api/validation.ts
Normal file
25
app/src/api/validation.ts
Normal 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
|
||||
};
|
@ -2,7 +2,7 @@
|
||||
* Client-side entry point to shared frontend React App
|
||||
*
|
||||
*/
|
||||
import BrowserRouter from 'react-router-dom/BrowserRouter';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { hydrate } from 'react-dom';
|
||||
|
||||
|
@ -1,29 +1,31 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Route, Switch, Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { parse } from 'query-string';
|
||||
|
||||
import '../../node_modules/bootstrap/dist/css/bootstrap.min.css';
|
||||
import './app.css';
|
||||
|
||||
import AboutPage from './about';
|
||||
import BuildingEdit from './building-edit';
|
||||
import BuildingView from './building-view';
|
||||
import MultiEdit from './multi-edit';
|
||||
import ColouringMap from './map';
|
||||
import Header from './header';
|
||||
import Overview from './overview';
|
||||
import Login from './login';
|
||||
import MyAccountPage from './my-account';
|
||||
import SignUp from './signup';
|
||||
import Welcome from './welcome';
|
||||
import { parseCategoryURL } from '../parse';
|
||||
import PrivacyPolicyPage from './privacy-policy';
|
||||
import ContributorAgreementPage from './contributor-agreement';
|
||||
import ForgottenPassword from './forgotten-password';
|
||||
import PasswordReset from './password-reset';
|
||||
import AboutPage from './pages/about';
|
||||
import ContributorAgreementPage from './pages/contributor-agreement';
|
||||
import PrivacyPolicyPage from './pages/privacy-policy';
|
||||
import DataExtracts from './data-extracts';
|
||||
|
||||
import Login from './user/login';
|
||||
import MyAccountPage from './user/my-account';
|
||||
import SignUp from './user/signup';
|
||||
import ForgottenPassword from './user/forgotten-password';
|
||||
import PasswordReset from './user/password-reset';
|
||||
|
||||
import MapApp from './map-app';
|
||||
|
||||
|
||||
interface AppProps {
|
||||
user?: any;
|
||||
building?: any;
|
||||
building_like?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* App component
|
||||
*
|
||||
@ -36,35 +38,28 @@ import DataExtracts from './data-extracts';
|
||||
* map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in
|
||||
* child components to navigate without a full page reload.
|
||||
*/
|
||||
class App extends React.Component<any, any> { // TODO: add proper types
|
||||
class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
user: PropTypes.object,
|
||||
building: PropTypes.object,
|
||||
building_like: PropTypes.bool
|
||||
}
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: Readonly<AppProps>) {
|
||||
super(props);
|
||||
// set building revision id, default 0
|
||||
const rev = (props.building)? +props.building.revision_id : 0;
|
||||
|
||||
this.state = {
|
||||
user: props.user,
|
||||
building: props.building,
|
||||
building_like: props.building_like,
|
||||
revision_id: rev
|
||||
user: props.user
|
||||
};
|
||||
this.login = this.login.bind(this);
|
||||
this.updateUser = this.updateUser.bind(this);
|
||||
this.logout = this.logout.bind(this);
|
||||
this.selectBuilding = this.selectBuilding.bind(this);
|
||||
this.colourBuilding = this.colourBuilding.bind(this);
|
||||
this.increaseRevision = this.increaseRevision.bind(this);
|
||||
}
|
||||
|
||||
login(user) {
|
||||
if (user.error) {
|
||||
this.logout();
|
||||
return
|
||||
return;
|
||||
}
|
||||
this.setState({user: user});
|
||||
}
|
||||
@ -77,197 +72,42 @@ class App extends React.Component<any, any> { // TODO: add proper types
|
||||
this.setState({user: undefined});
|
||||
}
|
||||
|
||||
increaseRevision(revisionId) {
|
||||
revisionId = +revisionId;
|
||||
// bump revision id, only ever increasing
|
||||
if (revisionId > this.state.revision_id){
|
||||
this.setState({revision_id: revisionId})
|
||||
}
|
||||
}
|
||||
|
||||
selectBuilding(building) {
|
||||
this.increaseRevision(building.revision_id);
|
||||
// get UPRNs and update
|
||||
fetch(`/api/buildings/${building.building_id}/uprns.json`, {
|
||||
method: 'GET',
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then((res) => {
|
||||
if (res.error) {
|
||||
console.error(res);
|
||||
} else {
|
||||
building.uprns = res.uprns;
|
||||
this.setState({building: building});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
this.setState({building: building});
|
||||
});
|
||||
|
||||
// get if liked and update
|
||||
fetch(`/api/buildings/${building.building_id}/like.json`, {
|
||||
method: 'GET',
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then((res) => {
|
||||
if (res.error) {
|
||||
console.error(res);
|
||||
} else {
|
||||
this.setState({building_like: res.like});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
this.setState({building_like: false});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour building
|
||||
*
|
||||
* Used in multi-edit mode to colour buildings on map click
|
||||
*
|
||||
* Pulls data from URL to form update
|
||||
*
|
||||
* @param {object} building
|
||||
*/
|
||||
colourBuilding(building) {
|
||||
const cat = parseCategoryURL(window.location.pathname);
|
||||
const q = parse(window.location.search);
|
||||
const data = (cat === 'like')? {like: true}: JSON.parse(q.data as string); // TODO: verify what happens if data is string[]
|
||||
if (cat === 'like'){
|
||||
this.likeBuilding(building.building_id)
|
||||
} else {
|
||||
this.updateBuilding(building.building_id, data)
|
||||
}
|
||||
}
|
||||
|
||||
likeBuilding(buildingId) {
|
||||
fetch(`/api/buildings/${buildingId}/like.json`, {
|
||||
method: 'POST',
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({like: true})
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
console.error({error: res.error})
|
||||
} else {
|
||||
this.increaseRevision(res.revision_id);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => console.error({error: err})
|
||||
);
|
||||
}
|
||||
|
||||
updateBuilding(buildingId, data){
|
||||
fetch(`/api/buildings/${buildingId}.json`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then(res => {
|
||||
if (res.error) {
|
||||
console.error({error: res.error})
|
||||
} else {
|
||||
this.increaseRevision(res.revision_id);
|
||||
}
|
||||
}).catch(
|
||||
(err) => console.error({error: err})
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Header user={this.state.user} />
|
||||
<main>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Welcome />
|
||||
</Route>
|
||||
<Route exact path="/view/:cat.html" render={(props) => (
|
||||
<Overview
|
||||
{...props}
|
||||
mode='view' user={this.state.user}
|
||||
/>
|
||||
) } />
|
||||
<Route exact path="/edit/:cat.html" render={(props) => (
|
||||
<Overview
|
||||
{...props}
|
||||
mode='edit' user={this.state.user}
|
||||
/>
|
||||
) } />
|
||||
<Route exact path="/multi-edit/:cat.html" render={(props) => (
|
||||
<MultiEdit
|
||||
{...props}
|
||||
user={this.state.user}
|
||||
/>
|
||||
) } />
|
||||
<Route exact path="/view/:cat/building/:building.html" render={(props) => (
|
||||
<BuildingView
|
||||
{...props}
|
||||
{...this.state.building}
|
||||
user={this.state.user}
|
||||
building_like={this.state.building_like}
|
||||
/>
|
||||
) } />
|
||||
<Route exact path="/edit/:cat/building/:building.html" render={(props) => (
|
||||
<BuildingEdit
|
||||
{...props}
|
||||
{...this.state.building}
|
||||
user={this.state.user}
|
||||
building_like={this.state.building_like}
|
||||
selectBuilding={this.selectBuilding}
|
||||
/>
|
||||
) } />
|
||||
</Switch>
|
||||
<Switch>
|
||||
<Route exact path="/(multi-edit.*|edit.*|view.*)?" render={(props) => (
|
||||
<ColouringMap
|
||||
{...props}
|
||||
building={this.state.building}
|
||||
revision_id={this.state.revision_id}
|
||||
selectBuilding={this.selectBuilding}
|
||||
colourBuilding={this.colourBuilding}
|
||||
/>
|
||||
) } />
|
||||
<Route exact path="/about.html" component={AboutPage} />
|
||||
<Route exact path="/login.html">
|
||||
<Login user={this.state.user} login={this.login} />
|
||||
</Route>
|
||||
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
|
||||
<Route exact path="/password-reset.html" component={PasswordReset} />
|
||||
<Route exact path="/sign-up.html">
|
||||
<SignUp user={this.state.user} login={this.login} />
|
||||
</Route>
|
||||
<Route exact path="/my-account.html">
|
||||
<MyAccountPage
|
||||
user={this.state.user}
|
||||
updateUser={this.updateUser}
|
||||
logout={this.logout}
|
||||
/>
|
||||
</Route>
|
||||
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
|
||||
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
|
||||
<Route exact path="/data-extracts.html" component={DataExtracts} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
<Header user={this.state.user} />
|
||||
<main>
|
||||
<Switch>
|
||||
<Route exact path="/about.html" component={AboutPage} />
|
||||
<Route exact path="/login.html">
|
||||
<Login user={this.state.user} login={this.login} />
|
||||
</Route>
|
||||
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
|
||||
<Route exact path="/password-reset.html" component={PasswordReset} />
|
||||
<Route exact path="/sign-up.html">
|
||||
<SignUp user={this.state.user} login={this.login} />
|
||||
</Route>
|
||||
<Route exact path="/my-account.html">
|
||||
<MyAccountPage
|
||||
user={this.state.user}
|
||||
updateUser={this.updateUser}
|
||||
logout={this.logout}
|
||||
/>
|
||||
</Route>
|
||||
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
|
||||
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
|
||||
<Route exact path="/data-extracts.html" component={DataExtracts} />
|
||||
<Route exact path={["/", "/:mode(view|edit|multi-edit)/:category/:building(\\d+)?"]} render={(props) => (
|
||||
<MapApp
|
||||
{...props}
|
||||
building={this.props.building}
|
||||
building_like={this.props.building_like}
|
||||
user={this.state.user}
|
||||
/>
|
||||
)} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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>…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;
|
25
app/src/frontend/building/building-not-found.tsx
Normal file
25
app/src/frontend/building/building-not-found.tsx
Normal 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;
|
133
app/src/frontend/building/building-view.tsx
Normal file
133
app/src/frontend/building/building-view.tsx
Normal 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 & 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;
|
67
app/src/frontend/building/categories.css
Normal file
67
app/src/frontend/building/categories.css
Normal 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;
|
||||
}
|
159
app/src/frontend/building/categories.tsx
Normal file
159
app/src/frontend/building/categories.tsx
Normal 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 & 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;
|
70
app/src/frontend/building/container-header.tsx
Normal file
70
app/src/frontend/building/container-header.tsx
Normal 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;
|
@ -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;
|
45
app/src/frontend/building/data-components/data-entry.tsx
Normal file
45
app/src/frontend/building/data-components/data-entry.tsx
Normal 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;
|
56
app/src/frontend/building/data-components/data-title.tsx
Normal file
56
app/src/frontend/building/data-components/data-title.tsx
Normal 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 }
|
@ -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;
|
111
app/src/frontend/building/data-components/multi-data-entry.tsx
Normal file
111
app/src/frontend/building/data-components/multi-data-entry.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
259
app/src/frontend/building/data-container.tsx
Normal file
259
app/src/frontend/building/data-container.tsx
Normal 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;
|
77
app/src/frontend/building/data-containers/age.tsx
Normal file
77
app/src/frontend/building/data-containers/age.tsx
Normal 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;
|
32
app/src/frontend/building/data-containers/community.tsx
Normal file
32
app/src/frontend/building/data-containers/community.tsx
Normal 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;
|
56
app/src/frontend/building/data-containers/construction.tsx
Normal file
56
app/src/frontend/building/data-containers/construction.tsx
Normal 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;
|
21
app/src/frontend/building/data-containers/like.tsx
Normal file
21
app/src/frontend/building/data-containers/like.tsx
Normal 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;
|
116
app/src/frontend/building/data-containers/location.tsx
Normal file
116
app/src/frontend/building/data-containers/location.tsx
Normal 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;
|
201
app/src/frontend/building/data-containers/planning.tsx
Normal file
201
app/src/frontend/building/data-containers/planning.tsx
Normal 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
|
141
app/src/frontend/building/data-containers/size.tsx
Normal file
141
app/src/frontend/building/data-containers/size.tsx
Normal 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;
|
23
app/src/frontend/building/data-containers/streetscape.tsx
Normal file
23
app/src/frontend/building/data-containers/streetscape.tsx
Normal 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;
|
65
app/src/frontend/building/data-containers/sustainability.tsx
Normal file
65
app/src/frontend/building/data-containers/sustainability.tsx
Normal 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;
|
30
app/src/frontend/building/data-containers/team.tsx
Normal file
30
app/src/frontend/building/data-containers/team.tsx
Normal 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;
|
23
app/src/frontend/building/data-containers/type.tsx
Normal file
23
app/src/frontend/building/data-containers/type.tsx
Normal 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;
|
36
app/src/frontend/building/data-containers/use.tsx
Normal file
36
app/src/frontend/building/data-containers/use.tsx
Normal 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;
|
@ -4,9 +4,10 @@ import { parse } from 'query-string';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Sidebar from './sidebar';
|
||||
import CONFIG from './fields-config.json';
|
||||
import InfoBox from './info-box';
|
||||
import { sanitiseURL } from './helpers';
|
||||
import InfoBox from '../components/info-box';
|
||||
import { sanitiseURL } from '../helpers';
|
||||
|
||||
const CONFIG = [];
|
||||
|
||||
const MultiEdit = (props) => {
|
||||
if (!props.user){
|
||||
@ -16,9 +17,7 @@ const MultiEdit = (props) => {
|
||||
if (cat === 'like') {
|
||||
// special case for likes
|
||||
return (
|
||||
<Sidebar
|
||||
title='Quick edit'
|
||||
back={`/edit/${cat}.html`}>
|
||||
<Sidebar>
|
||||
<section className='data-section'>
|
||||
<header className={`section-header view ${cat} active`}>
|
||||
<a><h3 className="h3">Like me!</h3></a>
|
||||
@ -26,8 +25,8 @@ const MultiEdit = (props) => {
|
||||
<form className='buttons-container'>
|
||||
<InfoBox msg='Click all the buildings that you like and think contribute to the city!' />
|
||||
|
||||
<Link to='/view/like.html' className='btn btn-secondary'>Back to view</Link>
|
||||
<Link to='/edit/like.html' className='btn btn-secondary'>Back to edit</Link>
|
||||
<Link to='/view/like' className='btn btn-secondary'>Back to view</Link>
|
||||
<Link to='/edit/like' className='btn btn-secondary'>Back to edit</Link>
|
||||
</form>
|
||||
</section>
|
||||
</Sidebar>
|
||||
@ -38,26 +37,24 @@ const MultiEdit = (props) => {
|
||||
const data = JSON.parse(q.data as string) // TODO: verify what happens when data is string[]
|
||||
const title = sectionTitleFromCat(cat);
|
||||
return (
|
||||
<Sidebar
|
||||
title='Quick edit'
|
||||
back={`/edit/${cat}.html`}>
|
||||
<Sidebar>
|
||||
<section className='data-section'>
|
||||
<header className={`section-header view ${cat} active`}>
|
||||
<a><h3 className="h3">{title}</h3></a>
|
||||
</header>
|
||||
<dl className='data-list'>
|
||||
<Fragment>
|
||||
{
|
||||
Object.keys(data).map((key => {
|
||||
const label = fieldTitleFromSlug(key);
|
||||
return <DataEntry key={key} label={label} value={data[key]}/>
|
||||
}))
|
||||
}
|
||||
</dl>
|
||||
</Fragment>
|
||||
<form className='buttons-container'>
|
||||
<InfoBox msg='Click buildings to colour using the data above' />
|
||||
|
||||
<Link to={`/view/${cat}.html`} className='btn btn-secondary'>Back to view</Link>
|
||||
<Link to={`/edit/${cat}.html`} className='btn btn-secondary'>Back to edit</Link>
|
||||
<Link to={`/view/${cat}`} className='btn btn-secondary'>Back to view</Link>
|
||||
<Link to={`/edit/${cat}`} className='btn btn-secondary'>Back to edit</Link>
|
||||
</form>
|
||||
</section>
|
||||
</Sidebar>
|
210
app/src/frontend/building/sidebar.css
Normal file
210
app/src/frontend/building/sidebar.css
Normal 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;
|
||||
}
|
16
app/src/frontend/building/sidebar.tsx
Normal file
16
app/src/frontend/building/sidebar.tsx
Normal 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;
|
@ -5,7 +5,7 @@ import React from 'react'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble,
|
||||
faAngleLeft, faCaretDown, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
faAngleLeft, faCaretDown, faSearch, faEye, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faQuestionCircle,
|
||||
@ -16,7 +16,9 @@ library.add(
|
||||
faCheckDouble,
|
||||
faAngleLeft,
|
||||
faCaretDown,
|
||||
faSearch
|
||||
faCaretUp,
|
||||
faSearch,
|
||||
faEye
|
||||
);
|
||||
|
||||
const HelpIcon = () => (
|
||||
@ -31,6 +33,10 @@ const EditIcon = () => (
|
||||
<FontAwesomeIcon icon="paint-brush" />
|
||||
);
|
||||
|
||||
const ViewIcon = () => (
|
||||
<FontAwesomeIcon icon="eye" />
|
||||
);
|
||||
|
||||
const CloseIcon = () => (
|
||||
<FontAwesomeIcon icon="times" />
|
||||
);
|
||||
@ -51,8 +57,24 @@ const DownIcon = () => (
|
||||
<FontAwesomeIcon icon="caret-down" />
|
||||
);
|
||||
|
||||
const UpIcon = () => (
|
||||
<FontAwesomeIcon icon="caret-up" />
|
||||
);
|
||||
|
||||
const SearchIcon = () => (
|
||||
<FontAwesomeIcon icon="search" />
|
||||
);
|
||||
|
||||
export { HelpIcon, InfoIcon, EditIcon, CloseIcon, SaveIcon, SaveDoneIcon, BackIcon, DownIcon, SearchIcon };
|
||||
export {
|
||||
HelpIcon,
|
||||
InfoIcon,
|
||||
EditIcon,
|
||||
ViewIcon,
|
||||
CloseIcon,
|
||||
SaveIcon,
|
||||
SaveDoneIcon,
|
||||
BackIcon,
|
||||
DownIcon,
|
||||
UpIcon,
|
||||
SearchIcon
|
||||
};
|
@ -5,8 +5,7 @@
|
||||
font-family: 'glacial_cl', sans-serif;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.logo,
|
||||
.logo.navbar-brand {
|
||||
.logo {
|
||||
display: block;
|
||||
padding: 0;
|
||||
color: #000;
|
||||
@ -27,19 +26,7 @@
|
||||
font-size: 0.625em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.map-legend .logo {
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
.map-legend .logo .logotype {
|
||||
font-size: 1.9rem;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
.map-legend .logo .row .cell {
|
||||
background-color: #ccc;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.logo .grid {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
@ -60,51 +47,56 @@
|
||||
height: 14px;
|
||||
margin: 0 3px 0 0;
|
||||
}
|
||||
.logo .row:nth-child(1) .cell:nth-child(1) {
|
||||
|
||||
.logo.gray .cell {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.logo.animated .row:nth-child(1) .cell:nth-child(1) {
|
||||
animation: pulse 87s infinite;
|
||||
animation-delay: -1.5s;
|
||||
}
|
||||
.logo .row:nth-child(1) .cell:nth-child(2) {
|
||||
.logo.animated .row:nth-child(1) .cell:nth-child(2) {
|
||||
animation: pulse 52s infinite;
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
.logo .row:nth-child(1) .cell:nth-child(3) {
|
||||
.logo.animated .row:nth-child(1) .cell:nth-child(3) {
|
||||
animation: pulse 79s infinite;
|
||||
animation-delay: -6s;
|
||||
}
|
||||
.logo .row:nth-child(1) .cell:nth-child(4) {
|
||||
.logo.animated .row:nth-child(1) .cell:nth-child(4) {
|
||||
animation: pulse 55s infinite;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
.logo .row:nth-child(2) .cell:nth-child(1) {
|
||||
.logo.animated .row:nth-child(2) .cell:nth-child(1) {
|
||||
animation: pulse 64s infinite;
|
||||
animation-delay: -7.2s;
|
||||
}
|
||||
.logo .row:nth-child(2) .cell:nth-child(2) {
|
||||
.logo.animated .row:nth-child(2) .cell:nth-child(2) {
|
||||
animation: pulse 98s infinite;
|
||||
animation-delay: -25s;
|
||||
}
|
||||
.logo .row:nth-child(2) .cell:nth-child(3) {
|
||||
.logo.animated .row:nth-child(2) .cell:nth-child(3) {
|
||||
animation: pulse 51s infinite;
|
||||
animation-delay: -35s;
|
||||
}
|
||||
.logo .row:nth-child(2) .cell:nth-child(4) {
|
||||
.logo.animated .row:nth-child(2) .cell:nth-child(4) {
|
||||
animation: pulse 76s infinite;
|
||||
animation-delay: -20s;
|
||||
}
|
||||
.logo .row:nth-child(3) .cell:nth-child(1) {
|
||||
.logo.animated .row:nth-child(3) .cell:nth-child(1) {
|
||||
animation: pulse 52s infinite;
|
||||
animation-delay: -3.5s;
|
||||
}
|
||||
.logo .row:nth-child(3) .cell:nth-child(2) {
|
||||
.logo.animated .row:nth-child(3) .cell:nth-child(2) {
|
||||
animation: pulse 79s infinite;
|
||||
animation-delay: -8.5s;
|
||||
}
|
||||
.logo .row:nth-child(3) .cell:nth-child(3) {
|
||||
.logo.animated .row:nth-child(3) .cell:nth-child(3) {
|
||||
animation: pulse 65s infinite;
|
||||
animation-delay: -4s;
|
||||
}
|
||||
.logo .row:nth-child(3) .cell:nth-child(4) {
|
||||
.logo.animated .row:nth-child(3) .cell:nth-child(4) {
|
||||
animation: pulse 54s infinite;
|
||||
animation-delay: -17s;
|
||||
}
|
49
app/src/frontend/components/logo.tsx
Normal file
49
app/src/frontend/components/logo.tsx
Normal 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 };
|
@ -8,7 +8,7 @@ class Tooltip extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
text: PropTypes.string
|
||||
};
|
||||
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
@ -2,14 +2,27 @@
|
||||
* Main header
|
||||
*/
|
||||
.main-header {
|
||||
display: block;
|
||||
min-height: 79px;
|
||||
text-decoration: none;
|
||||
border-bottom: 3px solid #222;
|
||||
border-bottom: 2px solid #222;
|
||||
}
|
||||
.main-header .navbar {
|
||||
padding: 0.75em 0.5em 0.75em;
|
||||
padding: 0.5em 0.5em 0.5em;
|
||||
}
|
||||
.main-header .navbar-brand {
|
||||
margin: 0 1em 0 0;
|
||||
}
|
||||
|
||||
.main-header .shorten-username {
|
||||
text-overflow: '…)';
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
max-width: 70vw;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main-header .shorten-username {
|
||||
max-width: 5vw;
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Logo from './logo';
|
||||
import { Logo } from './components/logo';
|
||||
import './header.css';
|
||||
|
||||
/**
|
||||
@ -14,11 +14,12 @@ class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
username: PropTypes.string
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {collapseMenu: true};
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleNavigate = this.handleNavigate.bind(this);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
@ -27,22 +28,35 @@ class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
}));
|
||||
}
|
||||
|
||||
handleNavigate() {
|
||||
this.setState({
|
||||
collapseMenu: true
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<header className="main-header">
|
||||
<nav className="navbar navbar-light navbar-expand-md">
|
||||
<span className="navbar-brand">
|
||||
<Logo/>
|
||||
<nav className="navbar navbar-light navbar-expand-lg">
|
||||
<span className="navbar-brand align-self-start">
|
||||
<NavLink to="/">
|
||||
<Logo variant='animated'/>
|
||||
</NavLink>
|
||||
</span>
|
||||
<button className="navbar-toggler navbar-toggler-right" type="button"
|
||||
onClick={this.handleClick} aria-expanded="false" aria-label="Toggle navigation">
|
||||
onClick={this.handleClick} aria-expanded={!this.state.collapseMenu} aria-label="Toggle navigation">
|
||||
<span className="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div className={this.state.collapseMenu ? 'collapse navbar-collapse' : 'navbar-collapse'}>
|
||||
<ul className="navbar-nav ml-auto">
|
||||
<li className="nav-item">
|
||||
<NavLink to="/view/categories" className="nav-link" onClick={this.handleNavigate}>
|
||||
View/Edit Maps
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className="nav-link" href="https://pages.colouring.london">
|
||||
Hello
|
||||
About
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
@ -50,28 +64,13 @@ class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
Data Categories
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink to="/view/age.html" className="nav-link">
|
||||
View Maps
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink to="/edit/age.html" className="nav-link">
|
||||
Add/Edit Data
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className="nav-link" href="https://pages.colouring.london/about">
|
||||
More about
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className="nav-link" href="https://pages.colouring.london/whoisinvolved">
|
||||
Who’s Involved?
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<a className="nav-link" href="https://www.pages.colouring.london/data-ethics">
|
||||
<a className="nav-link" href="https://pages.colouring.london/data-ethics">
|
||||
Data Ethics
|
||||
</a>
|
||||
</li>
|
||||
@ -84,20 +83,20 @@ class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
this.props.user?
|
||||
(
|
||||
<li className="nav-item">
|
||||
<NavLink to="/my-account.html" className="nav-link">
|
||||
My account (Logged in as {this.props.user.username})
|
||||
<NavLink to="/my-account.html" className="nav-link" onClick={this.handleNavigate}>
|
||||
Account <span className="shorten-username">({this.props.user.username})</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
):
|
||||
(
|
||||
<Fragment>
|
||||
<li className="nav-item">
|
||||
<NavLink to="/login.html" className="nav-link">
|
||||
<NavLink to="/login.html" className="nav-link" onClick={this.handleNavigate}>
|
||||
Log in
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink to="/sign-up.html" className="nav-link">
|
||||
<NavLink to="/sign-up.html" className="nav-link" onClick={this.handleNavigate}>
|
||||
Sign up
|
||||
</NavLink>
|
||||
</li>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
250
app/src/frontend/map-app.tsx
Normal file
250
app/src/frontend/map-app.tsx
Normal 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;
|
112
app/src/frontend/map/legend.css
Normal file
112
app/src/frontend/map/legend.css
Normal 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;
|
||||
}
|
@ -2,6 +2,8 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './legend.css';
|
||||
import { Logo } from '../components/logo';
|
||||
import { DownIcon, UpIcon, BackIcon } from '../components/icons';
|
||||
|
||||
const LEGEND_CONFIG = {
|
||||
location: {
|
||||
@ -69,8 +71,8 @@ const LEGEND_CONFIG = {
|
||||
title: 'Sustainability',
|
||||
elements: []
|
||||
},
|
||||
greenery: {
|
||||
title: 'Greenery',
|
||||
streetscape: {
|
||||
title: 'Streetscape',
|
||||
elements: []
|
||||
},
|
||||
planning: {
|
||||
@ -145,33 +147,21 @@ class Legend extends React.Component<any, any> { // TODO: add proper types
|
||||
|
||||
return (
|
||||
<div className="map-legend">
|
||||
<div className="logo">
|
||||
<div className="grid">
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="h3 logotype">
|
||||
<span>Colouring</span>
|
||||
<span>London</span>
|
||||
</h3>
|
||||
</div>
|
||||
<h4 className="h4">{ title } {elements.length?<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >^</button>:null}</h4>
|
||||
<Logo variant='gray' />
|
||||
<h4 className="h4">
|
||||
{ title }
|
||||
</h4>
|
||||
{
|
||||
elements.length > 0 ?
|
||||
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >
|
||||
{
|
||||
this.state.collapseList ?
|
||||
<UpIcon /> :
|
||||
<DownIcon />
|
||||
}
|
||||
</button> :
|
||||
null
|
||||
}
|
||||
{
|
||||
details.description?
|
||||
<p>{details.description} </p>
|
@ -8,25 +8,34 @@
|
||||
box-shadow: 0px 0px 1px 1px #222;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.leaflet-container .leaflet-control-zoom {
|
||||
border: 1px solid #fff;
|
||||
box-shadow: 0 0 1px 1px #222;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.leaflet-grab {
|
||||
cursor: crosshair;
|
||||
}
|
||||
@media (min-width: 768px){
|
||||
/* Only show the "Click a building ..." notice for larger screens */
|
||||
.map-notice {
|
||||
left: 25.5rem;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
top: 3.5rem;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
@ -1,31 +1,47 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { LatLngExpression } from 'leaflet';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
|
||||
|
||||
import '../../node_modules/leaflet/dist/leaflet.css'
|
||||
import '../../../node_modules/leaflet/dist/leaflet.css'
|
||||
import './map.css'
|
||||
|
||||
import { HelpIcon } from './icons';
|
||||
import { HelpIcon } from '../components/icons';
|
||||
import Legend from './legend';
|
||||
import { parseCategoryURL } from '../parse';
|
||||
import { parseCategoryURL } from '../../parse';
|
||||
import SearchBox from './search-box';
|
||||
import ThemeSwitcher from './theme-switcher';
|
||||
|
||||
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
|
||||
|
||||
interface ColouringMapProps {
|
||||
building: any;
|
||||
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
|
||||
category: string;
|
||||
revision_id: number;
|
||||
selectBuilding: any;
|
||||
colourBuilding: any;
|
||||
}
|
||||
|
||||
interface ColouringMapState {
|
||||
theme: 'light' | 'night';
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom: number;
|
||||
}
|
||||
/**
|
||||
* Map area
|
||||
*/
|
||||
class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
building: PropTypes.object,
|
||||
mode: PropTypes.string,
|
||||
category: PropTypes.string,
|
||||
revision_id: PropTypes.number,
|
||||
selectBuilding: PropTypes.func,
|
||||
colourBuilding: PropTypes.func,
|
||||
match: PropTypes.object,
|
||||
history: PropTypes.object
|
||||
colourBuilding: PropTypes.func
|
||||
};
|
||||
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -37,7 +53,6 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleLocate = this.handleLocate.bind(this);
|
||||
this.themeSwitch = this.themeSwitch.bind(this);
|
||||
this.getMode = this.getMode.bind(this);
|
||||
}
|
||||
|
||||
handleLocate(lat, lng, zoom){
|
||||
@ -45,21 +60,13 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
zoom: zoom
|
||||
})
|
||||
}
|
||||
|
||||
getMode() {
|
||||
const isEdit = this.props.match.url.match('edit')
|
||||
const isMulti = this.props.match.url.match('multi')
|
||||
return isEdit? (isMulti? 'multi' : 'edit') : 'view';
|
||||
});
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const mode = this.getMode()
|
||||
const lat = e.latlng.lat
|
||||
const lng = e.latlng.lng
|
||||
const newCat = parseCategoryURL(this.props.match.url);
|
||||
const mapCat = newCat || 'age';
|
||||
const mode = this.props.mode;
|
||||
const lat = e.latlng.lat;
|
||||
const lng = e.latlng.lng;
|
||||
fetch(
|
||||
'/api/buildings/locate?lat='+lat+'&lng='+lng
|
||||
).then(
|
||||
@ -67,17 +74,15 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
).then(function(data){
|
||||
if (data && data.length){
|
||||
const building = data[0];
|
||||
if (mode === 'multi') {
|
||||
if (mode === 'multi-edit') {
|
||||
// colour building directly
|
||||
this.props.colourBuilding(building);
|
||||
} else {
|
||||
this.props.selectBuilding(building);
|
||||
this.props.history.push(`/${mode}/${mapCat}/building/${building.building_id}.html`);
|
||||
}
|
||||
} else {
|
||||
// deselect but keep/return to expected colour theme
|
||||
this.props.selectBuilding(undefined);
|
||||
this.props.history.push(`/${mode}/${mapCat}.html`);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => console.error(err)
|
||||
@ -91,52 +96,56 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
}
|
||||
|
||||
render() {
|
||||
const position = [this.state.lat, this.state.lng];
|
||||
const position: LatLngExpression = [this.state.lat, this.state.lng];
|
||||
|
||||
// baselayer
|
||||
const key = OS_API_KEY
|
||||
const tilematrixSet = 'EPSG:3857'
|
||||
const key = OS_API_KEY;
|
||||
const tilematrixSet = 'EPSG:3857';
|
||||
const layer = (this.state.theme === 'light')? 'Light 3857' : 'Night 3857';
|
||||
const url = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}`
|
||||
const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.'
|
||||
const baseUrl = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}`;
|
||||
const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.';
|
||||
const baseLayer = <TileLayer
|
||||
url={baseUrl}
|
||||
attribution={attribution}
|
||||
/>;
|
||||
|
||||
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
|
||||
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
|
||||
|
||||
// colour-data tiles
|
||||
const isBuilding = /building/.test(this.props.match.url);
|
||||
const isEdit = /edit/.test(this.props.match.url);
|
||||
const cat = parseCategoryURL(this.props.match.url);
|
||||
const cat = this.props.category;
|
||||
const tilesetByCat = {
|
||||
age: 'date_year',
|
||||
size: 'size_storeys',
|
||||
location: 'location',
|
||||
like: 'likes',
|
||||
planning: 'conservation_area',
|
||||
}
|
||||
};
|
||||
const tileset = tilesetByCat[cat];
|
||||
// pick revision id to bust browser cache
|
||||
const rev = this.props.revision_id;
|
||||
const dataLayer = tileset?
|
||||
const dataLayer = tileset != undefined ?
|
||||
<TileLayer
|
||||
key={tileset}
|
||||
url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`}
|
||||
minZoom={9} />
|
||||
url={`/tiles/${tileset}/{z}/{x}/{y}{r}.png?rev=${rev}`}
|
||||
minZoom={9}
|
||||
/>
|
||||
: null;
|
||||
|
||||
// highlight
|
||||
const geometryId = (this.props.building) ? this.props.building.geometry_id : undefined;
|
||||
const highlight = `/tiles/highlight/{z}/{x}/{y}.png?highlight=${geometryId}`
|
||||
const highlightLayer = (isBuilding && this.props.building) ?
|
||||
const highlightLayer = this.props.building != undefined ?
|
||||
<TileLayer
|
||||
key={this.props.building.building_id}
|
||||
url={highlight}
|
||||
minZoom={14} />
|
||||
url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.building.geometry_id}&base=${tileset}`}
|
||||
minZoom={14}
|
||||
zIndex={100}
|
||||
/>
|
||||
: null;
|
||||
|
||||
const baseUrl = (this.state.theme === 'light')?
|
||||
'/tiles/base_light/{z}/{x}/{y}.png'
|
||||
: '/tiles/base_night/{z}/{x}/{y}.png'
|
||||
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="map-container">
|
||||
<Map
|
||||
center={position}
|
||||
zoom={this.state.zoom}
|
||||
@ -146,31 +155,32 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
onClick={this.handleClick}
|
||||
detectRetina={true}
|
||||
>
|
||||
<TileLayer url={url} attribution={attribution} />
|
||||
<TileLayer url={baseUrl} minZoom={14} />
|
||||
{ baseLayer }
|
||||
{ buildingBaseLayer }
|
||||
{ dataLayer }
|
||||
{ highlightLayer }
|
||||
<ZoomControl position="topright" />
|
||||
<AttributionControl prefix="" />
|
||||
<AttributionControl prefix=""/>
|
||||
</Map>
|
||||
{
|
||||
!isBuilding && this.props.match.url !== '/'? (
|
||||
<div className="map-notice">
|
||||
<HelpIcon /> {isEdit? 'Click a building to edit' : 'Click a building for details'}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
this.props.match.url !== '/'? (
|
||||
this.props.mode !== 'basic'? (
|
||||
<Fragment>
|
||||
{
|
||||
this.props.building == undefined ?
|
||||
<div className="map-notice">
|
||||
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<Legend slug={cat} />
|
||||
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
|
||||
<SearchBox onLocate={this.handleLocate} isBuilding={isBuilding} />
|
||||
<SearchBox onLocate={this.handleLocate} />
|
||||
</Fragment>
|
||||
) : null
|
||||
}
|
||||
</Fragment>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
9
app/src/frontend/map/react-leaflet-universal.d.ts
vendored
Normal file
9
app/src/frontend/map/react-leaflet-universal.d.ts
vendored
Normal 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';
|
||||
}
|
@ -5,9 +5,6 @@
|
||||
z-index: 1000;
|
||||
max-width:80%;
|
||||
}
|
||||
.building.search-box {
|
||||
top: 0.5rem;
|
||||
}
|
||||
.search-box form,
|
||||
.search-box .search-box-results {
|
||||
border-radius: 4px;
|
||||
@ -46,11 +43,6 @@
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
/* for large screens adopt the conventional search box position */
|
||||
.search-box{
|
||||
top: 3.625rem;
|
||||
left: 25.5rem;
|
||||
}
|
||||
/* The following is a fix (?) for the truncation of the "Search for postcode" text */
|
||||
.form-inline .form-control {
|
||||
width: 200px;
|
@ -2,16 +2,15 @@ import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './search-box.css';
|
||||
import { SearchIcon } from './icons';
|
||||
import { SearchIcon } from '../components/icons';
|
||||
/**
|
||||
* Search for location
|
||||
*/
|
||||
class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
onLocate: PropTypes.func,
|
||||
isBuilding: PropTypes.bool
|
||||
onLocate: PropTypes.func
|
||||
};
|
||||
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -159,7 +158,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
</ul>
|
||||
: null;
|
||||
return (
|
||||
<div className={`search-box ${this.props.isBuilding? 'building' : ''}`} onKeyDown={this.handleKeyPress}>
|
||||
<div className="search-box" onKeyDown={this.handleKeyPress}>
|
||||
<form onSubmit={this.search} className="form-inline">
|
||||
<div onClick={this.state.smallScreen ? this.expandSearch : null}>
|
||||
<SearchIcon/>
|
@ -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;
|
@ -33,11 +33,11 @@
|
||||
}
|
||||
.carousel-control.next {
|
||||
right: -1em;
|
||||
background-image: url('images/arrow-next.png');
|
||||
background-image: url('../images/arrow-next.png');
|
||||
}
|
||||
.carousel-control.back {
|
||||
left: -1em;
|
||||
background-image: url('images/arrow-back.png');
|
||||
background-image: url('../images/arrow-back.png');
|
||||
}
|
||||
.carousel-content {
|
||||
padding: 0;
|
||||
@ -52,31 +52,3 @@
|
||||
.carousel.active .carousel-content li.current {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data categories
|
||||
*/
|
||||
.data-category-list {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
list-style: none;
|
||||
margin: 0 -0.75em;
|
||||
}
|
||||
.data-category-list li {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
width: 9em;
|
||||
height: 9em;
|
||||
margin: 0.375em;
|
||||
padding: 0.1em;
|
||||
}
|
||||
.data-category-list .category {
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
margin: 1.4em 0 0.5em;
|
||||
}
|
||||
.data-category-list .description {
|
||||
text-align: center;
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import SupporterLogos from './supporter-logos';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
import './about.css';
|
||||
import Categories from '../building/categories';
|
||||
|
||||
const AboutPage = () => (
|
||||
<article>
|
||||
@ -71,56 +72,7 @@ const AboutPage = () => (
|
||||
research.
|
||||
|
||||
</p>
|
||||
<ol className="data-category-list">
|
||||
<li className="bold-yellow">
|
||||
<h3 className="category">Location</h3>
|
||||
<p className="description">Where is it?</p>
|
||||
</li>
|
||||
<li className="bright-yellow">
|
||||
<h3 className="category">Use</h3>
|
||||
<p className="description">How is it used?</p>
|
||||
</li>
|
||||
<li className="bold-orange">
|
||||
<h3 className="category">Type</h3>
|
||||
<p className="description">How was it first used?</p>
|
||||
</li>
|
||||
<li className="red">
|
||||
<h3 className="category">Age</h3>
|
||||
<p className="description">When was it built?</p>
|
||||
</li>
|
||||
<li className="pastel-pink">
|
||||
<h3 className="category">Size</h3>
|
||||
<p className="description">How big is it?</p>
|
||||
</li>
|
||||
<li className="pastel-purple">
|
||||
<h3 className="category">Construction</h3>
|
||||
<p className="description">How is it built?</p>
|
||||
</li>
|
||||
<li className="blue-grey">
|
||||
<h3 className="category">Design/Build</h3>
|
||||
<p className="description">Who built it?</p>
|
||||
</li>
|
||||
<li className="bright-green">
|
||||
<h3 className="category">Street Front</h3>
|
||||
<p className="description">How does it relate to the street?</p>
|
||||
</li>
|
||||
<li className="pastel-green">
|
||||
<h3 className="category">Greenery</h3>
|
||||
<p className="description">Is it near a tree or park?</p>
|
||||
</li>
|
||||
<li className="bright-blue">
|
||||
<h3 className="category">Protection</h3>
|
||||
<p className="description">Is it designated?</p>
|
||||
</li>
|
||||
<li className="pale-grey">
|
||||
<h3 className="category">Demolitions</h3>
|
||||
<p className="description">How many rebuilds on the site?</p>
|
||||
</li>
|
||||
<li className="pale-brown">
|
||||
<h3 className="category">Like Me?</h3>
|
||||
<p className="description">Do you like it?</p>
|
||||
</li>
|
||||
</ol>
|
||||
<Categories building_id={2503371} mode="view" />
|
||||
</div>
|
||||
<hr/>
|
||||
<div className="main-col">
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import InfoBox from './info-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
|
||||
const ContributorAgreementPage : React.SFC<any> = () => (
|
||||
<article>
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import InfoBox from './info-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
|
||||
const PrivacyPolicyPage: React.SFC<any> = () => (
|
||||
<article>
|
@ -6,8 +6,10 @@
|
||||
z-index: 10000;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 0;
|
||||
padding: 1.5em 2.5em 2.5em;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.welcome-float.jumbotron {
|
||||
background: #fff;
|
@ -17,7 +17,7 @@ const Welcome = () => (
|
||||
volunteers of all ages and abilities to test and provide feedback on the site as we
|
||||
build it.
|
||||
</p>
|
||||
<Link to="/view/age.html"
|
||||
<Link to="/view/categories"
|
||||
className="btn btn-outline-dark btn-lg btn-block">
|
||||
Start Colouring Here!
|
||||
</Link>
|
@ -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;
|
||||
}
|
@ -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;
|
@ -52,3 +52,43 @@
|
||||
.pale-brown {
|
||||
background-color: #918e6e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category colours
|
||||
*/
|
||||
.background-location {
|
||||
background-color: #edc40b;
|
||||
}
|
||||
.background-use {
|
||||
background-color: #f0ee0c;
|
||||
}
|
||||
.background-type {
|
||||
background-color: #ff9100;
|
||||
}
|
||||
.background-age {
|
||||
background-color: #ee5f63;
|
||||
}
|
||||
.background-size {
|
||||
background-color: #ee91bf;
|
||||
}
|
||||
.background-construction {
|
||||
background-color: #aa7fa7;
|
||||
}
|
||||
.background-team {
|
||||
background-color: #6f879c;
|
||||
}
|
||||
.background-sustainability {
|
||||
background-color: #5ec232;
|
||||
}
|
||||
.background-streetscape {
|
||||
background-color: #6dbb8b;
|
||||
}
|
||||
.background-community {
|
||||
background-color: #65b7ff;
|
||||
}
|
||||
.background-planning {
|
||||
background-color: #a1a3a9;
|
||||
}
|
||||
.background-like {
|
||||
background-color: #9c896d;
|
||||
}
|
||||
|
@ -41,17 +41,3 @@ form .btn {
|
||||
margin-right: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.btn.btn-half {
|
||||
width: 100%;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.btn.btn-half {
|
||||
width: 49%;
|
||||
margin-left: 0;
|
||||
margin-right: 2%;
|
||||
}
|
||||
.btn.btn-half:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,34 @@
|
||||
/**
|
||||
* Main Layout
|
||||
*/
|
||||
main {
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
min-height: 35rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
@media (min-width: 768px){
|
||||
|
||||
@media(min-width: 768px) {
|
||||
main {
|
||||
position: absolute;
|
||||
top: 79px; /* matches 79px .main-header */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-height: auto;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Text pages
|
||||
*/
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { FormEvent, ChangeEvent } from 'react';
|
||||
import InfoBox from './info-box';
|
||||
import ErrorBox from './error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import ErrorBox from '../components/error-box';
|
||||
|
||||
interface ForgottenPasswordState {
|
||||
success: boolean;
|
||||
@ -24,7 +24,7 @@ export default class ForgottenPassword extends React.Component<{}, ForgottenPass
|
||||
const { name, value } = event.currentTarget;
|
||||
this.setState({ [name]: value } as any);
|
||||
}
|
||||
|
||||
|
||||
async handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
this.setState({ error: undefined, success: undefined });
|
||||
@ -81,4 +81,4 @@ export default class ForgottenPassword extends React.Component<{}, ForgottenPass
|
||||
</article>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -2,16 +2,16 @@ import React, { Component } from 'react';
|
||||
import { Redirect, Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ErrorBox from './error-box';
|
||||
import InfoBox from './info-box';
|
||||
import SupporterLogos from './supporter-logos';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
|
||||
class Login extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
login: PropTypes.func,
|
||||
user: PropTypes.object
|
||||
};
|
||||
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
@ -2,8 +2,8 @@ import React, { Component, FormEvent } from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ErrorBox from './error-box';
|
||||
import ConfirmationModal from './confirmation-modal';
|
||||
import ConfirmationModal from '../components/confirmation-modal';
|
||||
import ErrorBox from '../components/error-box';
|
||||
|
||||
class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
@ -86,7 +86,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
|
||||
if(data.error) {
|
||||
this.setState({ error: data.error });
|
||||
} else {
|
||||
@ -116,7 +116,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
<ErrorBox msg={this.state.error} />
|
||||
<form onSubmit={this.handleLogout}>
|
||||
<div className="buttons-container">
|
||||
<Link to="/edit/age.html" className="btn btn-warning">Start colouring</Link>
|
||||
<Link to="/edit/age" className="btn btn-warning">Start colouring</Link>
|
||||
<input className="btn btn-secondary" type="submit" value="Log out"/>
|
||||
</div>
|
||||
</form>
|
@ -1,7 +1,7 @@
|
||||
import React, { ChangeEvent, FormEvent } from 'react';
|
||||
import { RouteComponentProps, Redirect } from 'react-router';
|
||||
|
||||
import ErrorBox from './error-box';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface PasswordResetState {
|
||||
@ -88,7 +88,7 @@ export default class PasswordReset extends React.Component<RouteComponentProps,
|
||||
return <Redirect to="/my-account.html" />;
|
||||
}
|
||||
return (
|
||||
|
||||
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1 className="h2">Forgotten password</h1>
|
||||
@ -119,4 +119,4 @@ export default class PasswordReset extends React.Component<RouteComponentProps,
|
||||
</article>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -2,9 +2,9 @@ import React, { Component } from 'react';
|
||||
import { Redirect, Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ErrorBox from './error-box';
|
||||
import InfoBox from './info-box';
|
||||
import SupporterLogos from './supporter-logos';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
|
||||
class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
@ -94,6 +94,10 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
className="form-control" type="text"
|
||||
value={this.state.username} onChange={this.handleChange}
|
||||
placeholder="not-your-real-name" required
|
||||
minLength={4}
|
||||
maxLength={30}
|
||||
pattern="\w+"
|
||||
title="Usernames can contain only letters, numbers and the underscore"
|
||||
/>
|
||||
|
||||
<label htmlFor="email">Email (optional)</label>
|
||||
@ -115,6 +119,8 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
type={(this.state.show_password)? 'text': 'password'}
|
||||
value={this.state.password} onChange={this.handleChange}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
/>
|
||||
|
||||
<div className="form-check">
|
131
app/src/frontendRoute.tsx
Normal file
131
app/src/frontendRoute.tsx
Normal 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. We’re 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;
|
@ -23,7 +23,7 @@ function strictParseInt(value) {
|
||||
* @returns {number|undefined}
|
||||
*/
|
||||
function parseBuildingURL(url) {
|
||||
const re = /\/building\/([^/]+).html/;
|
||||
const re = /\/(\d+)$/;
|
||||
const matches = re.exec(url);
|
||||
|
||||
if (matches && matches.length >= 2) {
|
||||
|
@ -4,40 +4,25 @@
|
||||
* - entry-point to shared React App
|
||||
*
|
||||
*/
|
||||
import React from 'react';
|
||||
import { StaticRouter } from 'react-router-dom';
|
||||
import express from 'express';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import serialize from 'serialize-javascript';
|
||||
|
||||
import session from 'express-session';
|
||||
import pgConnect from 'connect-pg-simple';
|
||||
|
||||
import App from './frontend/app';
|
||||
import db from './db';
|
||||
import { getUserById } from './api/services/user';
|
||||
import {
|
||||
getBuildingById,
|
||||
getBuildingLikeById,
|
||||
getBuildingUPRNsById
|
||||
} from './api/services/building';
|
||||
import tileserver from './tiles/tileserver';
|
||||
import apiServer from './api/api';
|
||||
import { parseBuildingURL } from './parse';
|
||||
import frontendRoute from './frontendRoute';
|
||||
|
||||
// create server
|
||||
const server = express();
|
||||
|
||||
// reference packed assets
|
||||
const assets = require(process.env.RAZZLE_ASSETS_MANIFEST);
|
||||
|
||||
// disable header
|
||||
server.disable('x-powered-by');
|
||||
|
||||
// serve static files
|
||||
server.use(express.static(process.env.RAZZLE_PUBLIC_DIR));
|
||||
|
||||
|
||||
// handle user sessions
|
||||
const pgSession = pgConnect(session);
|
||||
const sess: any = { // TODO: remove any
|
||||
@ -59,106 +44,8 @@ if (server.get('env') === 'production') {
|
||||
}
|
||||
server.use(session(sess));
|
||||
|
||||
// handle HTML routes (server-side rendered React)
|
||||
server.get('/*.html', frontendRoute);
|
||||
server.get('/', frontendRoute);
|
||||
|
||||
function frontendRoute(req, res) {
|
||||
const context: any = {}; // TODO: remove any
|
||||
const data: any = {}; // TODO: remove any
|
||||
context.status = 200;
|
||||
|
||||
const userId = req.session.user_id;
|
||||
const buildingId = parseBuildingURL(req.url);
|
||||
const isBuilding = (typeof (buildingId) !== 'undefined');
|
||||
if (isBuilding && isNaN(buildingId)) {
|
||||
context.status = 404;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
userId ? getUserById(userId) : undefined,
|
||||
isBuilding ? getBuildingById(buildingId) : undefined,
|
||||
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
|
||||
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false
|
||||
]).then(function (values) {
|
||||
const user = values[0];
|
||||
const building = values[1];
|
||||
const uprns = values[2];
|
||||
const buildingLike = values[3];
|
||||
if (isBuilding && typeof (building) === 'undefined') {
|
||||
context.status = 404
|
||||
}
|
||||
data.user = user;
|
||||
data.building = building;
|
||||
data.building_like = buildingLike;
|
||||
if (data.building != null) {
|
||||
data.building.uprns = uprns;
|
||||
}
|
||||
renderHTML(context, data, req, res)
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
data.user = undefined;
|
||||
data.building = undefined;
|
||||
data.building_like = undefined;
|
||||
context.status = 500;
|
||||
renderHTML(context, data, req, res);
|
||||
});
|
||||
}
|
||||
|
||||
function renderHTML(context, data, req, res) {
|
||||
const markup = renderToString(
|
||||
<StaticRouter context={context} location={req.url}>
|
||||
<App user={data.user} building={data.building} building_like={data.building_like} />
|
||||
</StaticRouter>
|
||||
);
|
||||
|
||||
if (context.url) {
|
||||
res.redirect(context.url);
|
||||
} else {
|
||||
res.status(context.status).send(
|
||||
`<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta charset="utf-8" />
|
||||
<title>Colouring London</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'glacial_cl';
|
||||
src: url('/fonts/glacialindifference-regular-webfont.woff2') format('woff2'),
|
||||
url('/fonts/glacialindifference-regular-webfont.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
${
|
||||
assets.client.css
|
||||
? `<link rel="stylesheet" href="${assets.client.css}">`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
process.env.NODE_ENV === 'production'
|
||||
? `<script src="${assets.client.js}" defer></script>`
|
||||
: `<script src="${assets.client.js}" defer crossorigin></script>`
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">${markup}</div>
|
||||
<script>
|
||||
window.__PRELOADED_STATE__ = ${serialize(data)}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
server.use('/tiles', tileserver);
|
||||
|
||||
server.use('/api', apiServer);
|
||||
|
||||
// use the frontend route for anything else - will presumably show the 404 page
|
||||
server.use(frontendRoute);
|
||||
|
||||
export default server;
|
||||
|
@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Cache tiles (PNG images generated from database)
|
||||
*
|
||||
* Frequency of change:
|
||||
* - base layer tiles change rarely - on changes to underlying geometry table
|
||||
* - visualisation layer tiles change frequently - with almost any edit to the buildings table
|
||||
*
|
||||
* Cost of generation and storage:
|
||||
* - low zoom tiles are more expensive to render, containing more features from the database
|
||||
* - high zoom tiles are cheaper to rerender, and changes are more visible
|
||||
* - there are many more high zoom tiles than low: 4 tiles at zoom level n+1 for each tile
|
||||
* at zoom level n
|
||||
*
|
||||
*/
|
||||
|
||||
// Using node-fs package to patch fs
|
||||
// for node >10 we could drop this in favour of fs.mkdir (which has recursive option)
|
||||
// and then use stdlib `import fs from 'fs';`
|
||||
import fs from 'node-fs';
|
||||
|
||||
import { getXYZ } from './tile';
|
||||
|
||||
// Use an environment variable to configure the cache location, somewhere we can read/write to.
|
||||
const CACHE_PATH = process.env.TILECACHE_PATH
|
||||
|
||||
/**
|
||||
* Get a tile from the cache
|
||||
*
|
||||
* @param {String} tileset
|
||||
* @param {number} z zoom level
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
function get(tileset, z, x, y) {
|
||||
if (!shouldTryCache(tileset, z)) {
|
||||
return Promise.reject(`Skip cache get ${tileset}/${z}/${x}/${y}`);
|
||||
}
|
||||
const location = cacheLocation(tileset, z, x, y);
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(location.fname, (err, data) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve(data)
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a tile in the cache
|
||||
*
|
||||
* @param {Buffer} im image data
|
||||
* @param {String} tileset
|
||||
* @param {number} z zoom level
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
function put(im, tileset, z, x, y) {
|
||||
if (!shouldTryCache(tileset, z)) {
|
||||
return Promise.reject(`Skip cache put ${tileset}/${z}/${x}/${y}`);
|
||||
}
|
||||
const location = cacheLocation(tileset, z, x, y);
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.writeFile(location.fname, im, 'binary', (err) => {
|
||||
if (err && err.code === 'ENOENT') {
|
||||
// recursively create tile directory if it didn't previously exist
|
||||
fs.mkdir(location.dir, 0o755, true, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
// then write the file
|
||||
fs.writeFile(location.fname, im, 'binary', (err) => {
|
||||
(err)? reject(err): resolve()
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
(err)? reject(err): resolve()
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single cached tile
|
||||
*
|
||||
* @param {String} tileset
|
||||
* @param {number} z zoom level
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
function remove(tileset, z, x, y) {
|
||||
const location = cacheLocation(tileset, z, x, y)
|
||||
return new Promise(resolve => {
|
||||
fs.unlink(location.fname, (err) => {
|
||||
if(err){
|
||||
// pass
|
||||
} else {
|
||||
console.log('Expire cache', tileset, z, x, y)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all cached data-visualising tiles which intersect a bbox
|
||||
* - initially called directly after edits; may be better on a worker process?
|
||||
*
|
||||
* @param {String} tileset
|
||||
* @param {Array} bbox [w, s, e, n] in EPSG:3857 coordinates
|
||||
*/
|
||||
function removeAllAtBbox(bbox) {
|
||||
// magic numbers for min/max zoom
|
||||
const minZoom = 9;
|
||||
const maxZoom = 18;
|
||||
// magic list of tilesets - see tileserver, other cache rules
|
||||
const tilesets = ['date_year', 'size_storeys', 'location', 'likes', 'conservation_area'];
|
||||
let tileBounds;
|
||||
const removePromises = [];
|
||||
for (let ti = 0; ti < tilesets.length; ti++) {
|
||||
const tileset = tilesets[ti];
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
tileBounds = getXYZ(bbox, z)
|
||||
for (let x = tileBounds.minX; x <= tileBounds.maxX; x++){
|
||||
for (let y = tileBounds.minY; y <= tileBounds.maxY; y++){
|
||||
removePromises.push(remove(tileset, z, x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Promise.all(removePromises)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache location for a tile
|
||||
*
|
||||
* @param {String} tileset
|
||||
* @param {number} z zoom level
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {object} { dir: <directory>, fname: <full filepath> }
|
||||
*/
|
||||
function cacheLocation(tileset, z, x, y) {
|
||||
const dir = `${CACHE_PATH}/${tileset}/${z}/${x}`
|
||||
const fname = `${dir}/${y}.png`
|
||||
return {dir, fname}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rules for caching tiles
|
||||
*
|
||||
* @param {String} tileset
|
||||
* @param {number} z zoom level
|
||||
* @returns {boolean} whether to use the cache (or not)
|
||||
*/
|
||||
function shouldTryCache(tileset, z) {
|
||||
if (tileset === 'date_year') {
|
||||
// cache high zoom because of front page hits
|
||||
return z <= 16
|
||||
}
|
||||
if (tileset === 'base_light' || tileset === 'base_night') {
|
||||
// cache for higher zoom levels (unlikely to change)
|
||||
return z <= 17
|
||||
}
|
||||
// else cache for lower zoom levels (change slowly)
|
||||
return z <= 13
|
||||
}
|
||||
|
||||
export { get, put, remove, removeAllAtBbox };
|
135
app/src/tiles/dataDefinition.ts
Normal file
135
app/src/tiles/dataDefinition.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { strictParseInt } from "../parse";
|
||||
import { DataConfig } from "./renderers/datasourceRenderer";
|
||||
|
||||
const BUILDING_LAYER_DEFINITIONS = {
|
||||
base_light: `(
|
||||
SELECT
|
||||
b.location_number as location_number,
|
||||
g.geometry_geom
|
||||
FROM
|
||||
geometries as g,
|
||||
buildings as b
|
||||
WHERE
|
||||
g.geometry_id = b.geometry_id
|
||||
) as outline`,
|
||||
base_night: `(
|
||||
SELECT
|
||||
b.location_number as location_number,
|
||||
g.geometry_geom
|
||||
FROM
|
||||
geometries as g,
|
||||
buildings as b
|
||||
WHERE
|
||||
g.geometry_id = b.geometry_id
|
||||
) as outline`,
|
||||
date_year: `(
|
||||
SELECT
|
||||
b.date_year as date_year,
|
||||
g.geometry_geom
|
||||
FROM
|
||||
geometries as g,
|
||||
buildings as b
|
||||
WHERE
|
||||
g.geometry_id = b.geometry_id
|
||||
) as outline`,
|
||||
size_storeys: `(
|
||||
SELECT
|
||||
(
|
||||
coalesce(b.size_storeys_attic, 0) +
|
||||
coalesce(b.size_storeys_core, 0)
|
||||
) as size_storeys,
|
||||
g.geometry_geom
|
||||
FROM
|
||||
geometries as g,
|
||||
buildings as b
|
||||
WHERE
|
||||
g.geometry_id = b.geometry_id
|
||||
) as outline`,
|
||||
location: `(
|
||||
SELECT
|
||||
(
|
||||
case when b.location_name is null then 0 else 1 end +
|
||||
case when b.location_number is null then 0 else 1 end +
|
||||
case when b.location_street is null then 0 else 1 end +
|
||||
case when b.location_line_two is null then 0 else 1 end +
|
||||
case when b.location_town is null then 0 else 1 end +
|
||||
case when b.location_postcode is null then 0 else 1 end +
|
||||
case when b.location_latitude is null then 0 else 1 end +
|
||||
case when b.location_longitude is null then 0 else 1 end +
|
||||
case when b.ref_toid is null then 0 else 1 end +
|
||||
case when b.ref_osm_id is null then 0 else 1 end
|
||||
) as location_info_count,
|
||||
g.geometry_geom
|
||||
FROM
|
||||
geometries as g,
|
||||
buildings as b
|
||||
WHERE
|
||||
g.geometry_id = b.geometry_id
|
||||
) as location`,
|
||||
likes: `(
|
||||
SELECT
|
||||
g.geometry_geom,
|
||||
b.likes_total as likes
|
||||
FROM
|
||||
geometries as g,
|
||||
buildings as b
|
||||
WHERE
|
||||
g.geometry_id = b.geometry_id
|
||||
AND b.likes_total > 0
|
||||
) as location`,
|
||||
conservation_area: `(
|
||||
SELECT
|
||||
g.geometry_geom
|
||||
FROM
|
||||
geometries as g,
|
||||
buildings as b
|
||||
WHERE
|
||||
g.geometry_id = b.geometry_id
|
||||
AND b.planning_in_conservation_area = true
|
||||
) as conservation_area`
|
||||
};
|
||||
|
||||
const GEOMETRY_FIELD = 'geometry_geom';
|
||||
|
||||
function getBuildingsDataConfig(tileset: string, dataParams: any): DataConfig {
|
||||
const table = BUILDING_LAYER_DEFINITIONS[tileset];
|
||||
|
||||
if(table == undefined) {
|
||||
throw new Error('Invalid tileset requested');
|
||||
}
|
||||
|
||||
return {
|
||||
geometry_field: GEOMETRY_FIELD,
|
||||
table: table
|
||||
};
|
||||
}
|
||||
|
||||
function getHighlightDataConfig(tileset: string, dataParams: any): DataConfig {
|
||||
let { highlight, base } = dataParams;
|
||||
|
||||
highlight = strictParseInt(highlight);
|
||||
base = base || 'default';
|
||||
|
||||
if(isNaN(highlight) || base.match(/^\w+$/) == undefined) {
|
||||
throw new Error('Bad parameters for highlight layer');
|
||||
}
|
||||
|
||||
return {
|
||||
geometry_field: GEOMETRY_FIELD,
|
||||
table: `(
|
||||
SELECT
|
||||
g.geometry_geom,
|
||||
'${base}' as base_layer
|
||||
FROM
|
||||
geometries as g
|
||||
WHERE
|
||||
g.geometry_id = ${highlight}
|
||||
) as highlight`
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
BUILDING_LAYER_DEFINITIONS,
|
||||
getBuildingsDataConfig,
|
||||
getHighlightDataConfig
|
||||
};
|
78
app/src/tiles/rendererDefinition.ts
Normal file
78
app/src/tiles/rendererDefinition.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { TileCache } from "./tileCache";
|
||||
import { BoundingBox, TileParams } from "./types";
|
||||
import { StitchRenderer } from "./renderers/stitchRenderer";
|
||||
import { CachedRenderer } from "./renderers/cachedRenderer";
|
||||
import { BranchingRenderer } from "./renderers/branchingRenderer";
|
||||
import { WindowedRenderer } from "./renderers/windowedRenderer";
|
||||
import { BlankRenderer } from "./renderers/blankRenderer";
|
||||
import { DatasourceRenderer } from "./renderers/datasourceRenderer";
|
||||
import { getBuildingsDataConfig, getHighlightDataConfig, BUILDING_LAYER_DEFINITIONS } from "./dataDefinition";
|
||||
|
||||
/**
|
||||
* A list of all tilesets handled by the tile server
|
||||
*/
|
||||
const allTilesets = ['highlight', ...Object.keys(BUILDING_LAYER_DEFINITIONS)];
|
||||
|
||||
const buildingDataRenderer = new DatasourceRenderer(getBuildingsDataConfig);
|
||||
|
||||
const stitchRenderer = new StitchRenderer(undefined); // depends recurrently on cache, so parameter will be set later
|
||||
|
||||
/**
|
||||
* Zoom level when we switch from rendering direct from database to instead composing tiles
|
||||
* from the zoom level below - gets similar effect, with much lower load on Postgres
|
||||
*/
|
||||
const STITCH_THRESHOLD = 12;
|
||||
|
||||
const renderOrStitchRenderer = new BranchingRenderer(
|
||||
({ z }) => z <= STITCH_THRESHOLD,
|
||||
stitchRenderer, // refer to the prepared stitch renderer
|
||||
buildingDataRenderer
|
||||
);
|
||||
|
||||
const tileCache = new TileCache(
|
||||
process.env.TILECACHE_PATH,
|
||||
{
|
||||
tilesets: ['date_year', 'size_storeys', 'location', 'likes', 'conservation_area'],
|
||||
minZoom: 9,
|
||||
maxZoom: 18,
|
||||
scales: [1, 2]
|
||||
},
|
||||
({ tileset, z }: TileParams) => (tileset === 'date_year' && z <= 16) ||
|
||||
((tileset === 'base_light' || tileset === 'base_night') && z <= 17) ||
|
||||
z <= 13
|
||||
);
|
||||
|
||||
const cachedRenderer = new CachedRenderer(
|
||||
tileCache,
|
||||
renderOrStitchRenderer
|
||||
);
|
||||
|
||||
// set up stitch renderer to use the data renderer with caching
|
||||
stitchRenderer.tileRenderer = cachedRenderer;
|
||||
|
||||
const highlightRenderer = new DatasourceRenderer(getHighlightDataConfig);
|
||||
|
||||
const highlightOrBuildingRenderer = new BranchingRenderer(
|
||||
({ tileset }) => tileset === 'highlight',
|
||||
highlightRenderer,
|
||||
cachedRenderer
|
||||
);
|
||||
|
||||
const blankRenderer = new BlankRenderer();
|
||||
|
||||
/**
|
||||
* Hard-code extent so we can short-circuit rendering and return empty/transparent tiles outside the area of interest
|
||||
* bbox in CRS epsg:3857 in form: [w, s, e, n]
|
||||
*/
|
||||
const EXTENT_BBOX: BoundingBox = [-61149.622628, 6667754.851372, 28128.826409, 6744803.375884];
|
||||
const mainRenderer = new WindowedRenderer(
|
||||
EXTENT_BBOX,
|
||||
highlightOrBuildingRenderer,
|
||||
blankRenderer
|
||||
);
|
||||
|
||||
export {
|
||||
allTilesets,
|
||||
mainRenderer,
|
||||
tileCache
|
||||
};
|
21
app/src/tiles/renderers/blankRenderer.ts
Normal file
21
app/src/tiles/renderers/blankRenderer.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Image } from "mapnik";
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
|
||||
class BlankRenderer implements TileRenderer {
|
||||
getTile(tileParams: TileParams): Promise<Image> {
|
||||
return sharp({
|
||||
create: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
}
|
||||
}).png().toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
BlankRenderer
|
||||
};
|
23
app/src/tiles/renderers/branchingRenderer.ts
Normal file
23
app/src/tiles/renderers/branchingRenderer.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Image } from "mapnik";
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
|
||||
class BranchingRenderer {
|
||||
constructor(
|
||||
public branchTestFn: (tileParams: TileParams) => boolean,
|
||||
public trueResultTileRenderer: TileRenderer,
|
||||
public falseResultTileRenderer: TileRenderer
|
||||
) {}
|
||||
|
||||
getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
|
||||
if(this.branchTestFn(tileParams)) {
|
||||
return this.trueResultTileRenderer.getTile(tileParams, dataParams);
|
||||
} else {
|
||||
return this.falseResultTileRenderer.getTile(tileParams, dataParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
BranchingRenderer
|
||||
};
|
32
app/src/tiles/renderers/cachedRenderer.ts
Normal file
32
app/src/tiles/renderers/cachedRenderer.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Image } from "mapnik";
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
import { TileCache } from "../tileCache";
|
||||
import { formatParams } from "../util";
|
||||
|
||||
class CachedRenderer implements TileRenderer {
|
||||
constructor(
|
||||
/** Cache to use for tiles */
|
||||
public tileCache: TileCache,
|
||||
|
||||
/** Renderer to use when tile hasn't been cached yet */
|
||||
public tileRenderer: TileRenderer
|
||||
) {}
|
||||
|
||||
async getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
|
||||
try {
|
||||
const tile = await this.tileCache.get(tileParams);
|
||||
return tile;
|
||||
} catch(err) {
|
||||
const im = await this.tileRenderer.getTile(tileParams, dataParams);
|
||||
try {
|
||||
await this.tileCache.put(im, tileParams);
|
||||
} catch (err) {}
|
||||
return im;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
CachedRenderer
|
||||
};
|
105
app/src/tiles/renderers/datasourceRenderer.ts
Normal file
105
app/src/tiles/renderers/datasourceRenderer.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import path from 'path';
|
||||
|
||||
import mapnik from "mapnik";
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
import { getBbox, TILE_SIZE } from "../util";
|
||||
import { promisify } from "util";
|
||||
|
||||
interface DataConfig {
|
||||
table: string;
|
||||
geometry_field: string;
|
||||
}
|
||||
|
||||
const TILE_BUFFER_SIZE = 64;
|
||||
const PROJ4_STRING = '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over';
|
||||
|
||||
// connection details from environment variables
|
||||
const DATASOURCE_CONFIG = {
|
||||
'host': process.env.PGHOST,
|
||||
'dbname': process.env.PGDATABASE,
|
||||
'user': process.env.PGUSER,
|
||||
'password': process.env.PGPASSWORD,
|
||||
'port': process.env.PGPORT,
|
||||
'extent': '-20005048.4188,-9039211.13765,19907487.2779,17096598.5401',
|
||||
'srid': 3857,
|
||||
'type': 'postgis'
|
||||
};
|
||||
|
||||
// register datasource adapters for mapnik database connection
|
||||
if (mapnik.register_default_input_plugins) {
|
||||
mapnik.register_default_input_plugins();
|
||||
}
|
||||
// register fonts for text rendering
|
||||
mapnik.register_default_fonts();
|
||||
|
||||
|
||||
class DatasourceRenderer implements TileRenderer {
|
||||
constructor(private getTableDefinitionFn: (tileset: string, dataParams: any) => DataConfig) {}
|
||||
|
||||
async getTile({tileset, z, x, y, scale}: TileParams, dataParams: any): Promise<mapnik.Image> {
|
||||
const bbox = getBbox(z, x, y);
|
||||
|
||||
const tileSize = TILE_SIZE * scale;
|
||||
let map = new mapnik.Map(tileSize, tileSize, PROJ4_STRING);
|
||||
map.bufferSize = TILE_BUFFER_SIZE;
|
||||
const layer = new mapnik.Layer('tile', PROJ4_STRING);
|
||||
|
||||
const dataSourceConfig = this.getTableDefinitionFn(tileset, dataParams);
|
||||
|
||||
const conf = Object.assign(dataSourceConfig, DATASOURCE_CONFIG);
|
||||
|
||||
const postgis = new mapnik.Datasource(conf);
|
||||
layer.datasource = postgis;
|
||||
layer.styles = [tileset];
|
||||
|
||||
const stylePath = path.join(__dirname, '..', 'map_styles', 'polygon.xml');
|
||||
|
||||
map = await promisify(map.load.bind(map))(stylePath, {strict: true});
|
||||
|
||||
map.add_layer(layer);
|
||||
const im = new mapnik.Image(map.width, map.height);
|
||||
map.extent = bbox;
|
||||
const rendered = await promisify(map.render.bind(map))(im, {});
|
||||
|
||||
return await promisify(rendered.encode.bind(rendered))('png');
|
||||
}
|
||||
}
|
||||
|
||||
function promiseHandler(resolve, reject) {
|
||||
return function(err, result) {
|
||||
if(err) reject(err);
|
||||
else resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function which promisifies a method of an object and binds it to the object
|
||||
* This makes it easier to use callback-based object methods in a promise-based way
|
||||
* @param obj Object containing the target method
|
||||
* @param methodName Method name to promisify and return
|
||||
*/
|
||||
function promisifyMethod<T, F>(obj: T, methodName: keyof T);
|
||||
/**
|
||||
* @param methodGetter accessor function to get the method from the object
|
||||
*/
|
||||
function promisifyMethod<T, S>(obj: T, methodGetter: (o: T) => S);
|
||||
function promisifyMethod<T, S>(obj: T, paramTwo: keyof T | ((o: T) => S)) {
|
||||
let method;
|
||||
if (typeof paramTwo === 'string') {
|
||||
method = obj[paramTwo];
|
||||
} else if (typeof paramTwo === 'function') {
|
||||
method = paramTwo(obj);
|
||||
}
|
||||
|
||||
if (typeof method === 'function') {
|
||||
return promisify(method.bind(obj));
|
||||
} else {
|
||||
throw new Error(`Cannot promisify non-function property '${paramTwo}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
DatasourceRenderer,
|
||||
DataConfig
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user