Merge frontend/api into universal react app

This commit is contained in:
Tom Russell 2018-09-09 22:22:44 +01:00
parent 3197fd937b
commit a005643746
66 changed files with 15077 additions and 0 deletions

12
app/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
logs
*.log
npm-debug.log*
.DS_Store
coverage
node_modules
build
.env.local
.env.development.local
.env.test.local
.env.production.local

12898
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
app/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "colouring-london-app",
"version": "1.0.0",
"description": "Render frontend (server- and client-side)",
"private": true,
"license": "MIT",
"scripts": {
"start": "razzle start",
"build": "razzle build",
"test": "razzle test --env=jsdom",
"start:prod": "NODE_ENV=production node build/server.js"
},
"dependencies": {
"body-parser": "^1.18.3",
"bootstrap": "^4.1.3",
"connect-pg-simple": "^5.0.0",
"express": "4.16.3",
"express-session": "^1.15.6",
"leaflet": "^1.3.4",
"razzle": "2.4.0",
"react": "16.4.2",
"react-dom": "16.4.2",
"react-leaflet": "^2.0.1",
"react-leaflet-universal": "^1.2.0",
"react-router-dom": "4.3.1",
"serialize-javascript": "^1.5.0"
},
"devDependencies": {
"babel-eslint": "^7.2.3"
}
}

23
app/public/404.html Executable file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Not found | Colouring London</title>
</head>
<body>
<h1 class="h2">Page not found&hellip;</h1>
<p>
This is a public prototype (beta) site for the Colouring London project.
</p>
<p>
If you're interested to follow the project, you can
sign up for updates or read more about the project.
</p>
<div class="button-container">
<a href="/about#sign-up" class="btn btn-outline-dark btn-half">Sign up for updates</a>
<a href="/about" class="btn btn-outline-dark btn-half">Read more about the project</a>
</div>
</body>
</html>

23
app/public/500.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Error | Colouring London</title>
</head>
<body>
<h1 class="h2">Server error&hellip;</h1>
<p>
This is a public prototype (beta) site for the Colouring London project.
</p>
<p>
If you're interested to follow the project, you can
sign up for updates or read more about the project.
</p>
<div class="button-container">
<a href="/about#sign-up" class="btn btn-outline-dark btn-half">Sign up for updates</a>
<a href="/about" class="btn btn-outline-dark btn-half">Read more about the project</a>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
app/public/images/edit.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
app/public/images/logo-casa.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
app/public/images/logo-gla.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
app/public/images/logo-he.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
app/public/images/logo-os.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
app/public/images/logo-ucl.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
app/public/images/showcase.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
app/public/images/view.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

2
app/public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *

13
app/razzle.config.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
modify: (config, { target, dev }, webpack) => {
// load webfonts
rules = config.module.rules || [];
rules.push({
test: /\.(eot|svg|ttf|woff|woff2)$/,
loader: 'file-loader?name=public/fonts/[name].[ext]'
})
config.module.rules = rules;
return config;
},
};

41
app/src/building.js Normal file
View File

@ -0,0 +1,41 @@
import { query } from './db';
function queryBuildingAtPoint(lng, lat) {
return query(
`SELECT
b.building_id as id,
b.building_doc as doc,
g.geometry_id as geometry_id
FROM buildings as b, geometries as g
WHERE
b.geometry_id = g.geometry_id
AND
ST_Intersects(
ST_Transform(
ST_SetSRID(ST_Point($1, $2), 4326),
3857
),
g.geometry_geom
)
LIMIT 1
`,
[lng, lat]
).then(function(data){
const rows = data.rows
if (rows.length){
const id = rows[0].id
const doc = rows[0].doc
const geometry_id = rows[0].geometry_id
doc.id = id
doc.geometry_id = geometry_id
return doc
}
return null;
}).catch(function(error){
console.error(error);
return null;
});
}
export { queryBuildingAtPoint };

18
app/src/client.js Normal file
View File

@ -0,0 +1,18 @@
import BrowserRouter from 'react-router-dom/BrowserRouter';
import React from 'react';
import { hydrate } from 'react-dom';
import App from './frontend/app';
const data = window.__PRELOADED_STATE__;
hydrate(
<BrowserRouter>
<App user={data.user} />
</BrowserRouter>,
document.getElementById('root')
);
if (module.hot) {
module.hot.accept();
}

17
app/src/db.js Normal file
View File

@ -0,0 +1,17 @@
/**
* Expose query interface to database pool
*
* - connection details must be set in environment variables, default to:
* PGHOST='localhost'
* PGUSER=process.env.USER
* PGDATABASE=process.env.USER
* PGPASSWORD=null
* PGPORT=5432
*/
import { Pool } from 'pg';
const pool = new Pool()
const query = (text, params) => pool.query(text, params);
export { pool, query }

245
app/src/frontend/about.js Normal file
View File

