VueJS rewrite;

This commit is contained in:
Benjamin Bouvier 2019-03-24 20:47:58 +01:00
parent 97b62ca02c
commit fd4ba2a8bb
23 changed files with 7030 additions and 188 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
*.log
.DS_Store
# poi dist
dist

47
README.md Normal file
View File

@ -0,0 +1,47 @@
# app
> My posh Vue project
## Commands
You can replace `yarn` with `npm run` here.
```bash
# build for production
yarn build
# development mode
yarn dev
# run unit tests
yarn test
# serve the bundled dist folder in production mode
yarn serve
```
## Polyfills
By default we only polyfill `window.Promise` and `Object.assign`. You can add more polyfills in `./src/polyfills.js`.
## Analyze bundle size
Run `yarn report` to get a report of bundle size which helps you:
- Realize what's really inside your bundle
- Find out what modules make up the most of it's size
- Find modules that got there by mistake
- Optimize it!
## Progress Web App
Your app is now offline-ready (only in production bundle), which means you can visit it without network.
Here we use a default [manifest.json](./static/manifest.json) to configurure your pwa, for example, to enable *Add to Home Screen* feature on Android. It will be copied directly to `./dist/manifest.json`.
For all the available options, please head to [poi-preset-offline](https://github.com/egoist/poi/tree/master/packages/poi-preset-offline#api).
---
This project is generated by [create-vue-app](https://github.com/vue-land/create-vue-app).

29
index.ejs Normal file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4DBA87">
<% if (htmlWebpackPlugin.options.description) { %>
<meta name="description" content="<%= htmlWebpackPlugin.options.description %>"/>
<% } %>
<% for (var chunk of webpack.chunks) {
for (var file of chunk.files) {
if (file.match(/\.(js|css)$/)) { %>
<link rel="<%= chunk.initial?'preload':'prefetch' %>" href="<%= htmlWebpackPlugin.files.publicPath + file %>" as="<%= file.match(/\.css$/)?'style':'script' %>"><% }}} %>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="app"></div>
</body>
</html>

View File

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Cool places in Lyon 7</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css"
integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA=="
crossorigin=""/>
<link href='https://api.mapbox.com/mapbox-gl-js/v0.53.0/mapbox-gl.css' rel='stylesheet' />
</head>
<body>
<div class="container">
<h1>Magellan</h1>
<form class="form" id="form">
<div class="form-group">
<input id="address" class="form-control" type="text" name="title" placeholder="Adresse">
<input id="submit" class="btn btn-primary" type="submit" value="Search">
</div>
</form>
<ul id="results"></ul>
<hr>
<div id="the-map" style="width: 100%; height: 800px"></div>
</div>
<script src="//unpkg.com/kinto/dist/kinto.min.js"></script>
<script src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"
integrity="sha512-QVftwZFqvtRNi0ZyCtsznlKSWOStnDORoefr1enyq5mVL4tmKB3S/EnC3rRJcxCPavG10IcrVGSmPh6Qw5lwrg=="
crossorigin=""></script>
<script src='https://api.mapbox.com/mapbox-gl-js/v0.53.0/mapbox-gl.js'></script>
<script src="magellan.js"></script>
</body>
</html>

View File

@ -1,151 +0,0 @@
(function() {
const MIN_ZOOM = 6;
const MAX_ZOOM = 18;
const INITIAL_ZOOM = 14;
const TILE_URL = 'https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png';
const STATE = {
locations: new Map(),
};
let db = new Kinto({
bucket: "magellan"
});
let kintoLocations = db.collection("locations");
let syncOptions = {
remote: "https://kinto.b.delire.party/v1",
//headers: {Authorization: "Basic " + btoa('user:pass')}
};
async function sync() {
try {
await kintoLocations.sync(syncOptions);
} catch (err){
alert(err);
}
}
sync();
function addLocation(loc) {
if (!STATE.locations.has(loc.label)) {
let marker = L.marker(loc.coordinates)
.addTo(map)
.bindPopup(loc.label);
STATE.locations.set(loc.label, marker);
return true;
}
return false;
}
async function init() {
try {
let locations = await kintoLocations.list();
locations = locations.data;
console.log(locations)
locations.forEach(addLocation);
} catch (err) {
alert(`on init: ${err.toString()}`)
}
}
init();
async function addNewLocation(loc) {
if (addLocation(loc)) {
let marker = STATE.locations.get(loc.label);
marker.openPopup();
// Add it to kinto.
try {
await kintoLocations.create({
label: loc.label,
coordinates: loc.coordinates
});
} catch (err) {
alert(`Error when creating new location: ${err.toString()}`);
};
sync();
} else {
STATE.locations.get(loc.label).openPopup();
}
}
class Location {
constructor({ id, label, coordinates}) {
this.id = id || null;
this.label = label;
this.coordinates = coordinates;
}
}
async function fetchLocation(address) {
// Alternatively, search only for addresses (specialized for Lyon 7).
//const GEOCODING_URL = 'https://api-adresse.data.gouv.fr/search/?q={query}&postcode=69007';
const GEOCODING_URL = 'https://demo.addok.xyz/search/?q={query}&limit=20';
let resp = await fetch(GEOCODING_URL.replace('{query}', address));
let results = await resp.json();
results = results.features.map(feature => {
let { properties: {label: label}, geometry: { coordinates: coordinates }} = feature;
coordinates.unshift(coordinates.pop());
return new Location({label, coordinates});
});
console.log(results);
return results;
}
let map = L.map('the-map').setView([45.751591, 4.845695], INITIAL_ZOOM);
L.tileLayer(TILE_URL, {minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM, attribution: 'Carte données &copy; <a href="https://osm.org/copyright/">OpenStreetMap (ODbL)</a> / fond OSM-FR (CC-by-SA), <a href="https://www.data.gouv.fr/fr/datasets/points-dinterets-openstreetmap/">POI: OpenStreetMap (ODbL)</a>, <a href="https://www.data.gouv.fr/fr/datasets/base-d-adresses-nationale-ouverte-bano/">adresses BANO (ODbL)</a>, <a href="https://github.com/addok/addok">geocodeur addok</a>'}).addTo(map);
let $ = document.querySelector.bind(document);
let $address = $('#address');
let $submit = $('#submit');
let $results = window.results = $('#results');
$('#form').onsubmit = function(event) {
event.preventDefault();
return false;
}
$submit.onclick = async function() {
let address = $address.value.trim();
$results.innerHTML = `searching for ${address}...`;
let firstResult = true;
let locations = await fetchLocation(address);
locations = locations.map(loc => {
let li = document.createElement('li');
let a = document.createElement('a');
a.href = '#';
a.onclick = function() {
L.popup()
.setLatLng(loc.coordinates)
.setContent(loc.label)
.openOn(map);
}
a.appendChild(document.createTextNode(loc.label))
li.appendChild(a);
li.appendChild(document.createTextNode(' '));
let button = document.createElement('button');
button.appendChild(document.createTextNode('Save'));
button.onclick = () => { addNewLocation(loc) };
li.appendChild(button);
if (firstResult) {
firstResult = false;
$results.innerHTML = '';
}
$results.appendChild(li);
return li;
});
}
})();

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"private": true,
"name": "app",
"productName": "app",
"description": "My posh Vue project",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"dev": "poi",
"build": "poi build",
"report": "poi build --bundle-report",
"serve": "serve dist --single"
},
"author": {
"name": "bnjbvr",
"email": "public@benj.me"
},
"dependencies": {
"kinto": "^12.3.0",
"leaflet": "^1.4.0",
"leaflet-sidebar-v2": "^3.1.1",
"lodash.debounce": "^4.0.8",
"normalize.css": "^7.0.0",
"object-assign": "^4.1.1",
"offline-plugin": "^4.8.0",
"promise-polyfill": "^6.0.2",
"vue-router": "^3.0.2",
"vue2-leaflet": "^2.0.2"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.8.1",
"poi": "^9.0.0",
"poi-preset-bundle-report": "^2.0.0",
"poi-preset-offline": "^9.0.0",
"serve": "^6.1.0"
}
}

