Clicking on a report now shows more infos and let users dismiss it
You should update your database by running ``` ALTER TABLE report ADD COLUMN (upvotes INTEGER NOT NULL, downvotes INTEGER NOT NULL) ``` Fix issue #8.
This commit is contained in:
parent
dde886d46e
commit
9d4842b44c
@ -16,6 +16,7 @@
|
||||
"leaflet": "^1.3.1",
|
||||
"leaflet-tracksymbol": "^1.0.8",
|
||||
"material-icons": "^0.2.3",
|
||||
"moment": "^2.22.2",
|
||||
"nosleep.js": "^0.7.0",
|
||||
"roboto-fontface": "^0.9.0",
|
||||
"vue": "^2.5.2",
|
||||
|
@ -44,6 +44,8 @@ class Report(BaseModel):
|
||||
default=lambda: arrow.utcnow().replace(microsecond=0).datetime
|
||||
)
|
||||
is_open = peewee.BooleanField(default=True)
|
||||
upvotes = peewee.IntegerField(default=0)
|
||||
downvotes = peewee.IntegerField(default=0)
|
||||
|
||||
def to_json(self):
|
||||
return {
|
||||
|
@ -104,3 +104,51 @@ def post_report():
|
||||
return {
|
||||
"data": r.to_json()
|
||||
}
|
||||
|
||||
|
||||
@bottle.route('/api/v1/reports/:id/upvote', ["POST", "OPTIONS"])
|
||||
def upvote_report(id):
|
||||
"""
|
||||
API v1 POST upvote route.
|
||||
|
||||
Example::
|
||||
|
||||
POST /api/v1/reports/1/upvote
|
||||
"""
|
||||
# Handle CORS
|
||||
if bottle.request.method == 'OPTIONS':
|
||||
return {}
|
||||
|
||||
r = Report.get(Report.id == id)
|
||||
if not r:
|
||||
return jsonapi.JsonApiError(404, "Invalid report id.")
|
||||
r.upvotes += 1
|
||||
r.save()
|
||||
|
||||
return {
|
||||
"data": r.to_json()
|
||||
}
|
||||
|
||||
|
||||
@bottle.route('/api/v1/reports/:id/downvote', ["POST", "OPTIONS"])
|
||||
def downvote_report(id):
|
||||
"""
|
||||
API v1 POST downvote route.
|
||||
|
||||
Example::
|
||||
|
||||
POST /api/v1/reports/1/downvote
|
||||
"""
|
||||
# Handle CORS
|
||||
if bottle.request.method == 'OPTIONS':
|
||||
return {}
|
||||
|
||||
r = Report.get(Report.id == id)
|
||||
if not r:
|
||||
return jsonapi.JsonApiError(404, "Invalid report id.")
|
||||
r.downvotes += 1
|
||||
r.save()
|
||||
|
||||
return {
|
||||
"data": r.to_json()
|
||||
}
|
||||
|
@ -30,3 +30,27 @@ export function getReports() {
|
||||
throw exc;
|
||||
});
|
||||
}
|
||||
|
||||
export function downvote(id) {
|
||||
return fetch(`${BASE_URL}api/v1/reports/${id}/downvote`, {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => response.data)
|
||||
.catch((exc) => {
|
||||
console.error(`Unable to downvote report: ${exc}.`);
|
||||
throw exc;
|
||||
});
|
||||
}
|
||||
|
||||
export function upvote(id) {
|
||||
return fetch(`${BASE_URL}api/v1/reports/${id}/upvote`, {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => response.data)
|
||||
.catch((exc) => {
|
||||
console.error(`Unable to upvote report: ${exc}.`);
|
||||
throw exc;
|
||||
});
|
||||
}
|
||||
|
129
src/components/ReportCard.vue
Normal file
129
src/components/ReportCard.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<v-flex xs12 v-if="report" class="reportCard">
|
||||
<div></div>
|
||||
<v-container grid-list-md fluid>
|
||||
<v-layout row align-center>
|
||||
<v-flex xs3 md2>
|
||||
<img :src="report.icon" :alt="report.label" class="icon">
|
||||
</v-flex>
|
||||
<v-flex xs5 md6>
|
||||
<v-layout row wrap>
|
||||
<v-flex xs12 class="firstLine subheading font-weight-medium">
|
||||
{{ report.label }}
|
||||
</v-flex>
|
||||
<v-flex xs12 class="secondLine">
|
||||
{{ $t('reportCard.Reported') }} {{ report.fromNow }}
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-flex>
|
||||
<v-flex xs2 class="text-xs-center">
|
||||
<v-btn color="green" dark small icon class="smallButton" @click.stop="upvote">
|
||||
<v-icon>thumb_up</v-icon>
|
||||
</v-btn>
|
||||
</v-flex>
|
||||
|
||||
<v-flex xs2 class="text-xs-center">
|
||||
<v-btn color="red" dark medium icon class="mediumButton" @click.stop="downvote">
|
||||
<v-icon>thumb_down</v-icon>
|
||||
</v-btn>
|
||||
</v-flex>
|
||||
|
||||
<v-btn
|
||||
color="grey"
|
||||
class="lighten-1"
|
||||
dark
|
||||
small
|
||||
absolute
|
||||
bottom
|
||||
left
|
||||
fab
|
||||
@click.stop="closeReportCard"
|
||||
>
|
||||
<v-icon>close</v-icon>
|
||||
</v-btn>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</v-flex>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
|
||||
import { REPORT_TYPES } from '@/constants';
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
report() {
|
||||
const reportID = this.$store.state.reportDetailsID;
|
||||
if (reportID != null) {
|
||||
const report = this.$store.state.reports.find(item => item.id === reportID);
|
||||
return {
|
||||
fromNow: moment(report.attributes.datetime).fromNow(),
|
||||
icon: this.icons[report.attributes.type],
|
||||
id: report.id,
|
||||
label: this.$t(`reportLabels.${report.attributes.type}`),
|
||||
type: report.attributes.type,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const icons = {};
|
||||
Object.keys(REPORT_TYPES).forEach((type) => {
|
||||
icons[type] = REPORT_TYPES[type].image;
|
||||
});
|
||||
return {
|
||||
icons,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
closeReportCard() {
|
||||
return this.$store.dispatch('showReportDetails', null);
|
||||
},
|
||||
downvote() {
|
||||
const reportID = this.report.id;
|
||||
this.closeReportCard(); // Resets this.report
|
||||
return this.$store.dispatch('downvote', reportID);
|
||||
},
|
||||
upvote() {
|
||||
const reportID = this.report.id;
|
||||
this.closeReportCard(); // Resets this.report
|
||||
return this.$store.dispatch('upvote', reportID);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reportCard {
|
||||
position: absolute;
|
||||
z-index: 1001;
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.icon {
|
||||
max-height: 52px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mediumButton {
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.smallButton {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.secondLine {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1em !important;
|
||||
}
|
||||
|
||||
.firstLine {
|
||||
padding: 2px !important;
|
||||
}
|
||||
</style>
|
@ -11,7 +11,7 @@
|
||||
|
||||
<v-btn
|
||||
color="red darken-1"
|
||||
@click="error = false"
|
||||
@click="error = null"
|
||||
dark
|
||||
large
|
||||
>
|
||||
@ -60,7 +60,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
error: null,
|
||||
REPORT_TYPES,
|
||||
};
|
||||
},
|
||||
@ -71,8 +71,9 @@ export default {
|
||||
type,
|
||||
lat: this.lat,
|
||||
lng: this.lng,
|
||||
}).catch(() => {
|
||||
this.error = true;
|
||||
}).catch((exc) => {
|
||||
console.error(exc);
|
||||
this.error = exc;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-lmarker :lat-lng="marker.latLng" :icon="icons[marker.type]"></v-lmarker>
|
||||
<v-lmarker :lat-lng="marker.latLng" :icon="icon" @click="onClick"></v-lmarker>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -9,14 +9,23 @@ export default {
|
||||
props: {
|
||||
marker: Object,
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
if (this.$store.state.reportDetailsID === this.marker.id) {
|
||||
return REPORT_TYPES[this.marker.type].markerLarge;
|
||||
}
|
||||
return REPORT_TYPES[this.marker.type].marker;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const icons = {};
|
||||
Object.keys(REPORT_TYPES).forEach((type) => {
|
||||
icons[type] = REPORT_TYPES[type].marker;
|
||||
});
|
||||
return {
|
||||
icons,
|
||||
showCard: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$store.dispatch('showReportDetails', this.marker.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -18,6 +18,12 @@ export const REPORT_TYPES = {
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
}),
|
||||
markerLarge: L.icon({
|
||||
iconUrl: gcumMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
}),
|
||||
|
||||
},
|
||||
interrupt: {
|
||||
label: 'reportLabels.interrupt',
|
||||
@ -27,6 +33,11 @@ export const REPORT_TYPES = {
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
}),
|
||||
markerLarge: L.icon({
|
||||
iconUrl: interruptMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
}),
|
||||
},
|
||||
obstacle: {
|
||||
label: 'reportLabels.obstacle',
|
||||
@ -36,6 +47,11 @@ export const REPORT_TYPES = {
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
}),
|
||||
markerLarge: L.icon({
|
||||
iconUrl: obstacleMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
}),
|
||||
},
|
||||
pothole: {
|
||||
label: 'reportLabels.pothole',
|
||||
@ -45,6 +61,11 @@ export const REPORT_TYPES = {
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
}),
|
||||
markerLarge: L.icon({
|
||||
iconUrl: potholeMarker,
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 60],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@ -53,6 +74,9 @@ export const MOCK_LOCATION_UPDATE_INTERVAL = 10 * 1000;
|
||||
|
||||
export const UPDATE_REPORTS_DISTANCE_THRESHOLD = 500;
|
||||
|
||||
// Minimal ratio between upvotes and downvotes needed for a report to be shown
|
||||
export const REPORT_VOTES_THRESHOLD = 0.5;
|
||||
|
||||
export const EARTH_RADIUS = 6378137;
|
||||
|
||||
export const DEFAULT_ZOOM = 17;
|
||||
|
@ -25,6 +25,9 @@ export default {
|
||||
retry: 'Retry',
|
||||
spaceBeforeDoublePunctuations: '',
|
||||
},
|
||||
reportCard: {
|
||||
Reported: 'Reported',
|
||||
},
|
||||
reportDialog: {
|
||||
unableToSendDescription: 'There was a network issue preventing from sending the latest report.',
|
||||
unableToSendTitle: 'Unable to send latest report',
|
||||
|
@ -25,6 +25,9 @@ export default {
|
||||
retry: 'Réessayer',
|
||||
spaceBeforeDoublePunctuations: ' ',
|
||||
},
|
||||
reportCard: {
|
||||
Reported: 'Signalé',
|
||||
},
|
||||
reportDialog: {
|
||||
unableToSendDescription: "Une erreur de réseau empêche l'envoi du dernier signalement.",
|
||||
unableToSendTitle: "Impossible d'envoyer le dernier signalement",
|
||||
|
@ -1,3 +1,4 @@
|
||||
import moment from 'moment';
|
||||
import Vue from 'vue';
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
@ -58,6 +59,11 @@ if (!locale) {
|
||||
locale = 'en'; // Safe default
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
// Set moment locale
|
||||
moment.locale(locale);
|
||||
}
|
||||
|
||||
export default new VueI18n({
|
||||
locale,
|
||||
messages,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import * as api from '@/api';
|
||||
import { IS_LOADING, IS_DONE_LOADING, PUSH_REPORT, STORE_REPORTS } from './mutations-types';
|
||||
import { IS_LOADING, IS_DONE_LOADING, PUSH_REPORT, SHOW_REPORT_DETAILS, STORE_REPORTS } from './mutations-types';
|
||||
|
||||
export function fetchReports({ commit }) {
|
||||
commit(IS_LOADING);
|
||||
@ -8,9 +8,23 @@ export function fetchReports({ commit }) {
|
||||
.finally(() => commit(IS_DONE_LOADING));
|
||||
}
|
||||
|
||||
export function downvote({ commit }, id) {
|
||||
return api.downvote(id)
|
||||
.then(report => commit(PUSH_REPORT, { report }));
|
||||
}
|
||||
|
||||
export function upvote({ commit }, id) {
|
||||
return api.upvote(id)
|
||||
.then(report => commit(PUSH_REPORT, { report }));
|
||||
}
|
||||
|
||||
export function saveReport({ commit }, { type, lat, lng }) {
|
||||
commit(IS_LOADING);
|
||||
return api.saveReport(type, lat, lng)
|
||||
.then(report => commit(PUSH_REPORT, { report }))
|
||||
.finally(() => commit(IS_DONE_LOADING));
|
||||
}
|
||||
|
||||
export function showReportDetails({ commit }, id) {
|
||||
return commit(SHOW_REPORT_DETAILS, { id });
|
||||
}
|
||||
|
10
src/store/getters.js
Normal file
10
src/store/getters.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { REPORT_VOTES_THRESHOLD } from '@/constants';
|
||||
|
||||
export function notDismissedReports(state) {
|
||||
return state.reports.filter((item) => {
|
||||
if (item.attributes.downvotes === 0) {
|
||||
return true;
|
||||
}
|
||||
return (item.attributes.upvotes / item.attributes.downvotes) > REPORT_VOTES_THRESHOLD;
|
||||
});
|
||||
}
|
@ -4,6 +4,7 @@ import createLogger from 'vuex/dist/logger';
|
||||
|
||||
import * as actions from './actions';
|
||||
import { initialState as state, mutations } from './mutations';
|
||||
import * as getters from './getters';
|
||||
|
||||
const plugins = [];
|
||||
if (process.NODE_ENV !== 'production') {
|
||||
@ -14,7 +15,8 @@ Vue.use(Vuex);
|
||||
|
||||
export default new Vuex.Store({
|
||||
actions,
|
||||
state,
|
||||
getters,
|
||||
mutations,
|
||||
plugins,
|
||||
state,
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
export const IS_LOADING = 'IS_LOADING';
|
||||
export const IS_DONE_LOADING = 'IS_DONE_LOADING';
|
||||
export const PUSH_REPORT = 'PUSH_REPORT';
|
||||
export const SHOW_REPORT_DETAILS = 'SHOW_REPORT_DETAILS';
|
||||
export const STORE_REPORTS = 'STORE_REPORTS';
|
||||
|
@ -1,7 +1,10 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
import * as types from './mutations-types';
|
||||
|
||||
export const initialState = {
|
||||
isLoading: false,
|
||||
reportDetailsID: null,
|
||||
reports: [],
|
||||
};
|
||||
|
||||
@ -12,10 +15,18 @@ export const mutations = {
|
||||
[types.IS_DONE_LOADING](state) {
|
||||
state.isLoading = false;
|
||||
},
|
||||
[types.SHOW_REPORT_DETAILS](state, { id }) {
|
||||
state.reportDetailsID = id;
|
||||
},
|
||||
[types.STORE_REPORTS](state, { reports }) {
|
||||
state.reports = reports;
|
||||
},
|
||||
[types.PUSH_REPORT](state, { report }) {
|
||||
const reportIndex = state.reports.findIndex(item => item.id === report.id);
|
||||
if (reportIndex === -1) {
|
||||
state.reports.push(report);
|
||||
} else {
|
||||
Vue.set(state.reports, reportIndex, report);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -8,6 +8,7 @@
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
<v-layout v-else row wrap fill-height>
|
||||
<ReportCard></ReportCard>
|
||||
<v-flex xs12 fill-height v-if="latLng">
|
||||
<Map :positionLatLng="latLng" :heading="heading" :accuracy="accuracy" :markers="reportsMarkers" :onPress="showReportDialog"></Map>
|
||||
<v-btn
|
||||
@ -44,6 +45,7 @@
|
||||
import NoSleep from 'nosleep.js';
|
||||
|
||||
import Map from '@/components/Map.vue';
|
||||
import ReportCard from '@/components/ReportCard.vue';
|
||||
import ReportDialog from '@/components/ReportDialog/index.vue';
|
||||
import * as constants from '@/constants';
|
||||
import { distance, mockLocation } from '@/tools';
|
||||
@ -51,6 +53,7 @@ import { distance, mockLocation } from '@/tools';
|
||||
export default {
|
||||
components: {
|
||||
Map,
|
||||
ReportCard,
|
||||
ReportDialog,
|
||||
},
|
||||
beforeDestroy() {
|
||||
@ -59,10 +62,11 @@ export default {
|
||||
this.disablePositionWatching();
|
||||
window.removeEventListener('keydown', this.hideReportDialogOnEsc);
|
||||
}
|
||||
this.$store.dispatch('showReportDetails', null);
|
||||
},
|
||||
computed: {
|
||||
reportsMarkers() {
|
||||
return this.$store.state.reports.map(report => ({
|
||||
return this.$store.getters.notDismissedReports.map(report => ({
|
||||
id: report.id,
|
||||
type: report.attributes.type,
|
||||
latLng: [report.attributes.lat, report.attributes.lng],
|
||||
|
@ -24,6 +24,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
|
||||
import { messages } from '@/i18n';
|
||||
import { storageAvailable } from '@/tools';
|
||||
|
||||
@ -48,6 +50,8 @@ export default {
|
||||
localStorage.setItem('preventSuspend', JSON.stringify(this.preventSuspend));
|
||||
}
|
||||
this.$i18n.locale = this.i18nSelect;
|
||||
// Set moment locale
|
||||
moment.locale(this.i18nSelect);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -3826,6 +3826,10 @@ mkdirp@0.5.1, mkdirp@0.5.x, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@
|
||||
dependencies:
|
||||
minimist "0.0.8"
|
||||
|
||||
moment@^2.22.2:
|
||||
version "2.22.2"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
|
||||
|
||||
move-concurrently@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
|
||||
|
Loading…
Reference in New Issue
Block a user