@ -0,0 +1,245 @@
import React from 'react';
const AboutPage = () => (
<article>
<div className="main-col">
<h1 className="h2">
Can you help us capture information on every building in London?
</h1>
<p>
How many buildings are there in London? What are their characteristics? Where
are they located and how do they contribute to the city? How adaptable are
they? How long will they last, and what are the environmental and
socio-economic implications of demolition?
</p>
<p>
Colouring London is being designed to address these questions by crowdsourcing
and visualising information on Londons buildings. Were releasing the
prototype for testing in the late summer. See the slideshow below for what it
will look like.
</p>
<div className="buttons-container btn-center">
<a className="btn btn-outline-dark btn-lg" href="#sign-up">Sign up for updates</a>
</div>
<div className="carousel">
<button className="carousel-control offscreen-text back">Back</button>
<ul className="carousel-content">
<li><img src="images/slide-1-welcome.png" alt="Welcome" /></li>
<li><img src="images/slide-2-categories.png" alt="Categories" /></li>
<li><img src="images/slide-3-edit.png" alt="Add/edit data" /></li>
<li><img src="images/slide-4-view.png" alt="View maps" /></li>
<li><img src="images/slide-5-download.png" alt="Download data" /></li>
<li><img src="images/slide-6-showcase.png" alt="Showcase" /></li>
<li><img src="images/slide-7-partners.png" alt="Partners" /></li>
</ul>
<button className="carousel-control offscreen-text next">Next</button>
</div>
<div className="buttons-container btn-center">
<a className="btn btn-outline-dark btn-lg"
href="files/colouring-london-online-exhibition.pdf">
View online exhibition</a>
</div>
</div>
<hr/>
<div className="main-col">
<p>
Colouring London is being designed and built by the Centre for Advanced Spatial
Analysis (CASA), University College London and funded by Historic England.
Ordnance Survey is providing building footprints required to collect the data,
facilitated by the GLA, and giving access to its API and technical support. It
will launch in 2019.
</p>
<ul className="logo-list">
<li>
<a href="https://www.ucl.ac.uk/bartlett/casa/">
<img src="images/logo-casa.png"
alt="Centre for Advanced Spatial Analysis (CASA)" />
</a>
</li>
<li>
<a href="https://www.ucl.ac.uk/">
<img src="images/logo-ucl.png"
alt="University College London" />
</a>
</li>
<li>
<a href="https://www.historicengland.org.uk/">
<img src="images/logo-he.png"
alt="Historic England" />
</a>
</li>
<li>
<a href="https://www.ordnancesurvey.co.uk/">
<img src="images/logo-os.png"
alt="Ordnance Survey" />
</a>
</li>
<li>
<a href="https://www.london.gov.uk/">
<img src="images/logo-gla.png"
alt="Supported by the Mayor of London" />
</a>
</li>
</ul>
</div>
<hr/>
<div className="main-col">
<h2 className="h1">Data Categories</h2>
<p>
12 categories have been chosen in consultation with specialists working in a
range of areas, from energy analysis and sustainable urban planning and design
to building conservation, community planning, architecture and historical
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>
</div>
<hr/>
<div className="main-col">
<h2 className="h1">Once built, our platform will allow you to:</h2>
</div>
<section className="pale-pink">
<div className="main-col">
<h3 className="h2">View maps</h3>
<p>
To view the data, navigate to the View Maps page and find the category that
interests you.
</p>
<img className="border-image" src="images/slide-4-view.png"
alt="Preview of view maps page" />
</div>
</section>
<section className="pale-yellow">
<div className="main-col">
<h3 className="h2">Add and edit data</h3>
<p>
Find a building and add or edit data for any of the 12 core categories.
</p>
<img className="border-image" src="images/slide-3-edit.png"
alt="Preview of add/edit data page" />
</div>
</section>
<section className="pale-orange">
<div className="main-col">
<h3 className="h2">See how people are using our data</h3>
<p>
Find links to visualisations and analysis, art projects and applications
relating to the evolution of London, housing, energy, planning, heritage and
history&mdash;or something we havent imagined yet.
</p>
<img className="border-image" src="images/slide-6-showcase.png"
alt="Preview of data showcase page" />
</div>
</section>
<section className="pale-green">
<div className="main-col">
<h3 className="h2">Download, remix and reuse</h3>
<p>
Access bulk downloads of data created through the project to use and reuse
under a liberal open data license. Let us know and well feature showcase
projects on the Colouring London site.
</p>
<img className="border-image" src="images/slide-5-download.png"
alt="Preview of download page" />
</div>
</section>
<div className="main-col">
<form id="sign-up" action="https://tinyletter.com/colouringlondon" method="post"
target="popupwindow"
onsubmit="window.open(
'https://tinyletter.com/colouringlondon',
'popupwindow',
'scrollbars=yes,width=800,height=600'); return true">
<h3 className="h1">Keep in touch</h3>
<p>
Receive occasional newsletters about the project as it develops. You can
read previous newsletters in our <a
href="https://tinyletter.com/colouringlondon/archive"
target="_blank" rel="noopener noreferrer">newsletter archive</a>.
</p>
<label for="tlemail">Enter your email address:</label>
<input className="form-control" type="email" name="email" id="tlemail" placeholder="name@example.com" />
<input type="hidden" value="1" name="embed"/>
<small className="form-text text-muted">
<a href="https://tinyletter.com" target="_blank" rel="noopener noreferrer">
powered by TinyLetter</a>.
We'll never share your email address.
</small>
<div className="buttons-container">
<input className="btn btn-outline-dark btn-block" type="submit" value="Sign up for updates" />
</div>
</form>
</div>
</article>
);
export default AboutPage;

68
app/src/frontend/app.js Normal file
View File

@ -0,0 +1,68 @@
import React, { Fragment } from 'react';
import { Route, Switch } from 'react-router-dom';
import AboutPage from './about';
import BetaBanner from './beta-banner';
import Header from './header';
import Login from './login';
import ColouringMap from './map';
import SignUp from './signup';
import Welcome from './welcome';
import '../../node_modules/bootstrap/dist/css/bootstrap.min.css';
import './main.css'
import MyAccountPage from './my-account';
class App extends React.Component {
constructor(props) {
super(props);
this.state = { user: props.user };
this.login = this.login.bind(this);
this.logout = this.logout.bind(this);
}
login(user) {
console.log(user)
this.setState({user: user});
}
logout(user) {
this.setState({user: undefined});
}
render() {
return (
<Fragment>
<BetaBanner />
<Header user={this.state.user} />
<main className="beta">
<Switch>
<Route exact path="/" component={Welcome} />
<Route exact path="/about.html" component={AboutPage} />
<Route exact path="/maps.html" component={ColouringMap} />
<Route exact path="/login.html">
<Login user={this.state.user} login={this.login} />
</Route>
<Route exact path="/sign-up.html">
<SignUp user={this.state.user} login={this.login} />
</Route>
<Route exact path="/my-account.html">
<MyAccountPage user={this.state.user} logout={this.logout} />
</Route>
<Route component={NotFound} />
</Switch>
</main>
</Fragment>
);
}
}
const NotFound = () => (
<article>
<section className="main-col">
<h1>Not found&hellip;</h1>
</section>
</article>
);
export default App;