29
poi.config.js Normal file
View File

@ -0,0 +1,29 @@
const path = require('path');
const pkg = require('./package');
module.exports = {
entry: [
'src/polyfills.js',
'src/index.js'
],
html: {
title: pkg.productName,
description: pkg.description,
template: path.join(__dirname, 'index.ejs')
},
postcss: {
plugins: [
// Your postcss plugins
]
},
presets: [
require('poi-preset-bundle-report')(),
require('poi-preset-offline')({
pwa: './src/pwa.js', // Path to pwa runtime entry
pluginOptions: {} // Additional options for offline-plugin
})
],
output: {
publicUrl: '/magellan',
}
};

35
src/backend.js Normal file
View File

@ -0,0 +1,35 @@
import * as Kinto from 'kinto';
const DB = new Kinto({
bucket: 'magellan'
});
const LOCATIONS = DB.collection('locations');
const syncOptions = { remote: 'https://kinto.b.delire.party/v1' };
export async function getAll() {
let locations = await LOCATIONS.list();
return locations.data;
}
function checkNotMissing(obj, field) {
if (typeof obj[field] === 'undefined') {
console.warn(`missing field ${field} on object in ${new Error().stack}`);
}
}
export async function create(loc) {
checkNotMissing(loc, 'label');
checkNotMissing(loc, 'coordinates');
let result = await LOCATIONS.create({
label: loc.label,
coordinates: loc.coordinates,
});
return result.data;
}
export async function sync() {
await LOCATIONS.sync(syncOptions);
}

