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 @@ + + + + + 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 @@