View File

@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { MemoryRouter } from 'react-router-dom';
import App from './app';
describe('<App />', () => {
test('renders without exploding', () => {
const div = document.createElement('div');
ReactDOM.render(
<MemoryRouter>
<App />
</MemoryRouter>,
div
);
});
});

View File

@ -0,0 +1,22 @@
/**
* Banner
*/
.beta-banner {
width: 100%;
min-height: 32px;
font-size: 0.8333rem;
padding: 0.25em 1em;
background: #505050;
color: #fff;
margin: 0;
}
.beta-banner a:hover,
.beta-banner a:focus,
.beta-banner a {
color: #fff;
border-bottom-color: #fff;
}
.beta-banner a:hover,
.beta-banner a:focus {
border-bottom-width: 2px;
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import './beta-banner.css';
/**
* Banner for beta site
*/
const BetaBanner = () => (
<p className="beta-banner" role="alert" >
Welcome to the prototype (beta) site for Colouring London. <a
href="http://colouringlondon.org">Find out more about the project.</a>
</p>
);
export default BetaBanner;

View File

@ -0,0 +1,79 @@
import React from 'react';
const BuildingEdit = () => (
<div id="legend" className="info-container">
<h2 className="h2">Edit building data</h2>
<form action="building-view.html" method="GET">
<fieldset className="data-section">
<legend
className="h3 bullet-prefix location toggled-on"
data-toggle="collapse"
data-target="#data-list-location">Location</legend>
<div id="data-list-location" className="data-list collapse show">
<label for="">Building name</label>
<input className="form-control" type="text" value="" />
<label for="">Building number</label>
<input className="form-control" type="text" value="" />
<label for="">Street</label>
<input className="form-control" type="text" value="" />
<label for="">Address line 2</label>
<input className="form-control" type="text" value="" />
<label for="">Town</label>
<input className="form-control" type="text" value="" />
<label for="">Postcode</label>
<input className="form-control" type="text" value="" />
</div>
</fieldset>
<fieldset className="data-section">
<legend
className="h3 bullet-prefix age"
data-toggle="collapse"
data-target="#data-list-age">Age</legend>
<div id="data-list-age" className="data-list collapse">
<label for="">Year built (best estimate)</label>
<input className="form-control" type="number" step="1" value="2018" />
<label for="">Year built (upper estimate)</label>
<input className="form-control" type="number" step="1" value="2018" />
<label for="">Year built (lower estimate)</label>
<input className="form-control" type="number" step="1" value="2018" />
<label for="">Facade date</label>
<input className="form-control" type="number" step="1" value="" />
<label for="">Source</label>
<input className="form-control" type="text" />
</div>
</fieldset>
<fieldset className="data-section">
<legend
className="h3 bullet-prefix size"
data-toggle="collapse"
data-target="#data-list-size">Size</legend>
<div id="data-list-size" className="data-list collapse">
<label for="">Attic storeys</label>
<input className="form-control" type="number" step="1" value="0" />
<label for="">Core storeys</label>
<input className="form-control" type="number" step="1" value="3" />
<label for="">Basement storeys</label>
<input className="form-control" type="number" step="1" value="1" />
</div>
</fieldset>
<fieldset className="data-section">
<legend
className="h3 bullet-prefix like"
data-toggle="collapse"
data-target="#data-list-like">Like Me!</legend>
<div id="data-list-like" className="data-list collapse">
<label for="">Like this building?</label>
<div className="form-check">
<input className="form-check-input position-static" type="checkbox" checked />
</div>
</div>
</fieldset>
<div className="buttons-container">
<a href="/building/id" className="btn btn-secondary">Cancel</a>
<button type="submit" className="btn btn-primary">Save</button>
</div>
</form>
</div>
);
export default BuildingEdit;

View File

@ -0,0 +1,94 @@
import React from 'react';
const BuildingView = () => (
<div id="legend" class="info-container">
<h2 class="h2">Building data</h2>
<section class="data-section">
<h3 class="h3 bullet-prefix location toggled-on"
data-toggle="collapse"
data-target="#data-list-location">
Location
</h3>
<p class="data-intro">
Section introduction of up to roughly 100 characters will take
approx&shy;imately this much space.
<a href="#">Read more</a>.
</p>
<dl id="data-list-location" class="data-list collapse show">
<dt>
Building Name
<span class="tooltip-hook" data-toggle="tooltip">
?
<div class="tooltip bs-tooltip-bottom">
<div class="arrow"></div>
<div class="tooltip-inner">
Hint tooltip content should be ~40 chars.
</div>
</div>
</span>
</dt>
<dd><span class="no-data">no data</span></dd>
<dt>Building Number</dt>
<dd><span class="no-data">no data</span></dd>
<dt>Street</dt>
<dd><span class="no-data">no data</span></dd>
<dt>Address line 2</dt>
<dd><span class="no-data">no data</span></dd>
<dt>Town</dt>
<dd><span class="no-data">no data</span></dd>
<dt>Postcode</dt>
<dd><span class="no-data">no data</span></dd>
</dl>
</section>
<section class="data-section">
<h3 class="h3 bullet-prefix age"
data-toggle="collapse"
data-target="#data-list-age">Age</h3>
<dl id="data-list-age" class="data-list collapse">
<dt>Year built (best estimate)</dt>
<dd>2018</dd>
<dt>Year built (lower estimate)</dt>
<dd>2018</dd>
<dt>Year built (upper estimate)</dt>
<dd>2018</dd>
<dt>Date Source</dt>
<dd>Pevsner</dd>
<dt>Facade date</dt>
<dd>2018</dd>
</dl>
</section>
<section class="data-section">
<h3 class="h3 bullet-prefix size"
data-toggle="collapse"
data-target="#data-list-size">Size</h3>
<dl id="data-list-size" class="data-list collapse">
<dt>Attic storeys</dt>
<dd>0</dd>
<dt>Core storeys</dt>
<dd>3</dd>
<dt>Basement storeys</dt>
<dd>1</dd>
</dl>
</section>
<section class="data-section">
<h3 class="h3 bullet-prefix like"
data-toggle="collapse"
data-target="#data-list-like">Like Me!</h3>
<dl id="data-list-like" class="data-list collapse">
<dt>Likes</dt>
<dd> 25</dd>
</dl>
</section>
<div class="buttons-container">
<a href="/maps" class="btn btn-secondary">Back to maps</a>
<a href="/building/id/edit" class="btn btn-primary">Edit data</a>
</div>
</div>
);
export default BuildingView;

View File

@ -0,0 +1,15 @@
/**
* Main header
*/
.main-header {
display: block;
min-height: 82px;
text-decoration: none;
border-bottom: 3px solid #222;
}
.main-header .navbar {
padding: 0.75em 0.75em;
}
.main-header .navbar-brand {
margin: 0 1em 0 0;
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import Logo from './logo';
import './header.css';
/**
* Main Header
*/
function Header(props) {
if (props.user) {
return (
<header className="main-header">
<nav className="navbar navbar-light navbar-expand-md">
<Logo />
<ul className="navbar-nav ml-auto">
<li className="nav-item">
<NavLink to="/maps.html" className="nav-link">View Maps</NavLink>
</li>
<li className="nav-item">
<NavLink to="/about.html" className="nav-link">About</NavLink>
</li>
<li className="nav-item">
<NavLink to="/my-account.html" className="nav-link">My account (Logged in as {props.user.username})</NavLink>
</li>
</ul>
</nav>
</header>
);
} else {
return (
<header className="main-header">
<nav className="navbar navbar-light navbar-expand-md">
<Logo />
<ul className="navbar-nav ml-auto">
<li className="nav-item">
<NavLink to="/maps.html" className="nav-link">View Maps</NavLink>
</li>
<li className="nav-item">
<NavLink to="/about.html" className="nav-link">About</NavLink>
</li>
<li className="nav-item">
<NavLink to="/login.html" className="nav-link">Log in</NavLink>
</li>
<li className="nav-item">
<NavLink to="/sign-up.html" className="nav-link">Sign up</NavLink>
</li>
</ul>
</nav>
</header>
);
}
}
export default Header;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

99
app/src/frontend/login.js Normal file
View File

@ -0,0 +1,99 @@
import React, { Component } from 'react';
import { Redirect, Link } from 'react-router-dom';
class Login extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
password: '',
show_password: ''
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
const target = event.target;
const value = target.type === 'checkbox' ? target.checked : target.value;
const name = target.name;
this.setState({
[name]: value
});
}
handleSubmit(event) {
event.preventDefault();
const login = this.props.login
fetch('/login', {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
}
}).then(
res => res.json()
).then(function(res){
if (res.error) {
console.error(res.error); // tell user
} else {
console.log(res); // redirect back
fetch('/users/me').then(
(res) => res.json()
).then(function(user){
console.log(user)
login(user);
}).catch(function(err){
console.error(err);
})
}
}).catch(
err => console.error(err)
);
}
render() {
if (this.props.user) {
return <Redirect to="/my-account.html" />
}
return (
<article>
<section className="main-col">
<h1 className="h2">Log in</h1>
<form onSubmit={this.handleSubmit}>
<label htmlFor="username">Username*</label>
<input name="username" id="username"
className="form-control" type="text"
value={this.state.username} onChange={this.handleChange}
placeholder="not-your-real-name" required
/>
<label htmlFor="password">Password</label>
<input name="password" id="password"
className="form-control"
type={(this.state.show_password)? 'text': 'password'}
value={this.state.password} onChange={this.handleChange}
required
/>
<div className="form-check">
<input id="show_password" name="show_password"
className="position-static" type="checkbox"
checked={this.state.show_password}
onChange={this.handleChange}
/>
<label htmlFor="show_password">Show password?</label>
</div>
<input type="submit" value="Log In" className="btn btn-primary" />
<Link to="sign-up.html">Sign Up</Link>
</form>
</section>
</article>
)
}
}
export default Login;