58
src/components/App.vue Normal file
View File

@ -0,0 +1,58 @@
<template>
<div id="app">
<router-view></router-view>
</div>
</template>
<script>
import store from '../store';
import * as backend from '../backend';
export default {
name: 'app',
created() {
backend.sync().then(() => {
return backend.getAll();
}).then(locations => {
store.setLocations(locations);
}).catch(err => {
alert(`at app startup: ${err.toString()}`);
})
}
};
</script>
<style src="normalize.css/normalize.css"></style>
<style src="@fortawesome/fontawesome-free/css/all.css"></style>
<style>
code {
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif;
font-size: 0.9em;
white-space: pre-wrap;
color: #2c3e50;
}
code::before, code::after {
content: '`';
}
a {
text-decoration: none;
}
.bm-burger-button {
top: 10px;
}
</style>
<style scoped>
#app {
text-align: center;
}
.bm-menu a,
.bm-menu a:visited {
color: white
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<div class="container">
<ul class="locations">
<li
v-for="location in store.locations"
@click="showDetails(location)"
>
<router-link :to="createLink(location)">
{{location.label}}
</router-link>
</li>
</ul>
</div>
</template>
<script>
import store from '../store';
export default {
data() {
return {
store
}
},
methods: {
createLink(loc) {
let lat = loc.coordinates[0];
let lng = loc.coordinates[1];
return `/${lat}/${lng}`;
},
showDetails(loc) {
let lat = loc.coordinates[0];
let lng = loc.coordinates[1];
store.openPopup(loc.label, lat, lng);
}
}
};
</script>
<style scoped>
li {
margin-top: 20px;
}
</style>
<style>
.leaflet-sidebar-content ul {
list-style-type: none;
padding-left: 5px;
}
</style>

105
src/components/Map.vue Normal file
View File

@ -0,0 +1,105 @@
<template>
<l-map
id="the-map"
ref="myMap"
:zoom="zoom"
:min-zoom="min_zoom"
:max-zoom="max_zoom"
:center="center"
:options="{zoomControl: false}"
@click="onClick"
>
<l-tile-layer
:url="url"
:attribution="attribution"
></l-tile-layer>
<l-marker
v-for="loc in store.locations"
:lat-lng="loc.coordinates"
>
<l-popup>
{{ loc.label }}
</l-popup>
</l-marker>
<l-control-zoom position="topright"></l-control-zoom>
<Sidebar></Sidebar>
</l-map>
</template>
<script>
import { LPopup, LControl, LControlZoom, LMap, LTileLayer, LMarker } from 'vue2-leaflet';
import * as L from 'leaflet';
import store from '../store';
import Sidebar from './Sidebar';
const TILE_URL = 'https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png';
const ATTRIBUTION = 'Carte données &copy; <a href="https://osm.org/copyright/">OpenStreetMap (ODbL)</a> / fond OSM-FR (CC-by-SA), <a href="https://www.data.gouv.fr/fr/datasets/points-dinterets-openstreetmap/">POI: OpenStreetMap (ODbL)</a>, <a href="https://www.data.gouv.fr/fr/datasets/base-d-adresses-nationale-ouverte-bano/">adresses BANO (ODbL)</a>, <a href="https://github.com/addok/addok">geocodeur addok</a>';
const MIN_ZOOM = 6;
const MAX_ZOOM = 18;
const INITIAL_ZOOM = 14;
export default {
name: 'fullmap',
components: {
LMap,
LTileLayer,
LMarker,
LControlZoom,
LControl,
LPopup,
Sidebar,
},
data() {
let lat = this.$route.params.lat;
let lng = this.$route.params.lng;
return {
center: L.latLng(lat, lng),
zoom: INITIAL_ZOOM,
min_zoom: MIN_ZOOM,
max_zoom: MAX_ZOOM,
url: TILE_URL,
attribution: ATTRIBUTION,
store,
};
},
methods: {
onClick(event) {
let {lat, lng} = event.latlng;
this.$router.replace(`/${lat}/${lng}`);
this.$refs.myMap.mapObject.panTo(event.latlng);
},
},
mounted() {
this.store.setMapObject(this.$refs.myMap.mapObject);
},
watch: {
$route(to, from) {
if (to.name === 'position') {
let {lat, lng} = to.params;
this.$refs.myMap.mapObject.panTo({ lat, lng });
}
},
}
};
</script>
<style src="leaflet/dist/leaflet.css"></style>
<style scoped>
#the-map {
width: 100%;
height: 100vh;
}
</style>

