diff --git a/.env.example b/.env.example index e864cae..9ff4586 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,10 @@ ADMIN_EMAIL=admin@example.com # Comment or leave empty if the postal code is not available. # DB_LOCATION__POSTAL_CODE=postalCode +# The name of the column that contains the description of each location point +# Comment or leave empty if the description is not available. +# DB_LOCATION__DESCRIPTION=description + ### ### Frontend configuration. ### This is only required if you want to run the frontend in development mode diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 0165224..ba4a9b2 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -30,7 +30,8 @@ <PointInfo :point="selectedPoint" ref="popup" @remove="onRemove" - @close="selectedPoint = null" /> + @edit="editPoint" + @close="clearSelectedPoint" /> <div class="controls"> <div class="form-container" v-if="showControls"> @@ -135,6 +136,7 @@ export default { routesLayer: null as Optional<VectorLayer>, selectedFeature: null as Optional<Feature>, selectedPoint: null as Optional<GPSPoint>, + selectedPointIndex: null as Optional<number>, showControls: false, showMetrics: new TimelineMetricsConfiguration(), } @@ -206,11 +208,20 @@ export default { } finally { this.loading = false this.pointToRemove = null - this.selectedPoint = null - this.selectedFeature = null + this.clearSelectedPoint() } }, + async editPoint(value: GPSPoint) { + const index = this.selectedPointIndex + if (index === null) { + return + } + + await this.updatePoints([value]) + this.gpsPoints[index] = value + }, + onRemove(point: GPSPoint) { this.pointToRemove = point }, @@ -273,9 +284,14 @@ export default { if (feature) { this.selectedFeature = feature as Feature - const point = this.gpsPoints.find((gps: GPSPoint) => { + const point = this.gpsPoints.find((gps: GPSPoint, index: number) => { const [longitude, latitude] = (feature.getGeometry() as any).getCoordinates() - return gps.longitude === longitude && gps.latitude === latitude + if (gps.longitude === longitude && gps.latitude === latitude) { + this.selectedPointIndex = index + return true + } + + return false }) if (point) { @@ -286,12 +302,17 @@ export default { map.getView().setCenter(event.coordinate) } } else { - this.selectedPoint = null - this.selectedFeature = null + this.clearSelectedPoint() } }) }, + clearSelectedPoint() { + this.selectedPoint = null + this.selectedPointIndex = null + this.selectedFeature = null + }, + refreshShowMetricsFromURL() { this.showMetrics = new TimelineMetricsConfiguration(this.parseQuery(window.location.href)) }, diff --git a/frontend/src/components/PointInfo.vue b/frontend/src/components/PointInfo.vue index 6d426b0..594cfae 100644 --- a/frontend/src/components/PointInfo.vue +++ b/frontend/src/components/PointInfo.vue @@ -22,6 +22,34 @@ <font-awesome-icon icon="fas fa-mountain" /> {{ Math.round(point.altitude) }} m </p> + + <form class="description editor" @submit.prevent="editPoint" v-if="editDescription"> + <div class="row"> + <textarea + :value="point.description" + @keydown.enter="editPoint" + @blur="onDescriptionBlur" + ref="description" + placeholder="Enter a description" /> + + <button type="submit" title="Save"> + <font-awesome-icon icon="fas fa-save" /> + </button> + </div> + </form> + + <p class="description" + :class="{ 'no-content': !point.description?.length }" + @click="editDescription = true" + v-else> + <span class="icon"> + <font-awesome-icon icon="fas fa-edit" /> + </span> + <span class="text"> + {{ point.description?.length ? point.description : 'No description' }} + </span> + </p> + <p class="locality" v-if="point.locality">{{ point.locality }}</p> <p class="postal-code" v-if="point.postalCode">{{ point.postalCode }}</p> <p class="country" v-if="country"> @@ -51,7 +79,7 @@ import Dates from '../mixins/Dates.vue'; import GPSPoint from '../models/GPSPoint'; export default { - emit: ['close', 'remove'], + emit: ['close', 'edit', 'remove'], mixins: [Dates], props: { point: { @@ -61,6 +89,8 @@ export default { data() { return { + newValue: null, + editDescription: false, popup: null as Overlay | null, } }, @@ -99,12 +129,44 @@ export default { map.addOverlay(this.popup) }, + editPoint() { + this.newValue.description = (this.$refs.description as HTMLTextAreaElement).value + this.$emit('edit', this.newValue) + this.editDescription = false + }, + + onDescriptionBlur() { + // Give it a moment to allow relevant click events to trigger + setTimeout(() => { + this.editDescription = false + }, 100) + }, + setPosition(coordinates: number[]) { if (this.popup) { this.popup.setPosition(coordinates) } }, }, + + watch: { + point: { + immediate: true, + handler(point: GPSPoint | null) { + if (point) { + this.newValue = point + } + }, + }, + + editDescription(edit: boolean) { + if (edit) { + this.$nextTick(() => { + this.$refs.description?.focus() + }) + } + }, + }, } </script> @@ -128,7 +190,7 @@ export default { .header { position: absolute; top: 0.5em; - right: 0.5em; + right: 0; button { background: none; @@ -158,6 +220,57 @@ export default { margin: -0.25em 0 0.25em 0; } + .description { + cursor: pointer; + + &:hover { + .icon { + color: var(--color-accent); + } + } + + .icon { + margin-right: 0.5em; + } + + .text { + font-style: italic; + } + + &:not(.no-content) { + .text { + font-weight: bold; + } + + .icon { + color: var(--color-accent); + } + } + + &.no-content { + font-size: 0.9em; + opacity: 0.5; + } + + textarea { + min-height: 5em; + } + + button { + background: var(--color-accent); + border: none; + color: var(--color-accent); + font-size: 0.9em; + margin: 0; + padding: 0.5em 1.5em; + cursor: pointer; + + &:hover { + color: var(--color-heading); + } + } + } + .timestamp { color: var(--color-heading); font-weight: bold; diff --git a/frontend/src/mixins/api/GPSData.vue b/frontend/src/mixins/api/GPSData.vue index 29081fd..0f5e705 100644 --- a/frontend/src/mixins/api/GPSData.vue +++ b/frontend/src/mixins/api/GPSData.vue @@ -12,13 +12,13 @@ export default { }) || [] return points.map((gps: any) => - new GPSPoint({ - ...gps, - // Normalize timestamp to Date object - timestamp: new Date(gps.timestamp), - }) - ) - .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime()) + new GPSPoint({ + ...gps, + // Normalize timestamp to Date object + timestamp: new Date(gps.timestamp), + }) + ) + .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime()) }, async deletePoints(points: GPSPoint[]) { @@ -27,6 +27,13 @@ export default { body: points.map((point: GPSPoint) => point.id), }) }, + + async updatePoints(points: GPSPoint[]) { + await this.request('/gpsdata', { + method: 'PATCH', + body: points, + }) + }, }, } </script> diff --git a/frontend/src/models/GPSPoint.ts b/frontend/src/models/GPSPoint.ts index 67e28db..14c68c3 100644 --- a/frontend/src/models/GPSPoint.ts +++ b/frontend/src/models/GPSPoint.ts @@ -3,22 +3,50 @@ class GPSPoint { public latitude: number; public longitude: number; public altitude: number; + public deviceId: string; public address: string; public locality: string; public country: string; public postalCode: string; + public description?: string; public timestamp: Date; - constructor(public data: any) { - this.id = data.id; - this.latitude = data.latitude; - this.longitude = data.longitude; - this.altitude = data.altitude; - this.address = data.address; - this.locality = data.locality; - this.country = data.country; - this.postalCode = data.postalCode; - this.timestamp = data.timestamp; + constructor({ + id, + latitude, + longitude, + altitude, + deviceId, + address, + locality, + country, + postalCode, + description, + timestamp, + }: { + id: number; + latitude: number; + longitude: number; + altitude: number; + deviceId: string; + address: string; + locality: string; + country: string; + postalCode: string; + description?: string; + timestamp: Date; + }) { + this.id = id; + this.latitude = latitude; + this.longitude = longitude; + this.altitude = altitude; + this.deviceId = deviceId; + this.address = address; + this.locality = locality; + this.country = country; + this.postalCode = postalCode; + this.description = description; + this.timestamp = timestamp; } } diff --git a/src/db/Db.ts b/src/db/Db.ts index 934ec9c..4e0f9ad 100644 --- a/src/db/Db.ts +++ b/src/db/Db.ts @@ -94,7 +94,8 @@ class Db { 'address', 'locality', 'country', - 'postalCode' + 'postalCode', + 'description', ].reduce((acc: any, name: string) => { acc[name] = process.env[this.prefixedEnv(name)]; if (!acc[name]?.length && (requiredColumns[name] || opts.locationUrl === opts.url)) { diff --git a/src/db/migrations/000_initial.ts b/src/db/migrations/000_initial.ts index 928cb46..bdde658 100644 --- a/src/db/migrations/000_initial.ts +++ b/src/db/migrations/000_initial.ts @@ -218,6 +218,10 @@ async function createLocationHistoryTable(query: { context: any }) { type: DataTypes.STRING, allowNull: true }, + [$db.locationTableColumns['description']]: { + type: DataTypes.STRING, + allowNull: true + }, [$db.locationTableColumns['timestamp']]: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, diff --git a/src/db/types/GPSData.ts b/src/db/types/GPSData.ts index 0f4103e..72c249e 100644 --- a/src/db/types/GPSData.ts +++ b/src/db/types/GPSData.ts @@ -66,6 +66,14 @@ function GPSData(locationTableColumns: Record<string, string>): Record<string, a }; } + const descriptionCol: string = locationTableColumns['description']; + if (descriptionCol?.length) { + typeDef[descriptionCol] = { + type: DataTypes.STRING, + allowNull: true + }; + } + typeDef[locationTableColumns['timestamp']] = { type: DataTypes.DATE, defaultValue: DataTypes.NOW diff --git a/src/models/GPSPoint.ts b/src/models/GPSPoint.ts index a4f5c58..a90ee4a 100644 --- a/src/models/GPSPoint.ts +++ b/src/models/GPSPoint.ts @@ -8,6 +8,7 @@ class GPSPoint { public locality: string | null; public country: string | null; public postalCode: string | null; + public description: string | null; public timestamp: Date; constructor(record: any) { @@ -20,6 +21,7 @@ class GPSPoint { this.locality = record.locality; this.country = record.country; this.postalCode = record.postalCode; + this.description = record.description; this.timestamp = record.timestamp; } } diff --git a/src/repos/Location.ts b/src/repos/Location.ts index 2a0c2d2..cf99e54 100644 --- a/src/repos/Location.ts +++ b/src/repos/Location.ts @@ -26,6 +26,7 @@ class Location { locality: data[mappings.locality], country: data[mappings.country], postalCode: data[mappings.postalCode], + description: data[mappings.description], timestamp: data[mappings.timestamp], }); }); @@ -62,6 +63,7 @@ class Location { locality: data[mappings.locality], country: data[mappings.country], postalCode: data[mappings.postalCode], + description: data[mappings.description], timestamp: data[mappings.timestamp], }); }); @@ -94,6 +96,7 @@ class Location { [mappings.locality]: p.locality, [mappings.country]: p.country, [mappings.postalCode]: p.postalcode, + [mappings.description]: p.description, [mappings.timestamp]: p.timestamp } }, @@ -111,6 +114,7 @@ class Location { locality: data[mappings.locality], country: data[mappings.country], postalCode: data[mappings.postalCode], + description: data[mappings.description], timestamp: data[mappings.timestamp], }); }); @@ -119,6 +123,41 @@ class Location { } } + public async updatePoints(points: GPSPoint[]): Promise<void> { + const mappings: any = $db.locationTableColumns; + // Lowercase the keys of the mappings object - + // some databases are case-insensitive and this will help with consistency + const normalizedPoints = points.map((p) => + Object.entries(p).reduce((acc, [key, value]) => { + acc[key.toLowerCase()] = value; + return acc; + } , {} as Record<string, any>) + ); + + try { + await $db.GPSData().bulkCreate( + normalizedPoints.map((p) => { + return { + [mappings.id]: p.id, + [mappings.deviceId]: p.deviceid, + [mappings.latitude]: p.latitude, + [mappings.longitude]: p.longitude, + [mappings.altitude]: p.altitude, + [mappings.address]: p.address, + [mappings.locality]: p.locality, + [mappings.country]: p.country, + [mappings.postalCode]: p.postalcode, + [mappings.description]: p.description, + [mappings.timestamp]: p.timestamp + } + }), + { updateOnDuplicate: Object.keys(mappings) } + ); + } catch (error) { + throw new Error(`Error updating data: ${error}`); + } + } + public async deletePoints(points: number[]): Promise<void> { try { await $db.GPSData().destroy({ diff --git a/src/requests/LocationRequest.ts b/src/requests/LocationRequest.ts index d73a326..2383d9a 100644 --- a/src/requests/LocationRequest.ts +++ b/src/requests/LocationRequest.ts @@ -14,6 +14,7 @@ class LocationRequest { country: Optional<string> = null; locality: Optional<string> = null; postalCode: Optional<string> = null; + description: Optional<string> = null; orderBy: string = 'timestamp'; order: string = 'DESC'; @@ -27,6 +28,7 @@ class LocationRequest { this.country = req.country; this.locality = req.locality; this.postalCode = req.postalCode; + this.description = req.description; this.orderBy = req.orderBy || this.orderBy; this.order = req.order || this.order; } @@ -95,6 +97,10 @@ class LocationRequest { where[colMapping.postalCode || 'postalCode'] = this.postalCode; } + if (this.description != null) { + where[colMapping.description || 'description'] = {[Op.like]: `%${this.description}%`}; + } + queryMap.where = where; queryMap.order = [[colMapping[this.orderBy], this.order.toUpperCase()]]; return queryMap; diff --git a/src/routes/api/v1/GPSData.ts b/src/routes/api/v1/GPSData.ts index 71e7354..5d88020 100644 --- a/src/routes/api/v1/GPSData.ts +++ b/src/routes/api/v1/GPSData.ts @@ -4,7 +4,7 @@ import { authenticate } from '../../../auth'; import { AuthInfo } from '../../../auth'; import { LocationRequest } from '../../../requests'; import { Optional } from '../../../types'; -import { RoleName } from '../../../models'; +import { GPSPoint, RoleName } from '../../../models'; import ApiV1Route from './Route'; class GPSData extends ApiV1Route { @@ -48,6 +48,20 @@ class GPSData extends ApiV1Route { res.status(201).send(); } + @authenticate() + patch = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + const points = (req.body as GPSPoint[]).map((p) => { + const descr = p.description?.trim() + p.description = descr?.length ? descr : null; + return p; + }); + + const deviceIds = points.map((p: any) => p.deviceId).filter((d: any) => !!d); + this.validateOwnership(deviceIds, auth!); + await $repos.location.updatePoints(points); + res.status(204).send(); + } + @authenticate() delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { const pointIds = req.body as number[];