167
app/src/frontend/logo.css Normal file
View File

@ -0,0 +1,167 @@
/**
* Logo
*/
.logo {
display: block;
width: 6em;
padding: 0;
text-transform: uppercase;
color: #000;
text-decoration: none;
}
.logo .logotype {
font-family: 'glacial_cl', sans-serif;
font-size: 2rem;
margin: 0 0 0 3px;
display: inline-block;
vertical-align: bottom;
}
.logo .logotype span {
display: block;
font-size: 0.75em;
letter-spacing: 0.15em;
}
.logo .logotype span:first-child {
font-size: 0.625em;
letter-spacing: 0;
}
.logo .grid {
position: relative;
top: -1px;
display: inline-block;
vertical-align: bottom;
font-size: 0;
}
.logo .row {
display: block;
margin: 0 0 5px 0;
}
.logo .row:last-child {
margin-bottom: 0;
}
.logo .cell {
display: inline-block;
width: 15px;
height: 15px;
margin: 0 3px 0 0;
}
.logo .row:nth-child(1) .cell:nth-child(1) {
animation: pulse 47s infinite;
animation-delay: -1.5s;
}
.logo .row:nth-child(1) .cell:nth-child(2) {
animation: pulse 32s infinite;
animation-delay: -0.5s;
}
.logo .row:nth-child(1) .cell:nth-child(3) {
animation: pulse 49s infinite;
animation-delay: -6s;
}
.logo .row:nth-child(1) .cell:nth-child(4) {
animation: pulse 35s infinite;
animation-delay: -10s;
}
.logo .row:nth-child(2) .cell:nth-child(1) {
animation: pulse 34s infinite;
animation-delay: -7.2s;
}
.logo .row:nth-child(2) .cell:nth-child(2) {
animation: pulse 58s infinite;
animation-delay: -15s;
}
.logo .row:nth-child(2) .cell:nth-child(3) {
animation: pulse 31s infinite;
animation-delay: -5s;
}
.logo .row:nth-child(2) .cell:nth-child(4) {
animation: pulse 46s infinite;
animation-delay: -4.5s;
}
.logo .row:nth-child(3) .cell:nth-child(1) {
animation: pulse 32s infinite;
animation-delay: -3.5s;
}
.logo .row:nth-child(3) .cell:nth-child(2) {
animation: pulse 49s infinite;
animation-delay: -8.5s;
}
.logo .row:nth-child(3) .cell:nth-child(3) {
animation: pulse 35s infinite;
animation-delay: -4s;
}
.logo .row:nth-child(3) .cell:nth-child(4) {
animation: pulse 34s infinite;
animation-delay: -17s;
}
@keyframes pulse {
0%, 100% {
background-color: #ffad00;
}
8% {
background-color: #ffad00;
}
10% {
background-color: #72b2fe;
}
16% {
background-color: #72b2fe;
}
18% {
background-color: #5ec233;
}
24% {
background-color: #5ec233;
}
26% {
background-color: #e96762;
}
32% {
background-color: #e96762;
}
34% {
background-color: #e099c1;
}
40% {
background-color: #e099c1;
}
42% {
background-color: #7d6f94;
}
48% {
background-color: #7d6f94;
}
50% {
background-color: #eb7905;
}
56% {
background-color: #eb7905;
}
58% {
background-color: #72b889;
}
64% {
background-color: #72b889;
}
66% {
background-color: #f0d106;
}
72% {
background-color: #f0d106;
}
74% {
background-color: #a6a6a7;
}
80% {
background-color: #a6a6a7;
}
82% {
background-color: #918e6e;
}
88% {
background-color: #918e6e;
}
90% {
background-color: #ffad00;
}
}