119
src/components/Search.vue Normal file
View File

@ -0,0 +1,119 @@
<template>
<div>
<span class="form">
<input type="text" placeholder="place" v-model="searchInput" @keydown="doSearch" />
<button @click="clearSearch()" :disabled="searchInput.length === 0">
<i class="fa fa-trash-alt"></i>
</button>
</span>
<p v-if="searchStatus">{{ searchStatus }}</p>
<ul class="results" v-if="results.length > 0">
<li
v-for="result in results"
@click="openPopup(result)"
>
<router-link :to="{ name: 'position', params: {
lat: result.coordinates[0],
lng: result.coordinates[1] } }">{{result.label}}</router-link>
<i class="fa fa-save" @click="save(result)"></i>
</li>
</ul>
<p v-if="searchInput.length > 0 && searchStatus === null && results.length == 0">
No results found.
</p>
</div>
</template>
<script>
import debounce from 'lodash.debounce';
import store from '../store';
const GEOCODING_URL = 'https://demo.addok.xyz/search/?q={query}&limit=20';
export default {
name: 'Menu',
data() {
return {
store,
searchInput: '',
searchStatus: null,
results: [],
}
},
methods: {
debouncedSearch: debounce(async function() {
if (this.searchInput.length === 0) {
// Just cleared the input.
this.searchStatus = null;
this.results = [];
return;
}
// Launch search.
this.searchStatus = "Searching...";
let resp = await fetch(GEOCODING_URL.replace('{query}', this.searchInput));
let results = await resp.json();
this.searchStatus = null;
this.results = (results && results.features && results.features.map(feature => {
let { properties: {label: label}, geometry: { coordinates: coordinates }} = feature;
coordinates.unshift(coordinates.pop());
return {label, coordinates}
})) || [];
}, 300),
doSearch() {
if (this.searchInput.length > 0) {
this.searchStatus = "Waiting for more characters...";
this.results = [];
}
this.debouncedSearch();
},
openPopup(loc) {
this.store.openPopup(loc.label, loc.coordinates[0], loc.coordinates[1]);
},
clearSearch() {
this.searchInput = '';
this.searchStatus = null;
this.results = [];
},
async save(loc) {
await store.addLocation(loc);
this.clearSearch();
}
}
}
</script>
<style scoped>
.form {
display: flex;
align-items: center;
}
input {
flex-grow: 2;
margin-left: 0;
margin-right: 5px;
}
button {
display: block;
padding: 10px;
background-color: white;
border: 0;
}
.fa-save, .fa-trash-alt {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<div id="sidebar" class="leaflet-sidebar collapsed">
<div class="leaflet-sidebar-tabs">
<ul role="tablist"> <!-- top aligned tabs -->
<li><a href="#search" role="tab"><i class="fa fa-search"></i></a></li>
<li><a href="#all" role="tab"><i class="fa fa-bars"></i></a></li>
</ul>
</div>
<div class="leaflet-sidebar-content">
<div class="leaflet-sidebar-pane" id="search">
<h1 class="leaflet-sidebar-header">Search<div class="leaflet-sidebar-close"><i class="fa fa-caret-left"></i></div></h1>
<Search/>
</div>
<div class="leaflet-sidebar-pane" id="all">
<h1 class="leaflet-sidebar-header">All locations<div class="leaflet-sidebar-close"><i class="fa fa-caret-left"></i></div></h1>
<ListAll></ListAll>
</div>
</div>
</div>
</template>
<script>
import * as _ from 'leaflet-sidebar-v2';
import Search from './Search';
import ListAll from './ListAll';
export default {
name: "sidebar",
components: {
Search,
ListAll,
},
mounted() {
this.mapObject = L.control.sidebar({
autopan: true,
closeButton: true,
container: 'sidebar',
position: 'left',
});
let parentContainer = this.$parent;
while (typeof parentContainer.mapObject === 'undefined') {
parentContainer = parentContainer.$parent;
}
this.mapObject.addTo(parentContainer.mapObject);
},
beforeDestroy() {
this.mapObject.remove();
}
};
</script>
<style src="leaflet-sidebar-v2/css/leaflet-sidebar.min.css"></style>
<style>
.leaflet-sidebar-content {
text-align: left;
font-size: 1.7em;
}
.leaflet-sidebar-pane h1 + * {
margin-top: 18px;
}
</style>

34
src/index.js Normal file
View File

@ -0,0 +1,34 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import { Icon } from 'leaflet';
import App from './components/App.vue';
import Map from './components/Map.vue';
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png')
});
Vue.config.productionTip = false;
Vue.use(VueRouter);
const routes = [
{ name: "position", path: "/:lat/:lng", component: Map },
{ path: "/", redirect: '/45.751591/4.845695/' }
];
const router = new VueRouter({
routes
});
new Vue({
el: '#app',
render: h => h(App),
router,
});

