diff --git a/package.json b/package.json
index da1ac0d..13474cb 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/models.py b/server/models.py
index d41b4a2..a4e12dc 100644
--- a/server/models.py
+++ b/server/models.py
@@ -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 {
diff --git a/server/routes.py b/server/routes.py
index f298603..d562598 100644
--- a/server/routes.py
+++ b/server/routes.py
@@ -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()
+ }
diff --git a/src/api/index.js b/src/api/index.js
index e80f0f1..16b76ef 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -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;
+ });
+}
diff --git a/src/components/ReportCard.vue b/src/components/ReportCard.vue
new file mode 100644
index 0000000..2f6e373
--- /dev/null
+++ b/src/components/ReportCard.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ report.label }}
+
+
+ {{ $t('reportCard.Reported') }} {{ report.fromNow }}
+
+
+
+
+
+ thumb_up
+
+
+
+
+
+ thumb_down
+
+
+
+
+ close
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ReportDialog/index.vue b/src/components/ReportDialog/index.vue
index c2aeac0..651abf2 100644
--- a/src/components/ReportDialog/index.vue
+++ b/src/components/ReportDialog/index.vue
@@ -11,7 +11,7 @@
@@ -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;
});
},
},
diff --git a/src/components/ReportMarker.vue b/src/components/ReportMarker.vue
index caea45a..6e99be2 100644
--- a/src/components/ReportMarker.vue
+++ b/src/components/ReportMarker.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/src/constants.js b/src/constants.js
index 8ad370c..32851dc 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -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;
diff --git a/src/i18n/en.js b/src/i18n/en.js
index 62c1b8e..272ccd7 100644
--- a/src/i18n/en.js
+++ b/src/i18n/en.js
@@ -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',
diff --git a/src/i18n/fr.js b/src/i18n/fr.js
index 9cfb3b6..2a284ff 100644
--- a/src/i18n/fr.js
+++ b/src/i18n/fr.js
@@ -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",
diff --git a/src/i18n/index.js b/src/i18n/index.js
index cdc8725..7e4d08e 100644
--- a/src/i18n/index.js
+++ b/src/i18n/index.js
@@ -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,
diff --git a/src/store/actions.js b/src/store/actions.js
index d0883ab..74aa13a 100644
--- a/src/store/actions.js
+++ b/src/store/actions.js
@@ -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 });
+}
diff --git a/src/store/getters.js b/src/store/getters.js
new file mode 100644
index 0000000..df6721a
--- /dev/null
+++ b/src/store/getters.js
@@ -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;
+ });
+}
diff --git a/src/store/index.js b/src/store/index.js
index a1fb790..0ba310e 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -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,
});
diff --git a/src/store/mutations-types.js b/src/store/mutations-types.js
index 9d783ac..eaebc1e 100644
--- a/src/store/mutations-types.js
+++ b/src/store/mutations-types.js
@@ -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';
diff --git a/src/store/mutations.js b/src/store/mutations.js
index 586747b..4b29192 100644
--- a/src/store/mutations.js
+++ b/src/store/mutations.js
@@ -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 }) {
- state.reports.push(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);
+ }
},
};
diff --git a/src/views/Map.vue b/src/views/Map.vue
index 4d38fa0..e4b84c4 100644
--- a/src/views/Map.vue
+++ b/src/views/Map.vue
@@ -8,6 +8,7 @@
+
({
+ return this.$store.getters.notDismissedReports.map(report => ({
id: report.id,
type: report.attributes.type,
latLng: [report.attributes.lat, report.attributes.lng],
diff --git a/src/views/Settings.vue b/src/views/Settings.vue
index a414295..5aba5b6 100644
--- a/src/views/Settings.vue
+++ b/src/views/Settings.vue
@@ -24,6 +24,8 @@