37
app/src/frontend/logo.js Normal file
View File

@ -0,0 +1,37 @@
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;

344
app/src/frontend/main.css Executable file
View File

@ -0,0 +1,344 @@
@import 'styles/typography.css';
@import 'styles/colours.css';
@import 'styles/content.css';
/**
* Main Layout
*/
main.beta {
position: relative;
min-height: 35rem;
}
@media (min-width: 768px){
main.beta {
position: absolute;
top: 117px; /* 32px banner + 82px header + 3px border */
bottom: 0;
left: 0;
right: 0;
min-height: auto;
}
}
.info-container {
position: absolute;
top: 50%;
left: 0;
right: 0;
bottom: 3rem;
padding: 0.25em 0.75em;
background: #fff;
background-color: rgba(255,255,255,0.95);
z-index: 1000;
overflow-y: auto;
transform: translateX(0);
transition: transform 0.4s;
}
.info-container.offscreen {
transform: translateX(-100%);
}
.leaflet-container .leaflet-control-attribution {
width: 100%;
height: 3rem;
background: #fff;
background: rgba(255, 255, 255, 0.95);
}
.leaflet-right{
left: 0;
}
@media (min-width: 380px){
.info-container {
bottom: 2rem;
}
.leaflet-container .leaflet-control-attribution {
height: 2rem;
}
}
@media (min-width: 768px){
.info-container {
top: 0;
left: 0;
width: 20rem;
bottom: 0;
}
.info-container.offscreen {
transform: translateX(-20rem);
}
.leaflet-right{
left: 20rem;
}
.leaflet-container .leaflet-control-attribution {
height: auto;
}
}
/**
* Text pages
*/
article section {
overflow: hidden;
margin: 2.25em 0 4em;
padding: 2em 0 4em;
}
.main-col {
max-width: 40em;
margin: 0 auto;
padding-left: 1em;
padding-right: 1em;
}
hr {
display: block;
height: 1px;
border: 0;
background: #000;
width: 100%;
margin: 2em 0;
padding: 0;
}
/**
* View/edit, maps legend
*/
.maps-list {
list-style: none;
padding-left: 17px;
}
.bullet-prefix {
position: relative;
padding: 0rem 0.5rem 0.5rem 1.5rem;
cursor: pointer;
}
.bullet-prefix::before {
display: block;
position: absolute;
left: 0px;
top: 6px;
width: 10px;
height: 10px;
background-color: #7d7d7d;
content: ' ';
transition: background-color 0.2s;
}
.bullet-prefix:hover::before,
.bullet-prefix.toggled-on::before {
background-color: #222;
}
.tooltip-hook {
display: inline-block;
position: relative;
cursor: pointer;
color: #222;
background: #fff;
border: 1px solid #222;
width: 22px;
height: 22px;
border-radius: 11px;
padding: 0 0 0 1px;
font-size: 0.8rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
text-align: center;
}
.tooltip {
display: none;
opacity: 1;
min-width: 11em;
left: -3px;
top: 25px;
}
.tooltip .arrow {
left: 5px;
}
.tooltip-hook:hover .tooltip,
.tooltip-hook:hover + .tooltip {
display: block;
opacity: 1;
}
.data-section .h3 {
margin: 0;
}
.data-intro {
padding-left: 1.5rem;
font-size: 0.8333rem;
}
.data-list {
margin: 0rem 0 1rem;
padding-left: 1.5rem;
}
.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 {
margin: 0;
font-size: 0.8333rem;
font-weight: normal;
text-transform: uppercase;
color: #555;
}
.data-list dd,
.data-list input {
margin: 0 0 0.5rem;
line-height: 1.5;
}
.data-list .no-data {
color: #999;
}
.data-legend {
list-style: none;
padding: 0;
}
.data-legend .key {
display: inline-block;
width: 10px;
height: 10px;
overflow: hidden;
}
/**
* Forms
*/
input[type="text"],
input[type="number"],
input[type="password"],
input[type="email"] {
border-color: #999;
padding: 0.25rem 0.5rem;
border-radius: 0;
}
input[type="number"] {
padding-right: 0.25rem;
}
.form-check {
padding-left: 0;
}
label {
margin: 0.5em 0 0;
}
.buttons-container {
margin-bottom: 0.75rem;
}
form .btn {
margin-top: 1em;
}
.buttons-container.btn-center {
text-align: center;
}
.btn.btn-half {
width: 100%;
margin-bottom: 0.25rem;
}
@media (min-width: 768px) {
.btn.btn-half {
width: 49%;
margin-left: 0;
margin-right: 2%;
}
.btn.btn-half:nth-child(2n) {
margin-right: 0;
}
}
/**
* Carousel
*/
.carousel {
position: relative;
}
.carousel-control {
display: none;
}
.carousel.active .carousel-control {
display: block;
position: absolute;
top: 0;
bottom: 0;
width: 1.75em;
border: 0;
background-color: #fff;
background-position: center center;
background-size: contain;
background-repeat: no-repeat;
cursor: pointer;
}
.carousel.active .carousel-control:hover,
.carousel.active .carousel-control:active,
.carousel.active .carousel-control:focus {
border: 0;
outline: none;
opacity: 0.6;
}
.carousel button::-moz-focus-inner {
border:0;
}
.carousel-control.next {
right: -1em;
background-image: url('images/arrow-next.png');
}
.carousel-control.back {
left: -1em;
background-image: url('images/arrow-back.png');
}
.carousel-content {
padding: 0;
list-style: none;
}
.carousel-content li {
text-align: center;
}
.carousel.active .carousel-content li {
display: none;
}
.carousel.active .carousel-content li.current {
display: block;
}
/**
* Logos
*/
.logo-list {
text-align: center;
padding: 0;
list-style: none;
}
.logo-list li {
display: inline-block;
width: 8em;
padding: 0 0.25em;
vertical-align: middle;
}
.logo-list li:first-child {
width: 4em;
}
/**
* 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;
}

8
app/src/frontend/map.css Normal file
View File

@ -0,0 +1,8 @@
.leaflet-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}

43
app/src/frontend/map.js Normal file
View File

@ -0,0 +1,43 @@
import React, { Component } from 'react';
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
import '../../node_modules/leaflet/dist/leaflet.css'
import './map.css'
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
/**
* Map area
*/
class ColouringMap extends Component {
state = {
lat: 51.5245255,
lng: -0.1338422,
zoom: 16,
}
render() {
const position = [this.state.lat, this.state.lng];
const key = OS_API_KEY
const tilematrixSet = 'EPSG:3857'
const layer = 'Night 3857' // alternatively 'Light 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.'
return (
<Map
center={position}
zoom={this.state.zoom}
minZoom={12}
maxZoom={18}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}>
<TileLayer url={url} attribution={attribution}/>
<ZoomControl position="topright" />
<AttributionControl prefix="" />
</Map>
);
}
};
export default ColouringMap;