5
src/polyfills.js Normal file
View File

@ -0,0 +1,5 @@
if (!window.Promise) {
window.Promise = require('promise-polyfill');
}
Object.assign = require('object-assign');

7
src/pwa.js Normal file
View File

@ -0,0 +1,7 @@
import runtime from 'offline-plugin/runtime';
runtime.install({
onUpdateReady() {
runtime.applyUpdate();
}
});

42
src/store.js Normal file
View File

@ -0,0 +1,42 @@
import * as backend from './backend';
const store = {
locations: [],
locationLabels: new Set(),
setLocations(newLocations) {
this.locations = newLocations;
this.locations.sort((a, b) => a.label > b.label);
for (let loc of newLocations) {
this.locationLabels.add(loc.label);
}
},
async addLocation(loc) {
if (this.locationLabels.has(loc.label)) {
return;
}
let newLoc = null;
try {
newLoc = await backend.create(loc);
} catch (err) {
alert(`Error when saving a new place: ${err.toString()}`);
return;
}
this.locations.push(newLoc);
this.locations.sort((a, b) => a.label > b.label);
this.locationLabels.add(newLoc.label);
},
mapObject: null,
setMapObject(mapObject) {
this.mapObject = mapObject;
},
openPopup(label, lat, lng) {
L.popup()
.setLatLng([lat, lng])
.setContent(label)
.openOn(this.mapObject);
},
};
export default store;

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

20
static/manifest.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "app",
"short_name": "app",
"icons": [
{
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#4DBA87"
}

6326
yarn.lock Normal file

File diff suppressed because it is too large Load Diff