Merge branch 'develop'
This commit is contained in:
commit
fb0358e5b1
@ -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>
|
||||
|
6930
app/package-lock.json
generated
6930
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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,11 +1,12 @@
|
||||
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';
|
||||
import usersRouter from './routes/usersRouter';
|
||||
import extractsRouter from './routes/extractsRouter';
|
||||
|
||||
|
||||
const server = express.Router();
|
||||
@ -15,35 +16,7 @@ server.use(bodyParser.json());
|
||||
|
||||
server.use('/buildings', buildingsRouter);
|
||||
server.use('/users', usersRouter);
|
||||
|
||||
// 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 });
|
||||
});
|
||||
})
|
||||
server.use('/extracts', extractsRouter);
|
||||
|
||||
// POST user auth
|
||||
server.post('/login', function (req, res) {
|
||||
|
28
app/src/api/controllers/extractController.ts
Normal file
28
app/src/api/controllers/extractController.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import express from 'express';
|
||||
import * as dataExtractService from '../services/dataExtract';
|
||||
import asyncController from '../routes/asyncController';
|
||||
|
||||
|
||||
const getAllDataExtracts = asyncController(async function(req: express.Request, res: express.Response) {
|
||||
try {
|
||||
const dataExtracts = await dataExtractService.listDataExtracts();
|
||||
res.send({ extracts: dataExtracts });
|
||||
} catch (err) {
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
const getDataExtract = asyncController(async function(req: express.Request, res: express.Response) {
|
||||
try {
|
||||
const extractId = req.params.extract_id;
|
||||
const extract = await dataExtractService.getDataExtractById(extractId);
|
||||
res.send({ extract: extract });
|
||||
} catch (err) {
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
getAllDataExtracts,
|
||||
getDataExtract
|
||||
};
|
@ -5,6 +5,8 @@ import express from 'express';
|
||||
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;
|
||||
@ -64,7 +66,7 @@ function deleteCurrentUser(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
async function resetPassword(req: express.Request, res: express.Response) {
|
||||
const resetPassword = asyncController(async function(req: express.Request, res: express.Response) {
|
||||
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) {
|
||||
return res.send({ error: 'Expected an email address or password reset token in the request body' });
|
||||
}
|
||||
@ -86,6 +88,8 @@ async function resetPassword(req: express.Request, res: express.Response) {
|
||||
} catch (err) {
|
||||
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;
|
||||
@ -93,7 +97,7 @@ async function resetPassword(req: express.Request, res: express.Response) {
|
||||
|
||||
return res.send({ success: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getWebAppOrigin() : string {
|
||||
const origin = process.env.WEBAPP_ORIGIN;
|
||||
|
17
app/src/api/routes/asyncController.ts
Normal file
17
app/src/api/routes/asyncController.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* A wrapper for controller functions that return a Promise, enabling them to be used with Express
|
||||
* Without this wrapper, Promise rejections caused by an error in the controller will not be passed properly
|
||||
* to subsequent middleware layers.
|
||||
* @param fn the async controller function to be wrapped
|
||||
* @returns controller function which handles async errors correctly
|
||||
*/
|
||||
function asyncController(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next))
|
||||
.catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export default asyncController;
|
10
app/src/api/routes/extractsRouter.ts
Normal file
10
app/src/api/routes/extractsRouter.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import express from 'express';
|
||||
|
||||
import extractController from '../controllers/extractController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', extractController.getAllDataExtracts);
|
||||
router.get('/:extract_id', extractController.getDataExtract);
|
||||
|
||||
export default router;
|
@ -2,12 +2,6 @@ import express from 'express';
|
||||
|
||||
import userController from '../controllers/userController';
|
||||
|
||||
const asyncMiddleware = fn =>
|
||||
(req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next))
|
||||
.catch(next);
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', userController.createUser);
|
||||
@ -16,6 +10,6 @@ router.route('/me')
|
||||
.get(userController.getCurrentUser)
|
||||
.delete(userController.deleteCurrentUser);
|
||||
|
||||
router.put('/password', asyncMiddleware(userController.resetPassword));
|
||||
router.put('/password', userController.resetPassword);
|
||||
|
||||
export default router;
|
@ -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);
|
||||
})
|
||||
}
|
||||
|
||||
|
64
app/src/api/services/dataExtract.ts
Normal file
64
app/src/api/services/dataExtract.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import path from 'path';
|
||||
|
||||
import db from '../../db';
|
||||
|
||||
interface DataExtractRow {
|
||||
extract_id: number;
|
||||
extracted_on: Date;
|
||||
extract_path: string;
|
||||
}
|
||||
|
||||
interface DataExtract {
|
||||
extract_id: number;
|
||||
extracted_on: Date;
|
||||
download_path: string;
|
||||
}
|
||||
|
||||
async function listDataExtracts(): Promise<DataExtract[]> {
|
||||
try {
|
||||
const extractRecords = await db.manyOrNone<DataExtractRow>(
|
||||
`SELECT
|
||||
extract_id, extracted_on, extract_path
|
||||
FROM bulk_extracts`
|
||||
);
|
||||
|
||||
return extractRecords.map(getDataExtractFromRow);
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getDataExtractById(extractId: number): Promise<DataExtract> {
|
||||
try {
|
||||
const extractRecord = await db.one<DataExtractRow>(
|
||||
`SELECT
|
||||
extract_id, extracted_on, extract_path
|
||||
FROM bulk_extracts
|
||||
WHERE extract_id = $1
|
||||
`, [extractId]);
|
||||
|
||||
return getDataExtractFromRow(extractRecord);
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getDataExtractFromRow(er: DataExtractRow): DataExtract {
|
||||
return {
|
||||
extract_id: er.extract_id,
|
||||
extracted_on: er.extracted_on,
|
||||
download_path: getDownloadLinkForExtract(er)
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadLinkForExtract(extract: DataExtractRow): string {
|
||||
const file_name = path.basename(extract.extract_path);
|
||||
return `/downloads/${file_name}` // TODO: potentially move base path to env var
|
||||
}
|
||||
|
||||
export {
|
||||
listDataExtracts,
|
||||
getDataExtractById
|
||||
};
|
@ -1,6 +1,6 @@
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
export const transporter = nodemailer.createTransport({
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.MAIL_SERVER_HOST,
|
||||
port: parseInt(process.env.MAIL_SERVER_PORT),
|
||||
secure: false,
|
||||
@ -9,3 +9,7 @@ export const transporter = nodemailer.createTransport({
|
||||
pass: process.env.MAIL_SERVER_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
export {
|
||||
transporter
|
||||
};
|
||||
|
@ -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';
|
||||
|
||||
|
||||
/**
|
||||
@ -12,7 +13,7 @@ import { transporter } from './email';
|
||||
* @param email the email address for which to generate a password reset token
|
||||
* @param siteOrigin the origin of the website, without a path element - e.g. https://beta.colouring.london
|
||||
*/
|
||||
export async function sendPasswordResetToken(email: string, siteOrigin: string): Promise<void> {
|
||||
async function sendPasswordResetToken(email: string, siteOrigin: string): Promise<void> {
|
||||
const user = await userService.getUserByEmail(email);
|
||||
|
||||
// if no user found for email, do nothing
|
||||
@ -23,7 +24,8 @@ export async function sendPasswordResetToken(email: string, siteOrigin: string):
|
||||
await transporter.sendMail(message);
|
||||
}
|
||||
|
||||
export async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
|
||||
async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
|
||||
validatePassword(newPassword);
|
||||
const userId = await verifyPasswordResetToken(passwordResetToken);
|
||||
if (userId != undefined) {
|
||||
await updatePasswordForUser(userId, newPassword);
|
||||
@ -33,7 +35,7 @@ export async function resetPassword(passwordResetToken: string, newPassword: str
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenVerificationError extends Error {
|
||||
class TokenVerificationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TokenVerificationError";
|
||||
@ -119,3 +121,8 @@ async function updatePasswordForUser(userId: string, newPassword: string): Promi
|
||||
`, [newPassword, userId]);
|
||||
}
|
||||
|
||||
export {
|
||||
sendPasswordResetToken,
|
||||
resetPassword,
|
||||
TokenVerificationError
|
||||
};
|
||||
|
@ -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);
|
||||
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,27 +1,30 @@
|
||||
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 './pages/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
|
||||
@ -35,35 +38,28 @@ import PasswordReset from './password-reset';
|
||||
* map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in
|
||||
* 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});
|
||||
}
|
||||
@ -76,175 +72,12 @@ 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} />
|
||||
@ -263,6 +96,15 @@ class App extends React.Component<any, any> { // TODO: add proper types
|
||||
</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>
|
||||
|
@ -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 };
|
@ -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';
|
||||
|
||||
/**
|
||||
@ -19,6 +19,7 @@ class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
super(props);
|
||||
this.state = {collapseMenu: true};
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleNavigate = this.handleNavigate.bind(this);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
@ -27,22 +28,40 @@ 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">
|
||||
<NavLink to="/data-extracts.html" className="nav-link">
|
||||
Downloads
|
||||
</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 +69,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 +88,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,29 +1,45 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { LatLngExpression } from 'leaflet';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
|
||||
|
||||
import '../../node_modules/leaflet/dist/leaflet.css'
|
||||
import '../../../node_modules/leaflet/dist/leaflet.css'
|
||||
import './map.css'
|
||||
|
||||
import { HelpIcon } from './icons';
|
||||
import { HelpIcon } from '../components/icons';
|
||||
import Legend from './legend';
|
||||
import { parseCategoryURL } from '../parse';
|
||||
import { parseCategoryURL } from '../../parse';
|
||||
import SearchBox from './search-box';
|
||||
import ThemeSwitcher from './theme-switcher';
|
||||
|
||||
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
|
||||
|
||||
interface ColouringMapProps {
|
||||
building: any;
|
||||
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
|
||||
category: string;
|
||||
revision_id: number;
|
||||
selectBuilding: any;
|
||||
colourBuilding: any;
|
||||
}
|
||||
|
||||
interface ColouringMapState {
|
||||
theme: 'light' | 'night';
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom: number;
|
||||
}
|
||||
/**
|
||||
* Map area
|
||||
*/
|
||||
class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
building: PropTypes.object,
|
||||
mode: PropTypes.string,
|
||||
category: PropTypes.string,
|
||||
revision_id: PropTypes.number,
|
||||
selectBuilding: PropTypes.func,
|
||||
colourBuilding: PropTypes.func,
|
||||
match: PropTypes.object,
|
||||
history: PropTypes.object
|
||||
colourBuilding: PropTypes.func
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@ -37,7 +53,6 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleLocate = this.handleLocate.bind(this);
|
||||
this.themeSwitch = this.themeSwitch.bind(this);
|
||||
this.getMode = this.getMode.bind(this);
|
||||
}
|
||||
|
||||
handleLocate(lat, lng, zoom){
|
||||
@ -45,21 +60,13 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
zoom: zoom
|
||||
})
|
||||
}
|
||||
|
||||
getMode() {
|
||||
const isEdit = this.props.match.url.match('edit')
|
||||
const isMulti = this.props.match.url.match('multi')
|
||||
return isEdit? (isMulti? 'multi' : 'edit') : 'view';
|
||||
});
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
const mode = this.getMode()
|
||||
const lat = e.latlng.lat
|
||||
const lng = e.latlng.lng
|
||||
const newCat = parseCategoryURL(this.props.match.url);
|
||||
const mapCat = newCat || 'age';
|
||||
const mode = this.props.mode;
|
||||
const lat = e.latlng.lat;
|
||||
const lng = e.latlng.lng;
|
||||
fetch(
|
||||
'/api/buildings/locate?lat='+lat+'&lng='+lng
|
||||
).then(
|
||||
@ -67,17 +74,15 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
).then(function(data){
|
||||
if (data && data.length){
|
||||
const building = data[0];
|
||||
if (mode === 'multi') {
|
||||
if (mode === 'multi-edit') {
|
||||
// colour building directly
|
||||
this.props.colourBuilding(building);
|
||||
} else {
|
||||
this.props.selectBuilding(building);
|
||||
this.props.history.push(`/${mode}/${mapCat}/building/${building.building_id}.html`);
|
||||
}
|
||||
} else {
|
||||
// deselect but keep/return to expected colour theme
|
||||
this.props.selectBuilding(undefined);
|
||||
this.props.history.push(`/${mode}/${mapCat}.html`);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => console.error(err)
|
||||
@ -91,52 +96,56 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
}
|
||||
|
||||
render() {
|
||||
const position = [this.state.lat, this.state.lng];
|
||||
const position: LatLngExpression = [this.state.lat, this.state.lng];
|
||||
|
||||
// baselayer
|
||||
const key = OS_API_KEY
|
||||
const tilematrixSet = 'EPSG:3857'
|
||||
const key = OS_API_KEY;
|
||||
const tilematrixSet = 'EPSG:3857';
|
||||
const layer = (this.state.theme === 'light')? 'Light 3857' : 'Night 3857';
|
||||
const url = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}`
|
||||
const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.'
|
||||
const baseUrl = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}`;
|
||||
const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.';
|
||||
const baseLayer = <TileLayer
|
||||
url={baseUrl}
|
||||
attribution={attribution}
|
||||
/>;
|
||||
|
||||
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
|
||||
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
|
||||
|
||||
// colour-data tiles
|
||||
const isBuilding = /building/.test(this.props.match.url);
|
||||
const isEdit = /edit/.test(this.props.match.url);
|
||||
const cat = parseCategoryURL(this.props.match.url);
|
||||
const cat = this.props.category;
|
||||
const tilesetByCat = {
|
||||
age: 'date_year',
|
||||
size: 'size_storeys',
|
||||
location: 'location',
|
||||
like: 'likes',
|
||||
planning: 'conservation_area',
|
||||
}
|
||||
};
|
||||
const tileset = tilesetByCat[cat];
|
||||
// pick revision id to bust browser cache
|
||||
const rev = this.props.revision_id;
|
||||
const dataLayer = tileset?
|
||||
const dataLayer = tileset != undefined ?
|
||||
<TileLayer
|
||||
key={tileset}
|
||||
url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`}
|
||||
minZoom={9} />
|
||||
url={`/tiles/${tileset}/{z}/{x}/{y}{r}.png?rev=${rev}`}
|
||||
minZoom={9}
|
||||
/>
|
||||
: null;
|
||||
|
||||
// highlight
|
||||
const geometryId = (this.props.building) ? this.props.building.geometry_id : undefined;
|
||||
const highlight = `/tiles/highlight/{z}/{x}/{y}.png?highlight=${geometryId}`
|
||||
const highlightLayer = (isBuilding && this.props.building) ?
|
||||
const highlightLayer = this.props.building != undefined ?
|
||||
<TileLayer
|
||||
key={this.props.building.building_id}
|
||||
url={highlight}
|
||||
minZoom={14} />
|
||||
url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.building.geometry_id}&base=${tileset}`}
|
||||
minZoom={14}
|
||||
zIndex={100}
|
||||
/>
|
||||
: null;
|
||||
|
||||
const baseUrl = (this.state.theme === 'light')?
|
||||
'/tiles/base_light/{z}/{x}/{y}.png'
|
||||
: '/tiles/base_night/{z}/{x}/{y}.png'
|
||||
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="map-container">
|
||||
<Map
|
||||
center={position}
|
||||
zoom={this.state.zoom}
|
||||
@ -146,31 +155,32 @@ class ColouringMap extends Component<any, any> { // TODO: add proper types
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
onClick={this.handleClick}
|
||||
detectRetina={true}
|
||||
>
|
||||
<TileLayer url={url} attribution={attribution} />
|
||||
<TileLayer url={baseUrl} minZoom={14} />
|
||||
{ baseLayer }
|
||||
{ buildingBaseLayer }
|
||||
{ dataLayer }
|
||||
{ highlightLayer }
|
||||
<ZoomControl position="topright" />
|
||||
<AttributionControl prefix="" />
|
||||
<AttributionControl prefix=""/>
|
||||
</Map>
|
||||
{
|
||||
!isBuilding && this.props.match.url !== '/'? (
|
||||
<div className="map-notice">
|
||||
<HelpIcon /> {isEdit? 'Click a building to edit' : 'Click a building for details'}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
this.props.match.url !== '/'? (
|
||||
this.props.mode !== 'basic'? (
|
||||
<Fragment>
|
||||
{
|
||||
this.props.building == undefined ?
|
||||
<div className="map-notice">
|
||||
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<Legend slug={cat} />
|
||||
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
|
||||
<SearchBox onLocate={this.handleLocate} isBuilding={isBuilding} />
|
||||
<SearchBox onLocate={this.handleLocate} />
|
||||
</Fragment>
|
||||
) : null
|
||||
}
|
||||
</Fragment>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
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,14 +2,13 @@ import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './search-box.css';
|
||||
import { SearchIcon } from './icons';
|
||||
import { SearchIcon } from '../components/icons';
|
||||
/**
|
||||
* Search for location
|
||||
*/
|
||||
class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
onLocate: PropTypes.func,
|
||||
isBuilding: PropTypes.bool
|
||||
onLocate: PropTypes.func
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@ -159,7 +158,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
</ul>
|
||||
: null;
|
||||
return (
|
||||
<div className={`search-box ${this.props.isBuilding? 'building' : ''}`} onKeyDown={this.handleKeyPress}>
|
||||
<div className="search-box" onKeyDown={this.handleKeyPress}>
|
||||
<form onSubmit={this.search} className="form-inline">
|
||||
<div onClick={this.state.smallScreen ? this.expandSearch : null}>
|
||||
<SearchIcon/>
|
@ -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>
|
90
app/src/frontend/pages/data-extracts.tsx
Normal file
90
app/src/frontend/pages/data-extracts.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
|
||||
import { dateReviver } from '../../helpers';
|
||||
|
||||
interface ExtractViewModel {
|
||||
extract_id: number;
|
||||
extracted_on: Date;
|
||||
download_path: string;
|
||||
}
|
||||
|
||||
interface DataExtractsState {
|
||||
extracts: ExtractViewModel[];
|
||||
latestExtract: ExtractViewModel;
|
||||
previousExtracts: ExtractViewModel[];
|
||||
}
|
||||
|
||||
export default class DataExtracts extends React.Component<{}, DataExtractsState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
extracts: undefined,
|
||||
latestExtract: undefined,
|
||||
previousExtracts: undefined
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const res = await fetch('/api/extracts');
|
||||
const data = JSON.parse(await res.text(), dateReviver);
|
||||
const extracts = (data.extracts as ExtractViewModel[])
|
||||
.sort((a, b) => a.extracted_on.valueOf() - b.extracted_on.valueOf())
|
||||
.reverse();
|
||||
|
||||
|
||||
|
||||
this.setState({ extracts: extracts, latestExtract: extracts[0], previousExtracts: extracts.slice(1) });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1 className="h2">Open data extracts</h1>
|
||||
<p>Choose one of the links below to download an archive containing the open data collected on the Colouring London platform</p>
|
||||
{
|
||||
this.state.extracts == undefined ?
|
||||
<p>Loading extracts...</p> :
|
||||
(
|
||||
this.state.extracts.length === 0 ?
|
||||
<p>No extracts available</p> :
|
||||
null
|
||||
)
|
||||
}
|
||||
{
|
||||
this.state.latestExtract != undefined ?
|
||||
<div>
|
||||
<h1 className="h3">Latest extract</h1>
|
||||
<ExtractDownloadLink {...this.state.latestExtract} />
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
{
|
||||
this.state.previousExtracts && this.state.previousExtracts.length > 0 ?
|
||||
(<div>
|
||||
<h1 className="h3">Older extracts</h1>
|
||||
<ul>
|
||||
{
|
||||
this.state.previousExtracts.map(e =>
|
||||
<li>
|
||||
<ExtractDownloadLink {...e} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>) :
|
||||
null
|
||||
}
|
||||
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const ExtractDownloadLink: FunctionComponent<ExtractViewModel> = (props) => (
|
||||
<p><a href={props.download_path}>Extracted on {props.extracted_on.toDateString()}</a></p>
|
||||
);
|
@ -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;
|
@ -2,9 +2,9 @@ import React, { Component } from 'react';
|
||||
import { Redirect, Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ErrorBox from './error-box';
|
||||
import InfoBox from './info-box';
|
||||
import SupporterLogos from './supporter-logos';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
|
||||
class Login extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
@ -2,8 +2,8 @@ import React, { Component, FormEvent } from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ErrorBox from './error-box';
|
||||
import ConfirmationModal from './confirmation-modal';
|
||||
import ConfirmationModal from '../components/confirmation-modal';
|
||||
import ErrorBox from '../components/error-box';
|
||||
|
||||
class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
@ -116,7 +116,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
<ErrorBox msg={this.state.error} />
|
||||
<form onSubmit={this.handleLogout}>
|
||||
<div className="buttons-container">
|
||||
<Link to="/edit/age.html" className="btn btn-warning">Start colouring</Link>
|
||||
<Link to="/edit/age" className="btn btn-warning">Start colouring</Link>
|
||||
<input className="btn btn-secondary" type="submit" value="Log out"/>
|
||||
</div>
|
||||
</form>
|
@ -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 {
|
@ -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;
|
15
app/src/helpers.ts
Normal file
15
app/src/helpers.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* A function to be passed to JSON.parse as a JSON reviver, in order to transform date values
|
||||
* (which don't have a native JSON representation and therefore are serialized as strings)
|
||||
* back to a JavaScript Date object.
|
||||
* This works by first checking if a string value complies with a date format
|
||||
* and then converting to a Date if and only if that's the case
|
||||
* @param name name of the JSON field to revive
|
||||
* @param value value of the JSON field to revive
|
||||
*/
|
||||
export function dateReviver(name, value) {
|
||||
if (typeof value === "string" && /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d.\d\d\dZ$/.test(value)) {
|
||||
return new Date(value);
|
||||
}
|
||||
return value;
|
||||
}
|
@ -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) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user