View File

@ -0,0 +1,49 @@
import React, { Component } from 'react';
import { Redirect } from 'react-router';
class MyAccountPage extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event) {
const logout = this.props.logout;
event.preventDefault();
fetch('/logout', {
method: 'POST'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
console.error(res.error); // tell user
} else {
logout();
console.log(res); // redirect back
}
}).catch(
err => console.error(err)
);
}
render() {
if (this.props && this.props.user) {
return (
<article>
<section className="main-col">
<h1 className="h3">Welcome, {this.props.user.username}</h1>
<form method="POST" action="/logout" onSubmit={this.handleSubmit}>
<input className="btn btn-secondary" type="submit" value="Log out"/>
</form>
</section>
</article>
);
} else {
return (
<Redirect to="/login.html" />
)
}
}
}
export default MyAccountPage;

127
app/src/frontend/signup.js Normal file
View File

@ -0,0 +1,127 @@
import React, { Component } from 'react';
import { Redirect, Link } from 'react-router-dom';
class SignUp extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
email: '',
confirm_email: '',
password: '',
show_password: '',
confirm_conditions: false
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
const target = event.target;
const value = target.type === 'checkbox' ? target.checked : target.value;
const name = target.name;
this.setState({
[name]: value
});
}
handleSubmit(event) {
event.preventDefault();
fetch('/users', {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
}
}).then(
res => res.json()
).then(function(res){
if (res.error) {
console.error(res.error); // tell user
} else {
console.log(res); // redirect back
fetch('/users/me').then(function(user){
this.props.login(user);
}).catch(function(err){
console.error(err);
})
}
}).catch(
err => console.error(err)
);
}
render() {
if (this.props.user) {
return <Redirect to="/my-account.html" />
}
return (
<article>
<section className="main-col">
<h1 className="h2">Sign up</h1>
<p>
Create an account to start colouring in.
</p>
<form onSubmit={this.handleSubmit}>
<label htmlFor="username">Username*</label>
<input name="username" id="username"
className="form-control" type="text"
value={this.state.username} onChange={this.handleChange}
placeholder="not-your-real-name" required
/>
<label htmlFor="email">Email (optional)</label>
<input name="email" id="email"
className="form-control" type="email"
value={this.state.email} onChange={this.handleChange}
placeholder="someone@example.com"
/>
<label htmlFor="confirm_email">Confirm email (optional)</label>
<input name="confirm_email" id="confirm_email"
className="form-control" type="email"
value={this.state.confirm_email} onChange={this.handleChange}
/>
<label htmlFor="password">Password</label>
<input name="password" id="password"
className="form-control"
type={(this.state.show_password)? 'text': 'password'}
value={this.state.password} onChange={this.handleChange}
required
/>
<div className="form-check">
<input id="show_password" name="show_password"
className="position-static" type="checkbox"
checked={this.state.show_password}
onChange={this.handleChange}
/>
<label htmlFor="show_password">Show password?</label>
</div>
<div className="form-check">
<input id="confirm_conditions" name="confirm_conditions"
className="position-static" type="checkbox"
checked={this.state.confirm_conditions}
onChange={this.handleChange}
required />
<label htmlFor="confirm_conditions">
I confirm that I have read and agree to the <a
href="/privacy-policy">privacy policy</a> and <a
href="/user-agreement">contributor agreement</a>.
</label>
</div>
<input type="submit" value="Sign Up" className="btn btn-primary" />
<Link to="login.html">Log in</Link>
</form>
</section>
</article>
)
}
}
export default SignUp;

View File

@ -0,0 +1,54 @@
/**
* Colours
*/
.white {
background-color: #fff;
}
.bold-yellow {
background-color: #ffad00;
}
.bright-yellow {
background-color: #f0d106;
}
.pale-yellow {
background-color: #fff021;
}
.bold-orange {
background-color: #eb7905;
}
.pale-orange {
background-color: #ffc04e;
}
.red {
background-color: #e96762;
}
.pastel-pink {
background-color: #e099c1;
}
.pale-pink {
background-color: #ffcde5;
}
.pastel-purple {
background-color: #7d6f94;
}
.blue-grey {
background-color: #6f879c;
}
.bright-green {
background-color: #5ec233;
}
.pastel-green {
background-color: #72b889;
}
.pale-green {
background-color: #73ebaf;
}
.bright-blue {
background-color: #72b2fe;
}
.pale-grey {
background-color: #a6a6a7;
}
.pale-brown {
background-color: #918e6e;
}

View File

@ -0,0 +1,80 @@
/**
* Text content
*/
img {
display: inline-block;
max-width: 100%;
}
.offscreen-text {
overflow: hidden;
text-indent: -999px;
}
figure img {
max-width: 100%;
}
figure a:hover img,
figure a:focus img,
figure a:active img {
opacity: 0.8;
}
.icon-pad {
padding: 0 4em 1em;
text-align: center;
}
.icon-pad svg {
max-width: 10em;
display: inline-block;
}
p a,
small a {
color: #52a5f8;
border-bottom: 1px solid #52a5f8;
}
p a:hover,
small a:hover,
p a:focus,
small a:focus,
p a:active,
small a:active {
color: #006fdf;
border-bottom-color: #006fdf;
text-decoration: none;
}
p a.btn:hover,
p a.btn:focus,
p a.btn:active {
color: #fff;
}
.text-muted {
color: #878d96 !important;
}
h1, h2, h3, h4 {
font-weight: normal;
}
.h1 {
font-size: 2em;
margin-bottom: 0.5em;
}
.h2 {
font-size: 1.5em;
margin: 0.25em 0 0.5em;
}
p, li, dd {
line-height: 1.3;
}
dd {
margin: 0 0 1.5em;
}
small {
font-size: 0.75em;
}
.text-muted {
color: #878d96 !important;
}
.border-image {
border: 1px solid #000;
}

View File

@ -0,0 +1,26 @@
/**
* Typography
*/
html,
body {
background: #fff;
color: #222;
}
.h1, .h2, .h3, .h4, .h5 {
font-family: 'glacial_cl', sans-serif;
}
.h2 {
font-weight: normal;
margin: 0;
}
.h3, .h4, .h5 {
font-family: 'glacial_cl', sans-serif;
font-weight: normal;
font-size: 1.2rem;
}
p {
font-size: 1.1rem;
}
pre {
white-space: pre-wrap;
}

View File

@ -0,0 +1,28 @@
/**
* Welcome jumbotron
*/
.welcome-float {
position: absolute;
z-index: 10000;
top: 0;
width: 100%;
border-radius: 0;
background: #fff;
background-color: rgba(255,255,255,0.95);
opacity: 1;
transition: opacity 0.4s;
}
.welcome-float.offscreen {
opacity: 0;
}
.welcome-float.remove {
display: none;
}
@media (min-width: 768px){
.welcome-float {
left: 50%;
margin-left: -20em;
width: 40em;
top: 1em;
}
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Link } from 'react-router-dom';
import './welcome.css';
const Welcome = () => (
<div className="jumbotron welcome-float">
<h1 className="h1">Welcome to Colouring London</h1>
<p className="lead">Colour in, view and download data on London's buildings</p>
<Link to="/maps.html" className="btn btn-outline-dark btn-lg btn-block">Get Started</Link>
</div>
);
export default Welcome;

26
app/src/index.js Normal file
View File

@ -0,0 +1,26 @@
import app from './server';
import http from 'http';
const server = http.createServer(app);
let currentApp = app;
server.listen(process.env.PORT || 3000, error => {
if (error) {
console.log(error);
}
console.log('🚀 started');
});
if (module.hot) {
console.log('✅ Server-side HMR Enabled!');
module.hot.accept('./server', () => {
console.log('🔁 HMR Reloading x`./server`...');
server.removeListener('request', currentApp);
const newApp = require('./server').default;
server.on('request', newApp);
currentApp = newApp;
});
}

203
app/src/server.js Normal file
View File

@ -0,0 +1,203 @@
import React from 'react';
import { StaticRouter } from 'react-router-dom';
import express from 'express';
import { renderToString } from 'react-dom/server';
import serialize from 'serialize-javascript';
const bodyParser = require('body-parser')
const session = require('express-session')
const pgSession = require('connect-pg-simple')(session);
import App from './frontend/app';
import { pool } from './db';
import { authUser, createUser, getUserById } from './user';
import { queryBuildingAtPoint } from './building';
// create server
const server = express();
// reference packed assets
const assets = require(process.env.RAZZLE_ASSETS_MANIFEST);
// disable header
server.disable('x-powered-by');
// serve static files
server.use(express.static(process.env.RAZZLE_PUBLIC_DIR));
// parse POSTed json body
server.use(bodyParser.json());
// handle user sessions
const sess = {
name: 'cl.session',
store: new pgSession({
pool: pool,
tableName : 'user_sessions'
}),
secret: process.env.APP_COOKIE_SECRET,
resave: false,
cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 } // 30 days
};
if (server.get('env') === 'production') {
// trust first proxy
server.set('trust proxy', 1)
// serve secure cookies
sess.cookie.secure = true
}
server.use(session(sess));
// handle HTML routes (server-side rendered React)
server.get('/*.html', frontendRoute);
server.get('/', frontendRoute);
function frontendRoute(req, res) {
const context = {};
const data = {};
if (req.session.user_id) {
getUserById(req.session.user_id).then(function(user){
data.user = user;
console.log(user);
renderHTML(context, data, req, res)
}).catch(function(){
renderHTML(context, data, req, res);
});
} else {
renderHTML(context, data, req, res);
}
}
function renderHTML(context, data, req, res){
const markup = renderToString(
<StaticRouter context={context} location={req.url}>
<App user={data.user} />
</StaticRouter>
);
if (context.url) {
res.redirect(context.url);
} else {
res.status(200).send(
`<!doctype html>
<html lang="">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta charset="utf-8" />
<title>Colouring London</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
@font-face {
font-family: 'glacial_cl';
src: url('/fonts/glacialindifference-regular-webfont.woff2') format('woff2'),
url('/fonts/glacialindifference-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
</style>
${
assets.client.css
? `<link rel="stylesheet" href="${assets.client.css}">`
: ''
}
${
process.env.NODE_ENV === 'production'
? `<script src="${assets.client.js}" defer></script>`
: `<script src="${assets.client.js}" defer crossorigin></script>`
}
</head>
<body>
<div id="root">${markup}</div>
<script>
window.__PRELOADED_STATE__ = ${serialize(data)}
</script>
</body>
</html>`
);
}
}
// GET building at point
server.get('/buildings', function(req, res){
const { lng, lat } = req.query
queryBuildingAtPoint(lng, lat).then(function(result){
if (result) {
res.send(result)
} else {
res.status(404).send({error:'Not Found'})
}
}).catch(function(error){
res.send({error:'Database error'})
})
})
// POST new user
server.post('/users', function(req, res){
const user = req.body;
if (req.session.user_id) {
res.send({error: 'Already signed in'});
}
if (user.email){
if (user.email != user.confirm_email) {
res.send({error: "Email did not match confirmation."});
}
} else {
user.email = null;
}
createUser(user).then(function(result){
if (result.user_id) {
req.session.user_id = result.user_id;
res.send({user_id: result.user_id});
} else {
req.session.user_id = undefined;
res.send({error: result.error});
}
}).catch(function(err){
console.error(err);
res.send({error: 'Server error'})
});
});
// POST user auth
server.post('/login', function(req, res){
authUser(req.body.username, req.body.password).then(function(user) {
if (user.user_id) {
req.session.user_id = user.user_id;
} else {
req.session.user_id = undefined;
}
res.send(user);
}).catch(function(error){
res.send(error);
})
});
// POST user logout
server.post('/logout', function(req, res){
req.session.destroy(function(err){
if (err) {
console.error(err);
res.send({error: 'Failed to end session'})
}
res.send({success: true});
});
});
// GET own user info
server.get('/users/me', 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);
});
});
export default server;

84
app/src/user.js Normal file
View File

@ -0,0 +1,84 @@
import { query } from './db';
function createUser(user) {
return query(
`INSERT
INTO users (
user_id,
username,
email,
pass
) VALUES (
gen_random_uuid(),
$1,
$2,
crypt($3, gen_salt('bf'))
) RETURNING user_id
`, [
user.username,
user.email,
user.password
]
).then(function(data){
return data.rows[0];
}).catch(function(error){
console.error('Error:', error)
if (error.detail.indexOf('already exists') !== -1){
if (error.detail.indexOf('username') !== -1){
return {error:'Username already registered'};
} else if (error.detail.indexOf('email') !== -1) {
return {error: 'Email already registered'};
}
}
return {error: 'Database error'}
});
}
function authUser(username, password) {
return query(
`SELECT
user_id,
(
pass = crypt($2, pass)
) AS auth_ok
FROM users
WHERE
username = $1
`, [
username,
password
]
).then(function(data){
const user = data.rows[0];
if (user.auth_ok) {
return {user_id: user.user_id}
} else {
return {error: 'Authentication failed'}
}
}).catch(function(err){
console.error(err);
return {error: 'Database error'};
})
}
function getUserById(user_id) {
return query(
`SELECT
username, email, registered
FROM users
WHERE
user_id = $1
`, [
user_id
]
).then(function(data){
return data.rows[0];
}).catch(function(error){
console.error('Error:', error)
return undefined;
});
}
export { getUserById, createUser, authUser }