From 967a653fae79e2b86bfa315982a5d248f888719b Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 16:28:11 -0400 Subject: [PATCH 01/54] feat: initial route support --- server/src/configs/default.json | 6 ++ server/src/configs/local.example.json | 8 ++- server/src/graphql/resolvers.js | 12 ++++ server/src/graphql/scannerTypes.js | 28 ++++++++ server/src/graphql/typeDefs.js | 8 +++ server/src/models/Route.js | 56 ++++++++++++++++ server/src/models/index.js | 2 + server/src/services/filters/builder/base.js | 4 ++ server/src/services/ui/primary.js | 1 + server/src/types.d.ts | 28 ++++++++ src/components/markers/route.js | 16 +++++ src/components/tiles/Route.jsx | 74 +++++++++++++++++++++ src/components/tiles/index.js | 2 + src/services/Query.js | 6 ++ src/services/queries/route.js | 59 ++++++++++++++++ 15 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 server/src/models/Route.js create mode 100644 src/components/markers/route.js create mode 100644 src/components/tiles/Route.jsx create mode 100644 src/services/queries/route.js diff --git a/server/src/configs/default.json b/server/src/configs/default.json index f855a994f..d1a5a477c 100644 --- a/server/src/configs/default.json +++ b/server/src/configs/default.json @@ -132,6 +132,7 @@ "nests", "pokestops", "pokemon", + "routes", "wayfarer", "s2cells", "scanAreas", @@ -834,6 +835,11 @@ "enabled": true, "trialPeriodEligible": false, "roles": [] + }, + "routes": { + "enabled": true, + "trialPeriodEligible": false, + "roles": [] } } }, diff --git a/server/src/configs/local.example.json b/server/src/configs/local.example.json index 6fc249f33..18f6afcb9 100644 --- a/server/src/configs/local.example.json +++ b/server/src/configs/local.example.json @@ -39,7 +39,8 @@ "pokestop", "scanCell", "spawnpoint", - "weather" + "weather", + "route" ] }, { @@ -236,6 +237,11 @@ "enabled": true, "trialPeriodEligible": false, "roles": [] + }, + "routes": { + "enabled": true, + "trialPeriodEligible": false, + "roles": [] } } }, diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index 9acddbd7f..b042d4347 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -141,6 +141,18 @@ const resolvers = { } return {} }, + route: (_, args, { perms, Db }) => { + if (perms?.routes) { + return Db.query('Route', 'getOne', args.id) + } + return {} + }, + routes: (_, args, { perms, Db }) => { + if (perms?.routes) { + return Db.query('Route', 'getAll', perms, args) + } + return [] + }, s2cells: (_, args, { perms }) => { if (perms?.s2cells) { const { onlyCells } = args.filters diff --git a/server/src/graphql/scannerTypes.js b/server/src/graphql/scannerTypes.js index 270802efd..ca3f2b1c5 100644 --- a/server/src/graphql/scannerTypes.js +++ b/server/src/graphql/scannerTypes.js @@ -222,4 +222,32 @@ module.exports = gql` status: String message: String } + + type Waypoint { + lat_degrees: Float + lon_degrees: Float + elevation_in_meters: Float + } + + type Route { + id: ID + name: String + description: String + distance_meters: Int + duration_seconds: Int + start_poi: String + end_poi: String + start_lat: Float + start_lon: Float + end_lat: Float + end_lon: Float + image: String + image_border_color: String + reversible: Boolean + tags: [String] + type: Int + updated: Int + version: Int + waypoints: [Waypoint] + } ` diff --git a/server/src/graphql/typeDefs.js b/server/src/graphql/typeDefs.js index 62d25039e..a43f0efa7 100644 --- a/server/src/graphql/typeDefs.js +++ b/server/src/graphql/typeDefs.js @@ -137,6 +137,14 @@ module.exports = gql` maxLon: Float filters: JSON ): [Weather] + route(id: ID): Route + routes( + minLat: Float + maxLat: Float + minLon: Float + maxLon: Float + filters: JSON + ): [Route] webhook(category: String, status: String, name: String): Poracle scanner(category: String, method: String, data: JSON): ScannerApi } diff --git a/server/src/models/Route.js b/server/src/models/Route.js new file mode 100644 index 000000000..fe912ab68 --- /dev/null +++ b/server/src/models/Route.js @@ -0,0 +1,56 @@ +const { Model } = require('objection') +const getAreaSql = require('../services/functions/getAreaSql') + +class Route extends Model { + static get tableName() { + return 'route' + } + + /** + * Returns the bare essentials for displaying on the map + * @param {import('../types').Permissions} perms + * @param {object} args + * @param {import('../types').DbContext} ctx + * @returns + */ + static async getAll(perms, args, ctx) { + const { areaRestrictions } = perms + const { onlyAreas } = args.filters + const query = this.query() + .select([ + 'id', + 'start_lat', + 'start_lon', + 'end_lat', + 'end_lon', + 'image', + 'image_border_color', + ]) + .whereBetween('start_lat', [args.minLat, args.maxLat]) + .andWhereBetween('start_lon', [args.minLon, args.maxLon]) + + if (!getAreaSql(query, areaRestrictions, onlyAreas, ctx.isMad, 'route')) { + return [] + } + const results = await query + + return results + } + + /** + * Returns the full route after querying it, generally from the Popup + * @param {number} id + */ + static async getOne(id) { + const result = await this.query().findById(id) + if (typeof result.waypoints === 'string') { + result.waypoints = JSON.parse(result.waypoints) + } + if (typeof result.tags === 'string') { + result.tags = JSON.parse(result.tags) + } + return result + } +} + +module.exports = Route diff --git a/server/src/models/index.js b/server/src/models/index.js index 48e128afb..27410bfff 100644 --- a/server/src/models/index.js +++ b/server/src/models/index.js @@ -9,6 +9,7 @@ const Pokestop = require('./Pokestop') const Pokemon = require('./Pokemon') const Portal = require('./Portal') const Ring = require('./Ring') +const Route = require('./Route') const ScanCell = require('./ScanCell') const Session = require('./Session') const Spawnpoint = require('./Spawnpoint') @@ -30,6 +31,7 @@ const scannerModels = { Pokestop, Pokemon, Portal, + Route, ScanCell, Spawnpoint, Weather, diff --git a/server/src/services/filters/builder/base.js b/server/src/services/filters/builder/base.js index 3681d11b1..8fa5819cf 100644 --- a/server/src/services/filters/builder/base.js +++ b/server/src/services/filters/builder/base.js @@ -104,6 +104,10 @@ module.exports = function buildDefault(perms, available, dbModels) { filter: pokemon.full, } : undefined, + routes: + perms.routes && dbModels.Route + ? { enabled: true, filter: {} } + : undefined, portals: perms.portals && dbModels.Portal ? { diff --git a/server/src/services/ui/primary.js b/server/src/services/ui/primary.js index efe5f7def..4e2255698 100644 --- a/server/src/services/ui/primary.js +++ b/server/src/services/ui/primary.js @@ -141,6 +141,7 @@ module.exports = function generateUi(filters, perms) { case 'scanAreas': ui[key].filterByAreas = true // eslint-disable-next-line no-fallthrough + case 'routes': case 'weather': ui[key].enabled = true break diff --git a/server/src/types.d.ts b/server/src/types.d.ts index 95e9871f3..4a194d7f2 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -238,3 +238,31 @@ type Test3 = Parameters export type Head = T extends [...infer Head, any] ? Head : any[] + +export interface Waypoint { + lat_degrees: number + lon_degrees: number + elevation_in_meters: number +} + +export interface Route { + id: string + name: string + description: string + distance_meters: number + duration_seconds: number + start_poi: string + end_poi: string + start_lat: number + end_lat: number + start_lon: number + end_lon: number + image: string + image_border_color: string + reversible: boolean + tags: string[] + type: number + updated: number + version: number + waypoints: Waypoint[] +} diff --git a/src/components/markers/route.js b/src/components/markers/route.js new file mode 100644 index 000000000..269dbc269 --- /dev/null +++ b/src/components/markers/route.js @@ -0,0 +1,16 @@ +// @ts-check +import { Icon } from 'leaflet' + +/** + * + * @param {string} iconUrl + * @returns + */ +export default function getRouteMarker(iconUrl) { + return new Icon({ + iconUrl, + iconAnchor: [20, 33.96], + popupAnchor: [-5, -37], + className: 'circle-image', + }) +} diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx new file mode 100644 index 000000000..8d5f079f3 --- /dev/null +++ b/src/components/tiles/Route.jsx @@ -0,0 +1,74 @@ +// @ts-check +import ErrorBoundary from '@components/ErrorBoundary' +import * as React from 'react' +import { Circle, Marker, Polyline, Popup } from 'react-leaflet' +import { useLazyQuery } from '@apollo/client' +import Query from '@services/Query' + +import routeMarker from '../markers/route' + +/** + * + * @param {{ + * item: import('../../../server/src/types').Route + * }} props + * @returns + */ +const RouteTile = ({ item }) => { + const [route, setRoute] = React.useState(item) + const [getFullRoute, { data }] = useLazyQuery(Query.routes('getOne'), { + variables: { id: item.id }, + }) + + React.useEffect(() => { + if (data?.route) { + setRoute(data.route) + } + }, [data]) + + const { waypoints = [], ...rest } = route || {} + return ( + <> + getFullRoute({ variables: { id: item.id } }), + }} + > + + {JSON.stringify(rest, null, 2)} + + + getFullRoute({ variables: { id: item.id } }), + }} + > + + {JSON.stringify(rest, null, 2)} + + + + {waypoints.map((waypoint) => ( + + ))} + [ + waypoint.lat_degrees, + waypoint.lon_degrees, + ])} + pathOptions={{ color: 'red', fillColor: 'red' }} + /> + + + ) +} + +export default React.memo(RouteTile, () => true) diff --git a/src/components/tiles/index.js b/src/components/tiles/index.js index 9758d8a8c..850e8fd93 100644 --- a/src/components/tiles/index.js +++ b/src/components/tiles/index.js @@ -10,6 +10,7 @@ import spawnpoints from './Spawnpoint' import submissionCells from './submissionCells/SubmissionCell' import weather from './Weather' import s2cells from './S2Cell' +import routes from './Route' export { devices, @@ -24,4 +25,5 @@ export { spawnpoints, weather, s2cells, + routes, } diff --git a/src/services/Query.js b/src/services/Query.js index 55289a9de..c9a0794e9 100644 --- a/src/services/Query.js +++ b/src/services/Query.js @@ -15,6 +15,7 @@ import scanner from './queries/scanner' import getGeocoder from './queries/geocoder' import * as user from './queries/user' import s2cell from './queries/s2cell' +import { getRoute, getRoutes } from './queries/route' export default class Query { static devices() { @@ -165,4 +166,9 @@ export default class Query { static s2cells() { return s2cell } + + static routes(method) { + if (method === 'getOne') return getRoute + return getRoutes + } } diff --git a/src/services/queries/route.js b/src/services/queries/route.js new file mode 100644 index 000000000..f3718cf27 --- /dev/null +++ b/src/services/queries/route.js @@ -0,0 +1,59 @@ +import { gql } from '@apollo/client' + +const core = gql` + fragment CoreRoute on Route { + id + start_lat + start_lon + end_lat + end_lon + image + image_border_color + } +` + +export const getRoute = gql` + ${core} + query Route(id: ID) { + route(id: $id) { + ...CoreRoute + } + } +` + +export const getRoutes = gql` + ${core} + query Routes( + $minLat: Float! + $minLon: Float! + $maxLat: Float! + $maxLon: Float! + $filters: JSON! + ) { + routes( + minLat: $minLat + minLon: $minLon + maxLat: $maxLat + maxLon: $maxLon + filters: $filters + ) { + ...CoreRoute + name + description + distance_meters + duration_seconds + start_poi + end_poi + reversible + tags + type + updated + version + waypoints { + lat_degrees + lon_degrees + elevation_in_meters + } + } + } +` From be46e3287467a5137b4bd7492523d2e5ad0aa271 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 16:35:58 -0400 Subject: [PATCH 02/54] fix: types --- server/src/graphql/scannerTypes.js | 4 ++-- server/src/types.d.ts | 4 ++-- src/services/queries/route.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/graphql/scannerTypes.js b/server/src/graphql/scannerTypes.js index ca3f2b1c5..c59d42a48 100644 --- a/server/src/graphql/scannerTypes.js +++ b/server/src/graphql/scannerTypes.js @@ -235,10 +235,10 @@ module.exports = gql` description: String distance_meters: Int duration_seconds: Int - start_poi: String - end_poi: String + start_fort_id: String start_lat: Float start_lon: Float + end_fort_id: String end_lat: Float end_lon: Float image: String diff --git a/server/src/types.d.ts b/server/src/types.d.ts index 4a194d7f2..f9cadb1ec 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -251,9 +251,9 @@ export interface Route { description: string distance_meters: number duration_seconds: number - start_poi: string - end_poi: string + start_ford_id: string start_lat: number + end_fort_id: string end_lat: number start_lon: number end_lon: number diff --git a/src/services/queries/route.js b/src/services/queries/route.js index f3718cf27..93c5994c1 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -42,8 +42,8 @@ export const getRoutes = gql` description distance_meters duration_seconds - start_poi - end_poi + start_fort_id + end_fort_id reversible tags type From 2cf18c215590c85ce523b59a15684b9e4164ba75 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 16:51:26 -0400 Subject: [PATCH 03/54] fix: missing $ --- src/services/queries/route.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/queries/route.js b/src/services/queries/route.js index 93c5994c1..2ead6bb5b 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -14,7 +14,7 @@ const core = gql` export const getRoute = gql` ${core} - query Route(id: ID) { + query Route($id: ID) { route(id: $id) { ...CoreRoute } From aed27740e773026406f4e14df1c509c919f70fd1 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 17:03:02 -0400 Subject: [PATCH 04/54] fix: dumb ui --- server/src/services/ui/primary.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/services/ui/primary.js b/server/src/services/ui/primary.js index 4e2255698..f3d0f3a3d 100644 --- a/server/src/services/ui/primary.js +++ b/server/src/services/ui/primary.js @@ -126,6 +126,7 @@ module.exports = function generateUi(filters, perms) { (!ignoredKeys.includes(subKey) && subValue !== undefined) || key === 'weather' || key === 'scanAreas' || + key === 'routes' || (key === 's2cells' && subKey !== 'filter') ) { switch (key) { From 4f7312fc91751efb4d268ccf49b7445ba033700d Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 17:39:08 -0400 Subject: [PATCH 05/54] fix: query correctly & popup management --- server/src/graphql/scannerTypes.js | 2 +- server/src/types.d.ts | 2 +- src/components/tiles/Route.jsx | 35 +++++++++++++++++++++++------- src/services/queries/route.js | 32 +++++++++++++-------------- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/server/src/graphql/scannerTypes.js b/server/src/graphql/scannerTypes.js index c59d42a48..c2300720b 100644 --- a/server/src/graphql/scannerTypes.js +++ b/server/src/graphql/scannerTypes.js @@ -225,7 +225,7 @@ module.exports = gql` type Waypoint { lat_degrees: Float - lon_degrees: Float + lng_degrees: Float elevation_in_meters: Float } diff --git a/server/src/types.d.ts b/server/src/types.d.ts index f9cadb1ec..a4b880d38 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -241,7 +241,7 @@ export type Head = T extends [...infer Head, any] export interface Waypoint { lat_degrees: number - lon_degrees: number + lng_degrees: number elevation_in_meters: number } diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 8d5f079f3..19c3f7a6d 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -16,6 +16,10 @@ import routeMarker from '../markers/route' */ const RouteTile = ({ item }) => { const [route, setRoute] = React.useState(item) + const [open, setOpen] = React.useState(0) + const refOne = React.useRef(null) + const refTwo = React.useRef(null) + const [getFullRoute, { data }] = useLazyQuery(Query.routes('getOne'), { variables: { id: item.id }, }) @@ -26,14 +30,26 @@ const RouteTile = ({ item }) => { } }, [data]) + React.useEffect(() => { + if (open === 1 && refOne.current) { + refOne.current.openPopup() + } + if (open === 2 && refTwo.current) { + refTwo.current.openPopup() + } + }) + const { waypoints = [], ...rest } = route || {} return ( <> getFullRoute({ variables: { id: item.id } }), + click: () => getFullRoute({ variables: { id: item.id } }), + popupclose: () => setOpen(0), + popupopen: () => setOpen(1), }} > @@ -43,8 +59,11 @@ const RouteTile = ({ item }) => { getFullRoute({ variables: { id: item.id } }), + click: () => getFullRoute({ variables: { id: item.id } }), + popupclose: () => setOpen(0), + popupopen: () => setOpen(2), }} > @@ -52,17 +71,17 @@ const RouteTile = ({ item }) => { - {waypoints.map((waypoint) => ( + {(waypoints || []).map((waypoint) => ( ))} [ + positions={(waypoints || []).map((waypoint) => [ waypoint.lat_degrees, - waypoint.lon_degrees, + waypoint.lng_degrees, ])} pathOptions={{ color: 'red', fillColor: 'red' }} /> diff --git a/src/services/queries/route.js b/src/services/queries/route.js index 2ead6bb5b..38e2d2f29 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -17,6 +17,22 @@ export const getRoute = gql` query Route($id: ID) { route(id: $id) { ...CoreRoute + name + description + distance_meters + duration_seconds + start_fort_id + end_fort_id + reversible + tags + type + updated + version + waypoints { + lat_degrees + lng_degrees + elevation_in_meters + } } } ` @@ -38,22 +54,6 @@ export const getRoutes = gql` filters: $filters ) { ...CoreRoute - name - description - distance_meters - duration_seconds - start_fort_id - end_fort_id - reversible - tags - type - updated - version - waypoints { - lat_degrees - lon_degrees - elevation_in_meters - } } } ` From 30e92acebdf854cdc5a78a1c1b3d1d8001963170 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 20:18:40 -0400 Subject: [PATCH 06/54] fix: filter by start and end --- server/src/configs/default.json | 1 + server/src/models/Route.js | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/server/src/configs/default.json b/server/src/configs/default.json index d1a5a477c..8935a8ca7 100644 --- a/server/src/configs/default.json +++ b/server/src/configs/default.json @@ -27,6 +27,7 @@ "pokemon": 20, "pokestops": 300, "portals": 300, + "routes": 10000, "scanAreas": 10000, "scanCells": 10, "submissionCells": 500, diff --git a/server/src/models/Route.js b/server/src/models/Route.js index fe912ab68..981635cfe 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -16,6 +16,7 @@ class Route extends Model { static async getAll(perms, args, ctx) { const { areaRestrictions } = perms const { onlyAreas } = args.filters + const query = this.query() .select([ 'id', @@ -23,18 +24,36 @@ class Route extends Model { 'start_lon', 'end_lat', 'end_lon', - 'image', - 'image_border_color', + 'waypoints', ]) .whereBetween('start_lat', [args.minLat, args.maxLat]) .andWhereBetween('start_lon', [args.minLon, args.maxLon]) + .union((qb) => + qb + .select([ + 'id', + 'start_lat', + 'start_lon', + 'end_lat', + 'end_lon', + 'waypoints', + ]) + .whereBetween('end_lat', [args.minLat, args.maxLat]) + .andWhereBetween('end_lon', [args.minLon, args.maxLon]) + .from('route'), + ) if (!getAreaSql(query, areaRestrictions, onlyAreas, ctx.isMad, 'route')) { return [] } const results = await query - return results + return results.map((result) => { + if (typeof result.waypoints === 'string') { + result.waypoints = JSON.parse(result.waypoints) + } + return result + }) } /** From 7805a58314383bff952da5bb3360358ac9b1eecd Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 20:19:00 -0400 Subject: [PATCH 07/54] styling: show game icons --- src/assets/css/main.css | 10 ++++++++++ src/components/markers/route.js | 9 ++++----- src/components/tiles/Route.jsx | 29 +++++++++++++++++++++-------- src/services/queries/route.js | 14 +++++++------- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/assets/css/main.css b/src/assets/css/main.css index e32bc879f..9ac97311d 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -76,6 +76,16 @@ body { -moz-box-shadow: 0 0 10px rgba(0, 0, 0, 1); } +.circle-route-start { + background-color: #00a3ee; + border-radius: 50%; +} + +.circle-route-end { + background-color: #ff4b4d; + border-radius: 50%; +} + .invasion-exists { border: 4px solid rgb(141, 13, 13); } diff --git a/src/components/markers/route.js b/src/components/markers/route.js index 269dbc269..1cdc0d01b 100644 --- a/src/components/markers/route.js +++ b/src/components/markers/route.js @@ -2,15 +2,14 @@ import { Icon } from 'leaflet' /** - * * @param {string} iconUrl + * @param {boolean} end * @returns */ -export default function getRouteMarker(iconUrl) { +export default function getRouteMarker(iconUrl, end = false) { return new Icon({ iconUrl, - iconAnchor: [20, 33.96], - popupAnchor: [-5, -37], - className: 'circle-image', + iconSize: [32, 32], + className: `circle-route-${end ? 'end' : 'start'}`, }) } diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 19c3f7a6d..9692292c0 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -1,7 +1,7 @@ // @ts-check import ErrorBoundary from '@components/ErrorBoundary' import * as React from 'react' -import { Circle, Marker, Polyline, Popup } from 'react-leaflet' +import { CircleMarker, Marker, Polyline, Popup } from 'react-leaflet' import { useLazyQuery } from '@apollo/client' import Query from '@services/Query' @@ -11,11 +11,23 @@ import routeMarker from '../markers/route' * * @param {{ * item: import('../../../server/src/types').Route + * Icons: InstanceType * }} props * @returns */ -const RouteTile = ({ item }) => { - const [route, setRoute] = React.useState(item) +const RouteTile = ({ item, Icons }) => { + const [route, setRoute] = React.useState({ + ...item, + waypoints: [ + { + lat_degrees: item.start_lat, + lng_degrees: item.start_lon, + elevation_in_meters: 0, + }, + ...item.waypoints, + { lat_degrees: item.end_lat, lng_degrees: item.end_lon }, + ], + }) const [open, setOpen] = React.useState(0) const refOne = React.useRef(null) const refTwo = React.useRef(null) @@ -26,7 +38,7 @@ const RouteTile = ({ item }) => { React.useEffect(() => { if (data?.route) { - setRoute(data.route) + setRoute({ ...route, ...data.route }) } }, [data]) @@ -40,11 +52,12 @@ const RouteTile = ({ item }) => { }) const { waypoints = [], ...rest } = route || {} + return ( <> getFullRoute({ variables: { id: item.id } }), @@ -58,7 +71,7 @@ const RouteTile = ({ item }) => { getFullRoute({ variables: { id: item.id } }), @@ -72,8 +85,8 @@ const RouteTile = ({ item }) => { {(waypoints || []).map((waypoint) => ( - diff --git a/src/services/queries/route.js b/src/services/queries/route.js index 38e2d2f29..f8f763d7c 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -7,8 +7,6 @@ const core = gql` start_lon end_lat end_lon - image - image_border_color } ` @@ -18,6 +16,8 @@ export const getRoute = gql` route(id: $id) { ...CoreRoute name + image + image_border_color description distance_meters duration_seconds @@ -28,11 +28,6 @@ export const getRoute = gql` type updated version - waypoints { - lat_degrees - lng_degrees - elevation_in_meters - } } } ` @@ -54,6 +49,11 @@ export const getRoutes = gql` filters: $filters ) { ...CoreRoute + waypoints { + lat_degrees + lng_degrees + elevation_in_meters + } } } ` From 4609297349df9a7359b0608952e9a1cf8568bc8d Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:08:56 -0400 Subject: [PATCH 08/54] fix: dump of info in popup --- .vscode/settings.json | 2 +- locales/en.json | 5 +- server/src/models/Route.js | 27 +++----- server/src/types.d.ts | 6 -- src/components/popups/Route.jsx | 90 +++++++++++++++++++++++++ src/components/popups/common/Timer.jsx | 2 +- src/components/popups/common/Title.jsx | 14 +++- src/components/tiles/Route.jsx | 92 ++++++++------------------ 8 files changed, 148 insertions(+), 90 deletions(-) create mode 100644 src/components/popups/Route.jsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 252ba1a83..b319edba0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "i18n-ally.localesPaths": [ - "public/base-locales" + "locales" ], "i18n-ally.keystyle": "flat", "[javascript]": { diff --git a/locales/en.json b/locales/en.json index 23d46b9bf..566f2ebbe 100644 --- a/locales/en.json +++ b/locales/en.json @@ -626,5 +626,8 @@ "level_circles": "Level Indicators", "min_level_circle": "Minimum Circle Level", "mutation_auth_error": "Your request was unsuccessful due to not being logged in", - "submitted_by": "Submitted By" + "submitted_by": "Submitted By", + "reversible": "Reversible", + "version": "Verison", + "route_tags": "Route Tags" } diff --git a/server/src/models/Route.js b/server/src/models/Route.js index 981635cfe..80643daa6 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -1,6 +1,15 @@ const { Model } = require('objection') const getAreaSql = require('../services/functions/getAreaSql') +const GET_ALL_SELECT = /** @type {const} */ ([ + 'id', + 'start_lat', + 'start_lon', + 'end_lat', + 'end_lon', + 'waypoints', +]) + class Route extends Model { static get tableName() { return 'route' @@ -18,26 +27,12 @@ class Route extends Model { const { onlyAreas } = args.filters const query = this.query() - .select([ - 'id', - 'start_lat', - 'start_lon', - 'end_lat', - 'end_lon', - 'waypoints', - ]) + .select(GET_ALL_SELECT) .whereBetween('start_lat', [args.minLat, args.maxLat]) .andWhereBetween('start_lon', [args.minLon, args.maxLon]) .union((qb) => qb - .select([ - 'id', - 'start_lat', - 'start_lon', - 'end_lat', - 'end_lon', - 'waypoints', - ]) + .select(GET_ALL_SELECT) .whereBetween('end_lat', [args.minLat, args.maxLat]) .andWhereBetween('end_lon', [args.minLon, args.maxLon]) .from('route'), diff --git a/server/src/types.d.ts b/server/src/types.d.ts index a4b880d38..8a41b1748 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -229,12 +229,6 @@ export type PickMatching = { export type ExtractMethods = PickMatching -type Test = ExtractMethods - -type Test2 = ReturnType - -type Test3 = Parameters - export type Head = T extends [...infer Head, any] ? Head : any[] diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx new file mode 100644 index 000000000..902c72b1b --- /dev/null +++ b/src/components/popups/Route.jsx @@ -0,0 +1,90 @@ +// @ts-check +/* eslint-disable react/destructuring-assignment */ +import * as React from 'react' +import { Popup } from 'react-leaflet' +import { useQuery } from '@apollo/client' +import { Avatar, Box, Typography } from '@mui/material' +import { useTranslation } from 'react-i18next' +import CheckIcon from '@mui/icons-material/Check' +import CloseIcon from '@mui/icons-material/Close' + +import Query from '@services/Query' + +import Title from './common/Title' +import TimeSince from './common/Timer' + +/** + * + * @param {import('../../../server/src/types').Route} props + * @returns + */ +export default function RoutePopup(props) { + const [route, setRoute] = React.useState({ ...props, tags: [] }) + + const { data } = useQuery(Query.routes('getOne'), { + variables: { id: props.id }, + }) + const { t } = useTranslation() + + React.useEffect(() => { + if (data?.route) { + setRoute({ ...route, ...data.route }) + } + }, [data]) + + return ( + + *': { + padding: 0, + }, + }} + > + {route.name} + + {route.description} + + {t('distance', 'Distance')}: {route.distance_meters}m + + + {t('poi', 'Points of Interest')}: {route.waypoints.length} + + + {t('reversible', 'reversible')}:{' '} + {route.reversible ? ( + + ) : ( + + )} + + + {t('version', 'Version')}: {route.version} + + {t('route_tags')} + {route.tags.map((tag) => ( + + {t(tag)} + + ))} + + {t(`route_type_${route.type}`)} + + {t('last_updated')} + + + + ) +} diff --git a/src/components/popups/common/Timer.jsx b/src/components/popups/common/Timer.jsx index cd8f9f63a..abd45c7d8 100644 --- a/src/components/popups/common/Timer.jsx +++ b/src/components/popups/common/Timer.jsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import Utility from '@services/Utility' -export default function TimeSince({ expireTime, until }) { +export default function TimeSince({ expireTime, until = false }) { const { t } = useTranslation() const endTime = new Date(expireTime * 1000) const [timerEnd, setTimerEnd] = useState(Utility.getTimeUntil(endTime, until)) diff --git a/src/components/popups/common/Title.jsx b/src/components/popups/common/Title.jsx index 9c46f7e0e..e47dfc596 100644 --- a/src/components/popups/common/Title.jsx +++ b/src/components/popups/common/Title.jsx @@ -1,9 +1,18 @@ -import React from 'react' +import * as React from 'react' import { Typography } from '@mui/material' import { useStore } from '@hooks/useStore' -export default function Title({ children, backup }) { +/** + * + * @param {{ + * children: React.ReactNode, + * backup?: string, + * sx?: import('@mui/material').SxProps + * }} props + * @returns + */ +export default function Title({ children, backup, sx }) { const popups = useStore((state) => state.popups) return ( @@ -19,6 +28,7 @@ export default function Title({ children, backup }) { }, })) } + sx={sx} > {children || backup} diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 9692292c0..6cd7439d5 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -1,12 +1,14 @@ // @ts-check -import ErrorBoundary from '@components/ErrorBoundary' import * as React from 'react' -import { CircleMarker, Marker, Polyline, Popup } from 'react-leaflet' -import { useLazyQuery } from '@apollo/client' -import Query from '@services/Query' +import { CircleMarker, Marker, Polyline } from 'react-leaflet' + +import ErrorBoundary from '@components/ErrorBoundary' +import RoutePopup from '@components/popups/Route' import routeMarker from '../markers/route' +const POSITIONS = /** @type {const} */ (['start', 'end']) + /** * * @param {{ @@ -16,75 +18,39 @@ import routeMarker from '../markers/route' * @returns */ const RouteTile = ({ item, Icons }) => { - const [route, setRoute] = React.useState({ - ...item, - waypoints: [ + const waypoints = React.useMemo( + () => [ { lat_degrees: item.start_lat, lng_degrees: item.start_lon, elevation_in_meters: 0, }, ...item.waypoints, - { lat_degrees: item.end_lat, lng_degrees: item.end_lon }, + { + lat_degrees: item.end_lat, + lng_degrees: item.end_lon, + elevation_in_meters: 1, + }, ], - }) - const [open, setOpen] = React.useState(0) - const refOne = React.useRef(null) - const refTwo = React.useRef(null) - - const [getFullRoute, { data }] = useLazyQuery(Query.routes('getOne'), { - variables: { id: item.id }, - }) - - React.useEffect(() => { - if (data?.route) { - setRoute({ ...route, ...data.route }) - } - }, [data]) - - React.useEffect(() => { - if (open === 1 && refOne.current) { - refOne.current.openPopup() - } - if (open === 2 && refTwo.current) { - refTwo.current.openPopup() - } - }) - - const { waypoints = [], ...rest } = route || {} + [item], + ) return ( <> - getFullRoute({ variables: { id: item.id } }), - popupclose: () => setOpen(0), - popupopen: () => setOpen(1), - }} - > - - {JSON.stringify(rest, null, 2)} - - - getFullRoute({ variables: { id: item.id } }), - popupclose: () => setOpen(0), - popupopen: () => setOpen(2), - }} - > - - {JSON.stringify(rest, null, 2)} - - + {POSITIONS.map((position) => ( + + + + ))} - {(waypoints || []).map((waypoint) => ( + {waypoints.map((waypoint) => ( { /> ))} [ + positions={waypoints.map((waypoint) => [ waypoint.lat_degrees, waypoint.lng_degrees, ])} From 1db5adad528f4aebe637d31a6977580ddafd5baa Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:11:08 -0400 Subject: [PATCH 09/54] 0m fallback --- src/components/popups/Route.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 902c72b1b..57c15179b 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -57,7 +57,7 @@ export default function RoutePopup(props) { /> {route.description} - {t('distance', 'Distance')}: {route.distance_meters}m + {t('distance', 'Distance')}: {route.distance_meters || 0}m {t('poi', 'Points of Interest')}: {route.waypoints.length} From 0bd58091d3b4331b8caeed598a59d3976988ac44 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:15:57 -0400 Subject: [PATCH 10/54] fix: areaRestrictions --- server/src/services/functions/getAreaSql.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/src/services/functions/getAreaSql.js b/server/src/services/functions/getAreaSql.js index d6a21e934..7d5c3b5df 100644 --- a/server/src/services/functions/getAreaSql.js +++ b/server/src/services/functions/getAreaSql.js @@ -33,9 +33,10 @@ module.exports = function getAreaRestrictionSql( } } else if (category === 'device') { columns = columns.map((each) => `last_${each}`) - } - if (category === 's2cell') { + } else if (category === 's2cell') { columns = columns.map((each) => `center_${each}`) + } else if (category === 'route') { + columns = columns.map((each) => `start_${each}`) } query.andWhere((restrictions) => { @@ -46,6 +47,13 @@ module.exports = function getAreaRestrictionSql( config.areas.polygons[area], )}', 2, 0), POINT(${columns[1]}, ${columns[0]}))`, ) + if (category === 'route') { + restrictions.orWhereRaw( + `ST_CONTAINS(ST_GeomFromGeoJSON('${JSON.stringify( + config.areas.polygons[area], + )}', 2, 0), POINT(end_lon, end_lat))`, + ) + } } }) }) From 6e58e866559d8b9b8b931ba0d0923e11a1a70383 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:18:24 -0400 Subject: [PATCH 11/54] fix: `routes` translation --- locales/en.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/locales/en.json b/locales/en.json index 566f2ebbe..b24f2da55 100644 --- a/locales/en.json +++ b/locales/en.json @@ -629,5 +629,6 @@ "submitted_by": "Submitted By", "reversible": "Reversible", "version": "Verison", - "route_tags": "Route Tags" -} + "route_tags": "Route Tags", + "routes": "Routes" +} \ No newline at end of file From ab8cb0ca5d0196ca797efe30aecbb8c4b9b05e65 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:23:33 -0400 Subject: [PATCH 12/54] fix: add routes to allowed menu items --- server/src/services/config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/services/config.js b/server/src/services/config.js index 2b5ef7c5a..e84a80151 100644 --- a/server/src/services/config.js +++ b/server/src/services/config.js @@ -17,6 +17,7 @@ const allowedMenuItems = [ 'weather', 'admin', 'settings', + 'routes', ] try { From 59e779b5865c5c33613300992c852e68ea67d271 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:33:56 -0400 Subject: [PATCH 13/54] fix: tag and waypoint fallbacks --- server/src/models/Route.js | 6 ++++++ src/components/popups/Route.jsx | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/src/models/Route.js b/server/src/models/Route.js index 80643daa6..6fb69b240 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -46,6 +46,8 @@ class Route extends Model { return results.map((result) => { if (typeof result.waypoints === 'string') { result.waypoints = JSON.parse(result.waypoints) + } else if (result.waypoints === null) { + result.waypoints = [] } return result }) @@ -59,9 +61,13 @@ class Route extends Model { const result = await this.query().findById(id) if (typeof result.waypoints === 'string') { result.waypoints = JSON.parse(result.waypoints) + } else if (result.waypoints === null) { + result.waypoints = [] } if (typeof result.tags === 'string') { result.tags = JSON.parse(result.tags) + } else if (result.tags === null) { + result.tags = [] } return result } diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 57c15179b..496d9ac36 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -28,7 +28,12 @@ export default function RoutePopup(props) { React.useEffect(() => { if (data?.route) { - setRoute({ ...route, ...data.route }) + setRoute({ + ...route, + ...data.route, + waypoints: route.waypoints || [], + tags: data.route.tags || [], + }) } }, [data]) From 6bf28b1d67087abd2939d630c366bc2fb78564bd Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:34:14 -0400 Subject: [PATCH 14/54] styling: remove circles on waypoints --- src/components/tiles/Route.jsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 6cd7439d5..08ab71d21 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -1,6 +1,6 @@ // @ts-check import * as React from 'react' -import { CircleMarker, Marker, Polyline } from 'react-leaflet' +import { Marker, Polyline } from 'react-leaflet' import ErrorBoundary from '@components/ErrorBoundary' import RoutePopup from '@components/popups/Route' @@ -50,13 +50,6 @@ const RouteTile = ({ item, Icons }) => { ))} - {waypoints.map((waypoint) => ( - - ))} [ waypoint.lat_degrees, From 3b9922ff96284d7cb7b52739b950cfd0f6df4c3b Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 22:34:42 -0400 Subject: [PATCH 15/54] fix: change poi translation --- src/components/popups/Route.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 496d9ac36..c09e44ba2 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -65,7 +65,7 @@ export default function RoutePopup(props) { {t('distance', 'Distance')}: {route.distance_meters || 0}m - {t('poi', 'Points of Interest')}: {route.waypoints.length} + {t('points', 'Points')}: {route.waypoints.length} {t('reversible', 'reversible')}:{' '} From 51cb7149c05b1dd3989aa15e9345ad069eaac6d7 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 27 Jul 2023 23:37:37 -0400 Subject: [PATCH 16/54] styling: popup cleanup --- locales/en.json | 5 +- src/components/popups/Route.jsx | 193 +++++++++++++++++++++++--------- 2 files changed, 146 insertions(+), 52 deletions(-) diff --git a/locales/en.json b/locales/en.json index b24f2da55..f7294595b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -630,5 +630,6 @@ "reversible": "Reversible", "version": "Verison", "route_tags": "Route Tags", - "routes": "Routes" -} \ No newline at end of file + "routes": "Routes", + "route_type": "Route Type" +} diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index c09e44ba2..d3ac5b7f3 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -3,16 +3,68 @@ import * as React from 'react' import { Popup } from 'react-leaflet' import { useQuery } from '@apollo/client' -import { Avatar, Box, Typography } from '@mui/material' import { useTranslation } from 'react-i18next' +import Grid2 from '@mui/material/Unstable_Grid2' +import Avatar from '@mui/material/Avatar' +import Typography from '@mui/material/Typography' import CheckIcon from '@mui/icons-material/Check' import CloseIcon from '@mui/icons-material/Close' +import ExpandMore from '@mui/icons-material/ExpandMore' +import Chip from '@mui/material/Chip' +import Collapse from '@mui/material/Collapse' +import Divider from '@mui/material/Divider' +import IconButton from '@mui/material/IconButton' +import List from '@mui/material/List' +import ListItem from '@mui/material/ListItem' +import ListItemText from '@mui/material/ListItemText' import Query from '@services/Query' +import { useStore } from '@hooks/useStore' import Title from './common/Title' import TimeSince from './common/Timer' +/** + * + * @param {{ + * primary: string + * primaryTypographyProps?: import('@mui/material/Typography').TypographyProps + * sx?: import('@mui/material').SxProps + * children?: React.ReactNode + * }} props + * @returns + */ +function ListItemWrapper({ + primary, + primaryTypographyProps, + sx, + children = null, +}) { + const { t } = useTranslation() + + return ( + + + {typeof children === 'object' ? ( + children + ) : ( + + )} + + ) +} + /** * * @param {import('../../../server/src/types').Route} props @@ -20,6 +72,8 @@ import TimeSince from './common/Timer' */ export default function RoutePopup(props) { const [route, setRoute] = React.useState({ ...props, tags: [] }) + // @ts-ignore + const expanded = useStore((s) => !!s.popups.tags) const { data } = useQuery(Query.routes('getOne'), { variables: { id: props.id }, @@ -39,57 +93,96 @@ export default function RoutePopup(props) { return ( - *': { - padding: 0, - }, - }} + - {route.name} - - {route.description} - - {t('distance', 'Distance')}: {route.distance_meters || 0}m - - - {t('points', 'Points')}: {route.waypoints.length} - - - {t('reversible', 'reversible')}:{' '} - {route.reversible ? ( - - ) : ( - - )} - - - {t('version', 'Version')}: {route.version} - - {t('route_tags')} - {route.tags.map((tag) => ( - - {t(tag)} + + {route.name} + + + + + + + {route.description} - ))} - - {t(`route_type_${route.type}`)} - - {t('last_updated')} - - + + + + + {`${route.distance_meters || 0}m`} + + + {route.waypoints.length} + + + {route.reversible ? ( + + ) : ( + + )} + + + {t(`route_type_${route.type}`)} + + {route.version} + + + + + {t('route_tags')} + + + + + useStore.setState((prev) => ({ + // @ts-ignore + popups: { ...prev.popups, tags: !prev.popups.tags }, + })) + } + > + + + + + + {route.tags.map((tag) => ( + + ))} + + + + + + {t('last_updated')}: + + + + + + ) } From f929298dee0db491a50648cddacb233b8667fa85 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 00:05:15 -0400 Subject: [PATCH 17/54] styling: trim description --- src/components/popups/Route.jsx | 19 +++++++++++++------ src/components/popups/common/Title.jsx | 11 ++++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index d3ac5b7f3..8f4c413cd 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -47,7 +47,7 @@ function ListItemWrapper({ - {route.description} + {route.description.length > 75 + ? `${route.description.slice(0, 75).trim()}...` + : route.description} @@ -138,9 +140,9 @@ export default function RoutePopup(props) { {route.version} - + - + {t('route_tags')} @@ -160,7 +162,12 @@ export default function RoutePopup(props) { {route.tags.map((tag) => ( - + {t('last_updated')}: diff --git a/src/components/popups/common/Title.jsx b/src/components/popups/common/Title.jsx index e47dfc596..0064d3777 100644 --- a/src/components/popups/common/Title.jsx +++ b/src/components/popups/common/Title.jsx @@ -8,23 +8,24 @@ import { useStore } from '@hooks/useStore' * @param {{ * children: React.ReactNode, * backup?: string, + * variant?: import('@mui/material/Typography').TypographyProps['variant'], * sx?: import('@mui/material').SxProps * }} props * @returns */ -export default function Title({ children, backup, sx }) { - const popups = useStore((state) => state.popups) +export default function Title({ children, variant = 'subtitle2', backup, sx }) { + const names = useStore((state) => !!state.popups.names) return ( useStore.setState((prev) => ({ popups: { ...prev.popups, - names: !popups.names, + names: !prev.popups.names, }, })) } From d776ac6cacb4180a3c1ca1a55199dc5abc8fb939 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 00:12:07 -0400 Subject: [PATCH 18/54] Update Route.jsx --- src/components/popups/Route.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 8f4c413cd..a7cd13c5e 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -115,7 +115,7 @@ export default function RoutePopup(props) { - {route.description.length > 75 + {route.description?.length > 75 ? `${route.description.slice(0, 75).trim()}...` : route.description} From 1406656f642f2e2792f6cb72549d33fc8c9feef5 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 08:41:41 -0400 Subject: [PATCH 19/54] feat: add start/end image support and navigation icon --- server/src/graphql/scannerTypes.js | 2 ++ server/src/types.d.ts | 2 ++ src/components/markers/route.js | 6 ++-- src/components/popups/Route.jsx | 51 ++++++++++++++++++++++-------- src/components/tiles/Route.jsx | 11 ++++--- src/services/queries/route.js | 8 +++-- 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/server/src/graphql/scannerTypes.js b/server/src/graphql/scannerTypes.js index c2300720b..e7d54991d 100644 --- a/server/src/graphql/scannerTypes.js +++ b/server/src/graphql/scannerTypes.js @@ -238,9 +238,11 @@ module.exports = gql` start_fort_id: String start_lat: Float start_lon: Float + start_image: String end_fort_id: String end_lat: Float end_lon: Float + end_image: String image: String image_border_color: String reversible: Boolean diff --git a/server/src/types.d.ts b/server/src/types.d.ts index 8a41b1748..9a91c865a 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -247,10 +247,12 @@ export interface Route { duration_seconds: number start_ford_id: string start_lat: number + start_image: string end_fort_id: string end_lat: number start_lon: number end_lon: number + end_image: string image: string image_border_color: string reversible: boolean diff --git a/src/components/markers/route.js b/src/components/markers/route.js index 1cdc0d01b..bda901900 100644 --- a/src/components/markers/route.js +++ b/src/components/markers/route.js @@ -3,13 +3,13 @@ import { Icon } from 'leaflet' /** * @param {string} iconUrl - * @param {boolean} end + * @param {'start' | 'end'} position * @returns */ -export default function getRouteMarker(iconUrl, end = false) { +export default function getRouteMarker(iconUrl, position) { return new Icon({ iconUrl, iconSize: [32, 32], - className: `circle-route-${end ? 'end' : 'start'}`, + className: `circle-route-${position}`, }) } diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index a7cd13c5e..2fdf39e1b 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -12,7 +12,6 @@ import CloseIcon from '@mui/icons-material/Close' import ExpandMore from '@mui/icons-material/ExpandMore' import Chip from '@mui/material/Chip' import Collapse from '@mui/material/Collapse' -import Divider from '@mui/material/Divider' import IconButton from '@mui/material/IconButton' import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' @@ -23,6 +22,7 @@ import { useStore } from '@hooks/useStore' import Title from './common/Title' import TimeSince from './common/Timer' +import Navigation from './common/Navigation' /** * @@ -67,10 +67,10 @@ function ListItemWrapper({ /** * - * @param {import('../../../server/src/types').Route} props + * @param {import('../../../server/src/types').Route & { end?: boolean }} props * @returns */ -export default function RoutePopup(props) { +export default function RoutePopup({ end, ...props }) { const [route, setRoute] = React.useState({ ...props, tags: [] }) // @ts-ignore const expanded = useStore((s) => !!s.popups.tags) @@ -91,6 +91,9 @@ export default function RoutePopup(props) { } }, [data]) + const imagesAreEqual = + route.image === (end ? route.end_image : route.start_image) + return ( {route.name} - + + {!imagesAreEqual && ( + + + + )} {route.description?.length > 75 @@ -140,6 +161,14 @@ export default function RoutePopup(props) { {route.version} + + + {t('last_updated')}: + + + + + @@ -179,15 +208,11 @@ export default function RoutePopup(props) { /> ))} - - - - - {t('last_updated')}: - - - - + + diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 08ab71d21..88dbb011d 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -41,12 +41,13 @@ const RouteTile = ({ item, Icons }) => { - + ))} diff --git a/src/services/queries/route.js b/src/services/queries/route.js index f8f763d7c..85fb5b09c 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -20,9 +20,11 @@ export const getRoute = gql` image_border_color description distance_meters - duration_seconds - start_fort_id - end_fort_id + # duration_seconds + # start_fort_id + start_image + end_image + # end_fort_id reversible tags type From e641157c4dbc672a6459543c8c38e641a8db803b Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 08:42:06 -0400 Subject: [PATCH 20/54] minor version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f0a59a862..26134ff6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.21.1", + "version": "1.22.0", "description": "React based frontend map.", "main": "ReactMap.js", "author": "TurtIeSocks <58572875+TurtIeSocks@users.noreply.github.com>", From ef1a8b0e70f5566d46e1c084bd412d50c3fb9620 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 08:57:39 -0400 Subject: [PATCH 21/54] styling: use image_border_color for route --- server/src/models/Route.js | 1 + src/components/popups/Route.jsx | 14 ++++++++------ src/components/tiles/Route.jsx | 5 ++++- src/services/queries/route.js | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/server/src/models/Route.js b/server/src/models/Route.js index 6fb69b240..81a9252f6 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -8,6 +8,7 @@ const GET_ALL_SELECT = /** @type {const} */ ([ 'end_lat', 'end_lon', 'waypoints', + 'image_border_color', ]) class Route extends Model { diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 2fdf39e1b..ede226610 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -24,6 +24,8 @@ import Title from './common/Title' import TimeSince from './common/Timer' import Navigation from './common/Navigation' +const IMAGE_SIZE = 80 + /** * * @param {{ @@ -115,27 +117,27 @@ export default function RoutePopup({ end, ...props }) { alt={route.name} src={route.image} style={{ - width: 120, - height: 120, + width: IMAGE_SIZE, + height: IMAGE_SIZE, border: `4px solid #${route.image_border_color}`, }} /> - {!imagesAreEqual && ( + {!imagesAreEqual && (end ? route.end_image : route.start_image) && ( )} - + {route.description?.length > 75 ? `${route.description.slice(0, 75).trim()}...` : route.description} diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 88dbb011d..0d9e901c2 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -56,7 +56,10 @@ const RouteTile = ({ item, Icons }) => { waypoint.lat_degrees, waypoint.lng_degrees, ])} - pathOptions={{ color: 'red', fillColor: 'red' }} + pathOptions={{ + color: `#${item.image_border_color}`, + fillColor: `#${item.image_border_color}`, + }} /> diff --git a/src/services/queries/route.js b/src/services/queries/route.js index 85fb5b09c..11bdb1bc6 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -17,7 +17,6 @@ export const getRoute = gql` ...CoreRoute name image - image_border_color description distance_meters # duration_seconds @@ -51,6 +50,7 @@ export const getRoutes = gql` filters: $filters ) { ...CoreRoute + image_border_color waypoints { lat_degrees lng_degrees From a0c09a9ef72530f7096d17de51227c98df39ad57 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 09:03:30 -0400 Subject: [PATCH 22/54] styling: center align tags too --- src/components/popups/Route.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index ede226610..1d7ad534b 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -198,6 +198,7 @@ export default function RoutePopup({ end, ...props }) { flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', + textAlign: 'center', }} > {route.tags.map((tag) => ( From 178f1d2fc9ec17bdf710726ae3efa22b68ac235b Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 09:41:18 -0400 Subject: [PATCH 23/54] fix: missing perm image/translation --- locales/en.json | 5 ++-- public/images/perms/routes.png | Bin 0 -> 75623 bytes src/components/popups/Route.jsx | 40 +++++++++++++++++--------------- 3 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 public/images/perms/routes.png diff --git a/locales/en.json b/locales/en.json index f7294595b..7174aa210 100644 --- a/locales/en.json +++ b/locales/en.json @@ -631,5 +631,6 @@ "version": "Verison", "route_tags": "Route Tags", "routes": "Routes", - "route_type": "Route Type" -} + "route_type": "Route Type", + "routes_subtitle": "View in game routes and relevant information about them on the map" +} \ No newline at end of file diff --git a/public/images/perms/routes.png b/public/images/perms/routes.png new file mode 100644 index 0000000000000000000000000000000000000000..4a347d40129494dc2bcd1c3bbe96a470f77ecab3 GIT binary patch literal 75623 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 z

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 literal 0 HcmV?d00001 diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 1d7ad534b..d68d9fd35 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -171,26 +171,28 @@ export default function RoutePopup({ end, ...props }) { - - - - {t('route_tags')} - + {!!route.tags.length && ( + + + + {t('route_tags')} + + + + + useStore.setState((prev) => ({ + // @ts-ignore + popups: { ...prev.popups, tags: !prev.popups.tags }, + })) + } + > + + + - - - useStore.setState((prev) => ({ - // @ts-ignore - popups: { ...prev.popups, tags: !prev.popups.tags }, - })) - } - > - - - - + )} Date: Fri, 28 Jul 2023 11:53:21 -0400 Subject: [PATCH 24/54] Update locales/en.json Co-authored-by: Clayton Burlison --- locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/en.json b/locales/en.json index 7174aa210..8bb465ec5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -628,7 +628,7 @@ "mutation_auth_error": "Your request was unsuccessful due to not being logged in", "submitted_by": "Submitted By", "reversible": "Reversible", - "version": "Verison", + "version": "Version", "route_tags": "Route Tags", "routes": "Routes", "route_type": "Route Type", From 7cad59e2d4bce86f1c7a24f5fe92aba9d1167c47 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 12:43:05 -0400 Subject: [PATCH 25/54] fix: add helpers for route/routes --- server/src/services/logger.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/services/logger.js b/server/src/services/logger.js index d4d032074..90c4f5c9b 100644 --- a/server/src/services/logger.js +++ b/server/src/services/logger.js @@ -50,6 +50,8 @@ const HELPERS = /** @type {const} */ ({ devices: chalk.hex('#ff9800')('[DEVICE]'), nests: chalk.hex('#ff5724')('[NESTS]'), portals: chalk.hex('#795548')('[PORTALS]'), + route: chalk.hex('#607d8b')('[ROUTE]'), + routes: chalk.hex('#9e9e9e')('[ROUTES]'), custom: (text = '', color = '#64b5f6') => chalk.hex(color)(`[${text.toUpperCase()}]`), From 4072d8ed615092ef45d5a32d5e361b9ce8ed7940 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 12:43:32 -0400 Subject: [PATCH 26/54] fix: update timer when expireTime changes --- src/components/popups/common/Timer.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/popups/common/Timer.jsx b/src/components/popups/common/Timer.jsx index abd45c7d8..7dbc6ff42 100644 --- a/src/components/popups/common/Timer.jsx +++ b/src/components/popups/common/Timer.jsx @@ -16,9 +16,15 @@ export default function TimeSince({ expireTime, until = false }) { return () => clearTimeout(timer) }) + useEffect(() => { + setTimerEnd(Utility.getTimeUntil(endTime, until)) + }, [expireTime]) + return ( - {timerEnd.str.replace('days', t('days')).replace('day', t('day'))} + {expireTime + ? timerEnd.str.replace('days', t('days')).replace('day', t('day')) + : t('never')} ) } From b64a562ae5bc09bfcc872fab810ff9c3115ab120 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 13:11:28 -0400 Subject: [PATCH 27/54] styling: restyle popup a bit --- locales/en.json | 3 +- package.json | 2 +- src/components/popups/Route.jsx | 175 ++++++++++++-------- src/components/popups/common/Navigation.jsx | 4 +- src/components/popups/common/Timer.jsx | 20 ++- 5 files changed, 122 insertions(+), 82 deletions(-) diff --git a/locales/en.json b/locales/en.json index 8bb465ec5..fc2503093 100644 --- a/locales/en.json +++ b/locales/en.json @@ -632,5 +632,6 @@ "route_tags": "Route Tags", "routes": "Routes", "route_type": "Route Type", - "routes_subtitle": "View in game routes and relevant information about them on the map" + "routes_subtitle": "View in game routes and relevant information about them on the map", + "description": "Description" } \ No newline at end of file diff --git a/package.json b/package.json index 26134ff6d..e024aad60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.22.0", + "version": "1.22.1", "description": "React based frontend map.", "main": "ReactMap.js", "author": "TurtIeSocks <58572875+TurtIeSocks@users.noreply.github.com>", diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index d68d9fd35..c1cbe9d05 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -2,11 +2,10 @@ /* eslint-disable react/destructuring-assignment */ import * as React from 'react' import { Popup } from 'react-leaflet' -import { useQuery } from '@apollo/client' +import { useLazyQuery } from '@apollo/client' import { useTranslation } from 'react-i18next' import Grid2 from '@mui/material/Unstable_Grid2' import Avatar from '@mui/material/Avatar' -import Typography from '@mui/material/Typography' import CheckIcon from '@mui/icons-material/Check' import CloseIcon from '@mui/icons-material/Close' import ExpandMore from '@mui/icons-material/ExpandMore' @@ -60,13 +59,70 @@ function ListItemWrapper({ ) : ( )} ) } +/** + * @param {{ + * disabled?: boolean + * children: React.ReactNode + * expandKey: string + * primary: string + * }} props + * @returns + */ +function ExpandableWrapper({ disabled = false, children, expandKey, primary }) { + // @ts-ignore + const expanded = useStore((s) => s.popups[expandKey]) + return ( + <> + + + useStore.setState((prev) => ({ + popups: { + // @ts-ignore + ...prev.popups, + // @ts-ignore + [expandKey]: !prev.popups[expandKey], + }, + })) + } + > + + + + + + {children} + + + + ) +} + /** * * @param {import('../../../server/src/types').Route & { end?: boolean }} props @@ -74,10 +130,8 @@ function ListItemWrapper({ */ export default function RoutePopup({ end, ...props }) { const [route, setRoute] = React.useState({ ...props, tags: [] }) - // @ts-ignore - const expanded = useStore((s) => !!s.popups.tags) - const { data } = useQuery(Query.routes('getOne'), { + const [getRoute, { data, called }] = useLazyQuery(Query.routes('getOne'), { variables: { id: props.id }, }) const { t } = useTranslation() @@ -97,21 +151,28 @@ export default function RoutePopup({ end, ...props }) { route.image === (end ? route.end_image : route.start_image) return ( - + { + if (ref && ref.isOpen() && !called) { + getRoute() + } + }} + > - {route.name} + {route.name} )} - - - {route.description?.length > 75 - ? `${route.description.slice(0, 75).trim()}...` - : route.description} - - - - + {`${route.distance_meters || 0}m`} @@ -153,70 +206,48 @@ export default function RoutePopup({ end, ...props }) { {route.reversible ? ( - + ) : ( - + )} - {t(`route_type_${route.type}`)} + {t(`route_type_${route.type || 0}`)} - {route.version} - - - - {t('last_updated')}: - - - - + + {route.version || 0} + + + + + + {route.tags.map((tag) => ( + + ))} + + + {route.description} + - {!!route.tags.length && ( - - - - {t('route_tags')} - - - - - useStore.setState((prev) => ({ - // @ts-ignore - popups: { ...prev.popups, tags: !prev.popups.tags }, - })) - } - > - - - - - )} - - {route.tags.map((tag) => ( - - ))} - diff --git a/src/components/popups/common/Navigation.jsx b/src/components/popups/common/Navigation.jsx index dae47437e..3e4a0e0ee 100644 --- a/src/components/popups/common/Navigation.jsx +++ b/src/components/popups/common/Navigation.jsx @@ -4,7 +4,7 @@ import { IconButton } from '@mui/material' import { useStore, useStatic } from '@hooks/useStore' -export default function Navigation({ lat, lon }) { +export default function Navigation({ lat, lon, size = 'large' }) { const { navigation } = useStore((state) => state.settings) const { navigation: { @@ -17,7 +17,7 @@ export default function Navigation({ lat, lon }) { href={url.replace('{x}', lat).replace('{y}', lon)} target="_blank" rel="noreferrer" - size="large" + size={size} style={{ color: 'inherit' }} > diff --git a/src/components/popups/common/Timer.jsx b/src/components/popups/common/Timer.jsx index 7dbc6ff42..a7035cbf2 100644 --- a/src/components/popups/common/Timer.jsx +++ b/src/components/popups/common/Timer.jsx @@ -1,27 +1,35 @@ -import React, { useState, useEffect } from 'react' +// @ts-check +import * as React from 'react' import { Typography } from '@mui/material' import { useTranslation } from 'react-i18next' import Utility from '@services/Utility' -export default function TimeSince({ expireTime, until = false }) { +/** + * + * @param {{ expireTime?: number, until?: boolean } & import('@mui/material').TypographyProps} props + * @returns + */ +export default function TimeSince({ expireTime, until = false, ...props }) { const { t } = useTranslation() const endTime = new Date(expireTime * 1000) - const [timerEnd, setTimerEnd] = useState(Utility.getTimeUntil(endTime, until)) + const [timerEnd, setTimerEnd] = React.useState( + Utility.getTimeUntil(endTime, until), + ) - useEffect(() => { + React.useEffect(() => { const timer = setTimeout(() => { setTimerEnd(Utility.getTimeUntil(endTime, until)) }, 1000) return () => clearTimeout(timer) }) - useEffect(() => { + React.useEffect(() => { setTimerEnd(Utility.getTimeUntil(endTime, until)) }, [expireTime]) return ( - + {expireTime ? timerEnd.str.replace('days', t('days')).replace('day', t('day')) : t('never')} From d03931c11477d89874b9e419a987aa14c0358ad8 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 13:13:16 -0400 Subject: [PATCH 28/54] fix: move popup anchor up a smidge --- src/components/markers/route.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/markers/route.js b/src/components/markers/route.js index bda901900..ae34eeaef 100644 --- a/src/components/markers/route.js +++ b/src/components/markers/route.js @@ -10,6 +10,7 @@ export default function getRouteMarker(iconUrl, position) { return new Icon({ iconUrl, iconSize: [32, 32], + popupAnchor: [0, -12], className: `circle-route-${position}`, }) } From 08ec96c89a5998234ff7202c53b3ec63fa95f3a5 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 13:22:53 -0400 Subject: [PATCH 29/54] styling: minor popup tip styling --- src/assets/mui/theme.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/assets/mui/theme.js b/src/assets/mui/theme.js index a4b072c2c..357d1ab65 100644 --- a/src/assets/mui/theme.js +++ b/src/assets/mui/theme.js @@ -70,6 +70,7 @@ export default function customTheme( }, '.leaflet-popup-tip-container .leaflet-popup-tip': { backgroundColor: t.palette.background.paper, + border: `${t.palette.divider} solid 7px`, }, '.leaflet-popup-content-wrapper': { backgroundColor: t.palette.background.paper, From 20d815f258d26a8afc2106d00a9aa075dbd463d7 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 15:30:58 -0400 Subject: [PATCH 30/54] styling: add duration and hide some info --- locales/en.json | 4 +- server/src/models/Route.js | 10 +++++ src/components/popups/Route.jsx | 76 +++++++++++++++++++-------------- src/services/queries/route.js | 4 +- 4 files changed, 60 insertions(+), 34 deletions(-) diff --git a/locales/en.json b/locales/en.json index fc2503093..c0da5f8c0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -633,5 +633,7 @@ "routes": "Routes", "route_type": "Route Type", "routes_subtitle": "View in game routes and relevant information about them on the map", - "description": "Description" + "description": "Description", + "additional_info": "Additional Info", + "duration": "Duration" } \ No newline at end of file diff --git a/server/src/models/Route.js b/server/src/models/Route.js index 81a9252f6..06a88e6df 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -9,6 +9,7 @@ const GET_ALL_SELECT = /** @type {const} */ ([ 'end_lon', 'waypoints', 'image_border_color', + 'reversible', ]) class Route extends Model { @@ -70,6 +71,15 @@ class Route extends Model { } else if (result.tags === null) { result.tags = [] } + if (typeof result.image === 'string') { + result.image = result.image.replace('http://', 'https://') + } + if (typeof result.start_image === 'string') { + result.start_image = result.start_image.replace('http://', 'https://') + } + if (typeof result.end_image === 'string') { + result.end_image = result.end_image.replace('http://', 'https://') + } return result } } diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index c1cbe9d05..5262e172b 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -15,8 +15,10 @@ import IconButton from '@mui/material/IconButton' import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' import ListItemText from '@mui/material/ListItemText' +import Box from '@mui/material/Box' import Query from '@services/Query' +import formatInterval from '@services/functions/formatInterval' import { useStore } from '@hooks/useStore' import Title from './common/Title' @@ -27,19 +29,14 @@ const IMAGE_SIZE = 80 /** * - * @param {{ - * primary: string - * primaryTypographyProps?: import('@mui/material/Typography').TypographyProps - * sx?: import('@mui/material').SxProps - * children?: React.ReactNode - * }} props + * @param {Exclude & { primary: string }} props * @returns */ function ListItemWrapper({ primary, primaryTypographyProps, - sx, children = null, + ...props }) { const { t } = useTranslation() @@ -52,7 +49,7 @@ function ListItemWrapper({ ...primaryTypographyProps, }} style={{ margin: 0 }} - sx={sx} + {...props} /> {typeof children === 'object' ? ( children @@ -73,16 +70,16 @@ function ListItemWrapper({ /** * @param {{ - * disabled?: boolean - * children: React.ReactNode - * expandKey: string - * primary: string + * disabled?: boolean + * children: React.ReactNode + * expandKey: string + * primary: string * }} props * @returns */ function ExpandableWrapper({ disabled = false, children, expandKey, primary }) { // @ts-ignore - const expanded = useStore((s) => s.popups[expandKey]) + const expanded = useStore((s) => !!s.popups[expandKey]) return ( <> @@ -114,9 +111,11 @@ function ExpandableWrapper({ disabled = false, children, expandKey, primary }) { justifyContent: 'center', alignItems: 'center', textAlign: 'center', + width: '90%', + mx: 'auto', }} > - {children} + {children} @@ -130,6 +129,8 @@ function ExpandableWrapper({ disabled = false, children, expandKey, primary }) { */ export default function RoutePopup({ end, ...props }) { const [route, setRoute] = React.useState({ ...props, tags: [] }) + // @ts-ignore + const locale = useStore((s) => s.settings.localeSelection) const [getRoute, { data, called }] = useLazyQuery(Query.routes('getOne'), { variables: { id: props.id }, @@ -147,6 +148,12 @@ export default function RoutePopup({ end, ...props }) { } }, [data]) + const numFormatter = new Intl.NumberFormat(locale, { + unitDisplay: 'short', + unit: 'meter', + style: 'unit', + }) + const imagesAreEqual = route.image === (end ? route.end_image : route.start_image) @@ -199,27 +206,14 @@ export default function RoutePopup({ end, ...props }) { )} - {`${route.distance_meters || 0}m`} + {`${numFormatter.format(route.distance_meters || 0)}`} + + + {`${formatInterval((route.duration_seconds || 0) * 1000).str}`} {route.waypoints.length} - - {route.reversible ? ( - - ) : ( - - )} - - - {t(`route_type_${route.type || 0}`)} - - - {route.version || 0} - - - - {route.description} + + + + {route.reversible ? ( + + ) : ( + + )} + + + {t(`route_type_${route.type || 0}`)} + + + {route.version || 0} + + + + + + Date: Fri, 28 Jul 2023 15:31:11 -0400 Subject: [PATCH 31/54] feat: add some event listeners to the route line --- src/components/tiles/Route.jsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 0d9e901c2..ff23ef7b5 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -1,6 +1,7 @@ // @ts-check import * as React from 'react' import { Marker, Polyline } from 'react-leaflet' +import { darken } from '@mui/material' import ErrorBoundary from '@components/ErrorBoundary' import RoutePopup from '@components/popups/Route' @@ -52,6 +53,33 @@ const RouteTile = ({ item, Icons }) => { ))} { + if (target) { + target.setStyle({ + color: target.options.clicked + ? `#${item.image_border_color}` + : darken(`#${item.image_border_color}`, 0.2), + }) + target.options.clicked = !target.options.clicked + } + }, + mouseover: ({ target }) => { + if (target?.options.clicked !== true) { + target.setStyle({ + color: darken(`#${item.image_border_color}`, 0.2), + }) + } + }, + mouseout: ({ target }) => { + if (target?.options.clicked !== true) { + target.setStyle({ + color: `#${item.image_border_color}`, + }) + } + }, + }} + dashArray={item.reversible ? undefined : '5, 5'} positions={waypoints.map((waypoint) => [ waypoint.lat_degrees, waypoint.lng_degrees, From f0f7628cecea201df59ff3a93ffc7b7fa1d4326d Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 15:54:46 -0400 Subject: [PATCH 32/54] fix: menu order when started with `yarn start` --- server/src/services/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/services/config.js b/server/src/services/config.js index e84a80151..b31778e30 100644 --- a/server/src/services/config.js +++ b/server/src/services/config.js @@ -11,13 +11,13 @@ const allowedMenuItems = [ 'nests', 'pokestops', 'pokemon', + 'routes', 'wayfarer', 's2cells', 'scanAreas', 'weather', 'admin', 'settings', - 'routes', ] try { From 7dd2d54963414f681ee7d8b5c00609b5c636bf26 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 16:44:59 -0400 Subject: [PATCH 33/54] fix: better click behavior --- src/components/tiles/Route.jsx | 35 +++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index ff23ef7b5..0dae4caf3 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -1,6 +1,6 @@ // @ts-check import * as React from 'react' -import { Marker, Polyline } from 'react-leaflet' +import { Marker, Polyline, useMapEvent } from 'react-leaflet' import { darken } from '@mui/material' import ErrorBoundary from '@components/ErrorBoundary' @@ -19,6 +19,12 @@ const POSITIONS = /** @type {const} */ (['start', 'end']) * @returns */ const RouteTile = ({ item, Icons }) => { + const [clicked, setClicked] = React.useState(false) + + useMapEvent('click', ({ originalEvent }) => { + if (!originalEvent.defaultPrevented) setClicked(false) + }) + const waypoints = React.useMemo( () => [ { @@ -43,6 +49,10 @@ const RouteTile = ({ item, Icons }) => { key={position} position={[item[`${position}_lat`], item[`${position}_lon`]]} icon={routeMarker(Icons.getMisc(`route-${position}`), position)} + eventHandlers={{ + popupclose: () => setClicked(false), + popupopen: () => setClicked(true), + }} > { { - if (target) { - target.setStyle({ - color: target.options.clicked - ? `#${item.image_border_color}` - : darken(`#${item.image_border_color}`, 0.2), - }) - target.options.clicked = !target.options.clicked - } + click: ({ originalEvent }) => { + originalEvent.preventDefault() + setClicked((prev) => !prev) }, mouseover: ({ target }) => { - if (target?.options.clicked !== true) { + if (target && !clicked) { target.setStyle({ - color: darken(`#${item.image_border_color}`, 0.2), + color: darken(`#${item.image_border_color}`, 0.3), }) } }, mouseout: ({ target }) => { - if (target?.options.clicked !== true) { + if (target && !clicked) { target.setStyle({ color: `#${item.image_border_color}`, }) @@ -85,8 +89,9 @@ const RouteTile = ({ item, Icons }) => { waypoint.lng_degrees, ])} pathOptions={{ - color: `#${item.image_border_color}`, - fillColor: `#${item.image_border_color}`, + color: clicked + ? darken(`#${item.image_border_color}`, 0.3) + : `#${item.image_border_color}`, }} /> From e1735b0e068462a36e15d71c8e12e597064a557c Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 18:14:41 -0400 Subject: [PATCH 34/54] fix: optimize click/hover behavior --- src/components/tiles/Route.jsx | 42 +++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 0dae4caf3..48d475c95 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -21,9 +21,8 @@ const POSITIONS = /** @type {const} */ (['start', 'end']) const RouteTile = ({ item, Icons }) => { const [clicked, setClicked] = React.useState(false) - useMapEvent('click', ({ originalEvent }) => { - if (!originalEvent.defaultPrevented) setClicked(false) - }) + /** @type {React.MutableRefObject} */ + const lineRef = React.useRef() const waypoints = React.useMemo( () => [ @@ -42,6 +41,18 @@ const RouteTile = ({ item, Icons }) => { [item], ) + const [color, darkened] = React.useMemo( + () => [ + `#${item.image_border_color}`, + darken(`#${item.image_border_color}`, 0.3), + ], + [item.image_border_color], + ) + + useMapEvent('click', ({ originalEvent }) => { + if (!originalEvent.defaultPrevented) setClicked(false) + }) + return ( <> {POSITIONS.map((position) => ( @@ -50,8 +61,18 @@ const RouteTile = ({ item, Icons }) => { position={[item[`${position}_lat`], item[`${position}_lon`]]} icon={routeMarker(Icons.getMisc(`route-${position}`), position)} eventHandlers={{ - popupclose: () => setClicked(false), popupopen: () => setClicked(true), + popupclose: () => setClicked(false), + mouseover: () => { + if (lineRef.current) { + lineRef.current.setStyle({ color: darkened }) + } + }, + mouseout: () => { + if (lineRef.current && !clicked) { + lineRef.current.setStyle({ color }) + } + }, }} > { ))} { originalEvent.preventDefault() @@ -70,16 +92,12 @@ const RouteTile = ({ item, Icons }) => { }, mouseover: ({ target }) => { if (target && !clicked) { - target.setStyle({ - color: darken(`#${item.image_border_color}`, 0.3), - }) + target.setStyle({ color: darkened }) } }, mouseout: ({ target }) => { if (target && !clicked) { - target.setStyle({ - color: `#${item.image_border_color}`, - }) + target.setStyle({ color }) } }, }} @@ -89,9 +107,7 @@ const RouteTile = ({ item, Icons }) => { waypoint.lng_degrees, ])} pathOptions={{ - color: clicked - ? darken(`#${item.image_border_color}`, 0.3) - : `#${item.image_border_color}`, + color: clicked ? darkened : color, }} /> From 02bc11a48b022d2ce40acff148cc53b7a74e4a1c Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 19:46:51 -0400 Subject: [PATCH 35/54] fix: various --- server/src/types.d.ts | 2 +- src/components/Container.jsx | 1 + src/components/tiles/Pokemon.jsx | 38 +++++++---------------------- src/components/tiles/Route.jsx | 17 ++++++++++--- src/services/functions/offset.js | 41 ++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 34 deletions(-) create mode 100644 src/services/functions/offset.js diff --git a/server/src/types.d.ts b/server/src/types.d.ts index 9a91c865a..8f654f9c7 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -245,7 +245,7 @@ export interface Route { description: string distance_meters: number duration_seconds: number - start_ford_id: string + start_fort_id: string start_lat: number start_image: string end_fort_id: string diff --git a/src/components/Container.jsx b/src/components/Container.jsx index b4306fbba..b8e498ea0 100644 --- a/src/components/Container.jsx +++ b/src/components/Container.jsx @@ -34,6 +34,7 @@ export default function Container({ serverSettings, params, location, zoom }) { {serverSettings.user && serverSettings.user.perms.map && ( )} + diff --git a/src/components/tiles/Pokemon.jsx b/src/components/tiles/Pokemon.jsx index 803ee19c4..79a3b95bc 100644 --- a/src/components/tiles/Pokemon.jsx +++ b/src/components/tiles/Pokemon.jsx @@ -4,12 +4,13 @@ import { Marker, Popup, Circle } from 'react-leaflet' import useMarkerTimer from '@hooks/useMarkerTimer' import useForcePopup from '@hooks/useForcePopup' +import { getOffset } from '@services/functions/offset' import PopupContent from '../popups/Pokemon' import { basicMarker, fancyMarker } from '../markers/pokemon' import ToolTipWrapper from './Timer' -const operator = { +const OPERATOR = { '=': (a, b) => a === b, '<': (a, b) => a < b, '<=': (a, b) => a <= b, @@ -17,33 +18,6 @@ const operator = { '>=': (a, b) => a >= b, } -const cyrb53 = (str, seed = 0) => { - let h1 = 0xdeadbeef ^ seed - let h2 = 0x41c6ce57 ^ seed - for (let i = 0, ch; i < str.length; i += 1) { - ch = str.charCodeAt(i) - h1 = Math.imul(h1 ^ ch, 2654435761) - h2 = Math.imul(h2 ^ ch, 1597334677) - } - h1 = - Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ - Math.imul(h2 ^ (h2 >>> 13), 3266489909) - h2 = - Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ - Math.imul(h1 ^ (h1 >>> 13), 3266489909) - return [h1, h2] -} - -const getOffset = (coords, type, seed) => { - const offOffset = type === 'nearby_cell' ? 0.0002 : 0.00015 - const rand = cyrb53(seed) - return [0, 1].map((i) => { - let offset = rand[i] * (0.0002 / 4294967296) - 0.0001 - offset += offset >= 0 ? -offOffset : offOffset - return coords[i] + offset - }) -} - const getGlowStatus = (item, userSettings, staticUserSettings) => { let glowCount = 0 let glowValue @@ -52,7 +26,7 @@ const getGlowStatus = (item, userSettings, staticUserSettings) => { const statKey = ruleValue.perm === 'iv' ? 'iv' : 'bestPvp' if (ruleValue.op) { if ( - operator[ruleValue.op](item[statKey], ruleValue.num) && + OPERATOR[ruleValue.op](item[statKey], ruleValue.num) && item[statKey] !== null ) { glowCount += 1 @@ -114,7 +88,11 @@ const PokemonTile = ({ const finalLocation = useMemo( () => item.seen_type?.startsWith('nearby') || item.seen_type?.includes('lure') - ? getOffset([item.lat, item.lon], item.seen_type, item.id) + ? getOffset( + [item.lat, item.lon], + item.seen_type === 'nearby_cell' ? 0.0002 : 0.00015, + item.id, + ) : [item.lat, item.lon], [item.seen_type], ) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 48d475c95..ada441c9d 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -1,6 +1,6 @@ // @ts-check import * as React from 'react' -import { Marker, Polyline, useMapEvent } from 'react-leaflet' +import { Marker, Polyline, useMapEvents } from 'react-leaflet' import { darken } from '@mui/material' import ErrorBoundary from '@components/ErrorBoundary' @@ -15,6 +15,7 @@ const POSITIONS = /** @type {const} */ (['start', 'end']) * @param {{ * item: import('../../../server/src/types').Route * Icons: InstanceType + * map: import("leaflet").Map * }} props * @returns */ @@ -49,8 +50,17 @@ const RouteTile = ({ item, Icons }) => { [item.image_border_color], ) - useMapEvent('click', ({ originalEvent }) => { - if (!originalEvent.defaultPrevented) setClicked(false) + useMapEvents({ + click: ({ originalEvent }) => { + if (!originalEvent.defaultPrevented) setClicked(false) + }, + /** @param {{ target: import('leaflet').Map }} args */ + zoom: ({ target }) => { + const pane = target.getPane('routes') + if (pane) { + pane.hidden = target.getZoom() < 13 + } + }, }) return ( @@ -85,6 +95,7 @@ const RouteTile = ({ item, Icons }) => { { originalEvent.preventDefault() diff --git a/src/services/functions/offset.js b/src/services/functions/offset.js new file mode 100644 index 000000000..5f06481fa --- /dev/null +++ b/src/services/functions/offset.js @@ -0,0 +1,41 @@ +/* eslint-disable no-bitwise */ + +/** + * cyrb53 hash function + * @param {string} str + * @param {number} [seed] + * @returns {[number, number]} + */ +export const cyrb53 = (str, seed = 0) => { + let h1 = 0xdeadbeef ^ seed + let h2 = 0x41c6ce57 ^ seed + for (let i = 0, ch; i < str.length; i += 1) { + ch = str.charCodeAt(i) + h1 = Math.imul(h1 ^ ch, 2654435761) + h2 = Math.imul(h2 ^ ch, 1597334677) + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909) + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909) + return [h1, h2] +} + +/** + * Get offset coordinates + * @param {[number, number]} coords + * @param {number} amount + * @param {string} id + * @param {number} [seed] + * @returns {[number, number]} + */ +export const getOffset = (coords, amount, id, seed = 0) => { + const rand = cyrb53(id, seed) + return [0, 1].map((i) => { + let offset = rand[i] * (0.0002 / 4294967296) - 0.0001 + offset += offset >= 0 ? -amount : amount + return coords[i] + offset + }) +} From 7e14bc2894bae3964975831b2e858b53de8f1830 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 21:04:47 -0400 Subject: [PATCH 36/54] styling: set opacity for extra contrast --- src/components/tiles/Route.jsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index ada441c9d..1d5d920fe 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -75,12 +75,12 @@ const RouteTile = ({ item, Icons }) => { popupclose: () => setClicked(false), mouseover: () => { if (lineRef.current) { - lineRef.current.setStyle({ color: darkened }) + lineRef.current.setStyle({ color: darkened, opacity: 1 }) } }, mouseout: () => { if (lineRef.current && !clicked) { - lineRef.current.setStyle({ color }) + lineRef.current.setStyle({ color, opacity: 0.5 }) } }, }} @@ -103,12 +103,12 @@ const RouteTile = ({ item, Icons }) => { }, mouseover: ({ target }) => { if (target && !clicked) { - target.setStyle({ color: darkened }) + target.setStyle({ color: darkened, opacity: 1 }) } }, mouseout: ({ target }) => { if (target && !clicked) { - target.setStyle({ color }) + target.setStyle({ color, opacity: 0.5 }) } }, }} @@ -119,6 +119,7 @@ const RouteTile = ({ item, Icons }) => { ])} pathOptions={{ color: clicked ? darkened : color, + opacity: clicked ? 1 : 0.5, }} /> From 4b6b9fbb9e6fa9a203374dd4f109123c0ed420a1 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 22:03:07 -0400 Subject: [PATCH 37/54] styling: marker opacity and offsets --- src/assets/css/main.css | 10 ++++++---- src/components/markers/route.js | 3 ++- src/components/tiles/Route.jsx | 20 ++++++++++++++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/assets/css/main.css b/src/assets/css/main.css index 9ac97311d..cf77a0121 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -76,14 +76,16 @@ body { -moz-box-shadow: 0 0 10px rgba(0, 0, 0, 1); } -.circle-route-start { - background-color: #00a3ee; +.circle-route { border-radius: 50%; } -.circle-route-end { +.start { + background-color: #00a3ee; +} + +.end { background-color: #ff4b4d; - border-radius: 50%; } .invasion-exists { diff --git a/src/components/markers/route.js b/src/components/markers/route.js index ae34eeaef..4fe414acd 100644 --- a/src/components/markers/route.js +++ b/src/components/markers/route.js @@ -9,8 +9,9 @@ import { Icon } from 'leaflet' export default function getRouteMarker(iconUrl, position) { return new Icon({ iconUrl, + iconAnchor: [position === 'start' ? 12 : 20, 16], iconSize: [32, 32], popupAnchor: [0, -12], - className: `circle-route-${position}`, + className: `circle-route ${position}`, }) } diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index 1d5d920fe..c37910c36 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ // @ts-check import * as React from 'react' import { Marker, Polyline, useMapEvents } from 'react-leaflet' @@ -10,6 +11,8 @@ import routeMarker from '../markers/route' const POSITIONS = /** @type {const} */ (['start', 'end']) +const OPACITY = 0.66 + /** * * @param {{ @@ -21,6 +24,7 @@ const POSITIONS = /** @type {const} */ (['start', 'end']) */ const RouteTile = ({ item, Icons }) => { const [clicked, setClicked] = React.useState(false) + const [hover, setHover] = React.useState('') /** @type {React.MutableRefObject} */ const lineRef = React.useRef() @@ -52,7 +56,10 @@ const RouteTile = ({ item, Icons }) => { useMapEvents({ click: ({ originalEvent }) => { - if (!originalEvent.defaultPrevented) setClicked(false) + if (!originalEvent.defaultPrevented) { + setClicked(false) + setHover('') + } }, /** @param {{ target: import('leaflet').Map }} args */ zoom: ({ target }) => { @@ -63,11 +70,14 @@ const RouteTile = ({ item, Icons }) => { }, }) + console.log({ hover, clicked }) return ( <> {POSITIONS.map((position) => ( { if (lineRef.current) { lineRef.current.setStyle({ color: darkened, opacity: 1 }) } + setHover(position) }, mouseout: () => { if (lineRef.current && !clicked) { - lineRef.current.setStyle({ color, opacity: 0.5 }) + lineRef.current.setStyle({ color, opacity: OPACITY }) } + setHover('') }, }} > @@ -108,7 +120,7 @@ const RouteTile = ({ item, Icons }) => { }, mouseout: ({ target }) => { if (target && !clicked) { - target.setStyle({ color, opacity: 0.5 }) + target.setStyle({ color, opacity: OPACITY }) } }, }} @@ -119,7 +131,7 @@ const RouteTile = ({ item, Icons }) => { ])} pathOptions={{ color: clicked ? darkened : color, - opacity: clicked ? 1 : 0.5, + opacity: clicked || hover ? 1 : OPACITY, }} /> From 1a36d3f5fddb26e0955a8a66430dedae01181a6d Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 28 Jul 2023 22:11:50 -0400 Subject: [PATCH 38/54] fix: remove log --- src/components/tiles/Route.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index c37910c36..d8692f812 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -70,7 +70,6 @@ const RouteTile = ({ item, Icons }) => { }, }) - console.log({ hover, clicked }) return ( <> {POSITIONS.map((position) => ( From 5aed6e4f3123735a98a2e80881f94a173becd55f Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Sat, 29 Jul 2023 11:27:43 -0400 Subject: [PATCH 39/54] fix: area restrictions --- server/src/configs/default.json | 3 +++ server/src/models/Route.js | 11 ++++++----- server/src/services/filters/builder/base.js | 2 +- server/src/services/functions/getAreaSql.js | 7 ------- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/server/src/configs/default.json b/server/src/configs/default.json index 8935a8ca7..1f361f14b 100644 --- a/server/src/configs/default.json +++ b/server/src/configs/default.json @@ -565,6 +565,9 @@ "portals": { "enabled": false }, + "routes": { + "enabled": true + }, "scanAreas": { "enabled": false }, diff --git a/server/src/models/Route.js b/server/src/models/Route.js index 06a88e6df..6e8bd9313 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -32,13 +32,14 @@ class Route extends Model { .select(GET_ALL_SELECT) .whereBetween('start_lat', [args.minLat, args.maxLat]) .andWhereBetween('start_lon', [args.minLon, args.maxLon]) - .union((qb) => - qb - .select(GET_ALL_SELECT) + .union((qb) => { + qb.select(GET_ALL_SELECT) .whereBetween('end_lat', [args.minLat, args.maxLat]) .andWhereBetween('end_lon', [args.minLon, args.maxLon]) - .from('route'), - ) + .from('route') + + getAreaSql(qb, areaRestrictions, onlyAreas, ctx.isMad, 'route') + }) if (!getAreaSql(query, areaRestrictions, onlyAreas, ctx.isMad, 'route')) { return [] diff --git a/server/src/services/filters/builder/base.js b/server/src/services/filters/builder/base.js index 8fa5819cf..f5df4b807 100644 --- a/server/src/services/filters/builder/base.js +++ b/server/src/services/filters/builder/base.js @@ -106,7 +106,7 @@ module.exports = function buildDefault(perms, available, dbModels) { : undefined, routes: perms.routes && dbModels.Route - ? { enabled: true, filter: {} } + ? { enabled: defaultFilters.routes.enabled, filter: {} } : undefined, portals: perms.portals && dbModels.Portal diff --git a/server/src/services/functions/getAreaSql.js b/server/src/services/functions/getAreaSql.js index 7d5c3b5df..d7562539d 100644 --- a/server/src/services/functions/getAreaSql.js +++ b/server/src/services/functions/getAreaSql.js @@ -47,13 +47,6 @@ module.exports = function getAreaRestrictionSql( config.areas.polygons[area], )}', 2, 0), POINT(${columns[1]}, ${columns[0]}))`, ) - if (category === 'route') { - restrictions.orWhereRaw( - `ST_CONTAINS(ST_GeomFromGeoJSON('${JSON.stringify( - config.areas.polygons[area], - )}', 2, 0), POINT(end_lon, end_lat))`, - ) - } } }) }) From 283919b6618645d05d9f164cf919a5098ee949f3 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Sat, 29 Jul 2023 12:54:54 -0400 Subject: [PATCH 40/54] feat: initial route distance filtering --- server/src/graphql/resolvers.js | 3 +- server/src/index.js | 1 + server/src/models/Route.js | 13 ++++++ server/src/routes/rootRouter.js | 5 ++- server/src/services/DbCheck.js | 21 ++++++++++ server/src/services/EventManager.js | 5 +++ server/src/services/Utility.js | 5 --- server/src/services/filters/Base.js | 4 +- server/src/services/filters/builder/base.js | 40 +++++++++++++------ .../src/services/filters/pokemon/Frontend.js | 24 +++++------ server/src/types.d.ts | 14 +++++++ 11 files changed, 100 insertions(+), 35 deletions(-) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index b042d4347..ecf699080 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -5,6 +5,7 @@ const { S2LatLng, S2RegionCoverer, S2LatLngRect } = require('nodes2ts') const config = require('../services/config') const Utility = require('../services/Utility') const Fetch = require('../services/Fetch') +const buildDefaultFilters = require('../services/filters/builder/base') /** * @typedef {(parent: unknown, args: object, context: import('../types').GqlContext) => unknown} Resolver @@ -28,7 +29,7 @@ const resolvers = { return { ...available, masterfile: { ...Event.masterfile, invasions: Event.invasions }, - filters: Utility.buildDefaultFilters(perms, available, Db.models), + filters: buildDefaultFilters(perms, available, Db), } }, backup: (_, args, { req, perms, Db }) => { diff --git a/server/src/index.js b/server/src/index.js index fda91188c..704107a49 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -383,6 +383,7 @@ connection.migrate.latest().then(async () => { await Db.getDbContext() await Promise.all([ Db.historicalRarity(), + Db.getFilterContext(), Event.setAvailable('gyms', 'Gym', Db), Event.setAvailable('pokestops', 'Pokestop', Db), Event.setAvailable('pokemon', 'Pokemon', Db), diff --git a/server/src/models/Route.js b/server/src/models/Route.js index 6e8bd9313..f7269e1b8 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -83,6 +83,19 @@ class Route extends Model { } return result } + + /** + * returns route context + * @returns {{ max_distance: number, max_duration: number }} + */ + static async getFilterContext() { + const result = await this.query() + .max('distance_meters AS max_distance') + .max('duration_seconds AS max_duration') + .first() + + return result + } } module.exports = Route diff --git a/server/src/routes/rootRouter.js b/server/src/routes/rootRouter.js index db319aeac..ea185b135 100644 --- a/server/src/routes/rootRouter.js +++ b/server/src/routes/rootRouter.js @@ -10,6 +10,7 @@ const Fetch = require('../services/Fetch') const { Event, Db } = require('../services/initialization') const { version } = require('../../../package.json') const { log, HELPERS } = require('../services/logger') +const buildDefaultFilters = require('../services/filters/builder/base') const rootRouter = express.Router() @@ -287,10 +288,10 @@ rootRouter.get('/api/settings', async (req, res, next) => { } } - serverSettings.defaultFilters = Utility.buildDefaultFilters( + serverSettings.defaultFilters = buildDefaultFilters( serverSettings.user.perms, serverSettings.available, - Db.models, + Db, ) // Backup in case there are Pokemon/Quests/Raids etc that are not in the masterfile diff --git a/server/src/services/DbCheck.js b/server/src/services/DbCheck.js index 9cf99f088..cedb92a64 100644 --- a/server/src/services/DbCheck.js +++ b/server/src/services/DbCheck.js @@ -38,6 +38,9 @@ module.exports = class DbCheck { this.endpoints = {} this.rarity = {} this.historical = {} + this.filterContext = { + Route: { maxDistance: 0, maxDuration: 0 }, + } this.reactMapDb = null this.connections = dbConfig.schemas .filter((s) => s.useFor.length) @@ -547,4 +550,22 @@ module.exports = class DbCheck { } return [] } + + /** + * Builds filter context for all models + */ + async getFilterContext() { + if (this.models.Route) { + const results = await Promise.all( + this.models.Route.map((source) => source.SubModel.getFilterContext()), + ) + this.filterContext.Route.maxDistance = Math.max( + ...results.map((result) => result.max_distance), + ) + this.filterContext.Route.maxDuration = Math.max( + ...results.map((result) => result.max_duration), + ) + log.info(HELPERS.db, 'Updating filter context for routes') + } + } } diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index 5e7ba82f9..c38498dcc 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -99,6 +99,11 @@ module.exports = class EventManager { this.chatLog({ description: 'Refreshed webhook settings' }) }, 1000 * 60 * 60 * (config.map.webhookCacheHrs || 1)) + setInterval(async () => { + await Db.getFilterContext() + this.chatLog({ description: 'Updated filter contexts' }) + }, 1000 * 60 * 30) + const newDate = new Date() config.authentication.strategies.forEach((strategy) => { if (strategy.enabled) { diff --git a/server/src/services/Utility.js b/server/src/services/Utility.js index 68ec53bf2..f7b74025c 100644 --- a/server/src/services/Utility.js +++ b/server/src/services/Utility.js @@ -1,7 +1,6 @@ const getPolyVector = require('./functions/getPolyVector') const getPlacementCells = require('./functions/getPlacementCells') const getTypeCells = require('./functions/getTypeCells') -const buildDefaultFilters = require('./filters/builder/base') const primaryUi = require('./ui/primary') const advMenus = require('./ui/advMenus') const clientOptions = require('./ui/clientOptions') @@ -26,10 +25,6 @@ module.exports = class Utility { return getTypeCells(...args) } - static buildDefaultFilters(...args) { - return buildDefaultFilters(...args) - } - static buildPrimaryUi(...args) { return primaryUi(...args) } diff --git a/server/src/services/filters/Base.js b/server/src/services/filters/Base.js index 1dff4599d..432813a95 100644 --- a/server/src/services/filters/Base.js +++ b/server/src/services/filters/Base.js @@ -1,8 +1,8 @@ module.exports = class BaseFilter { /** * - * @param {boolean} enabled - * @param {'sm' | 'md' | 'lg' | 'xl'} size + * @param {boolean} [enabled] + * @param {'sm' | 'md' | 'lg' | 'xl'} [size] */ constructor(enabled, size) { this.enabled = enabled || false diff --git a/server/src/services/filters/builder/base.js b/server/src/services/filters/builder/base.js index f5df4b807..02a3918a3 100644 --- a/server/src/services/filters/builder/base.js +++ b/server/src/services/filters/builder/base.js @@ -15,7 +15,14 @@ const custom = new PokemonFilter( ...Object.values(defaultFilters.pokemon.globalValues), ) -module.exports = function buildDefault(perms, available, dbModels) { +/** + * + * @param {import('../../../types').Permissions} perms + * @param {import('../../../types').Available} available + * @param {import('../../../types').DbCheckClass} database + * @returns + */ +function buildDefaultFilters(perms, available, database) { const stopReducer = perms.pokestops || perms.lures || perms.quests || perms.invasions const gymReducer = perms.gyms || perms.raids @@ -24,7 +31,7 @@ module.exports = function buildDefault(perms, available, dbModels) { return { gyms: - gymReducer && dbModels.Gym + gymReducer && database.models.Gym ? { enabled: defaultFilters.gyms.enabled, allGyms: perms.gyms ? defaultFilters.gyms.enabled : undefined, @@ -45,7 +52,7 @@ module.exports = function buildDefault(perms, available, dbModels) { } : undefined, nests: - perms.nests && dbModels.Nest + perms.nests && database.models.Nest ? { enabled: defaultFilters.nests.enabled, pokemon: defaultFilters.nests.pokemon, @@ -55,7 +62,7 @@ module.exports = function buildDefault(perms, available, dbModels) { } : undefined, pokestops: - stopReducer && dbModels.Pokestop + stopReducer && database.models.Pokestop ? { enabled: defaultFilters.pokestops.enabled, allPokestops: perms.pokestops @@ -85,7 +92,7 @@ module.exports = function buildDefault(perms, available, dbModels) { } : undefined, pokemon: - perms.pokemon && dbModels.Pokemon + perms.pokemon && database.models.Pokemon ? { enabled: defaultFilters.pokemon.enabled, legacy: @@ -105,11 +112,16 @@ module.exports = function buildDefault(perms, available, dbModels) { } : undefined, routes: - perms.routes && dbModels.Route - ? { enabled: defaultFilters.routes.enabled, filter: {} } + perms.routes && database.models.Route + ? { + enabled: defaultFilters.routes.enabled, + filter: { + distance: [0, database.filterContext.Route.maxDistance], + }, + } : undefined, portals: - perms.portals && dbModels.Portal + perms.portals && database.models.Portal ? { enabled: defaultFilters.portals.enabled, filter: { @@ -127,7 +139,7 @@ module.exports = function buildDefault(perms, available, dbModels) { } : undefined, submissionCells: - perms.submissionCells && dbModels.Pokestop && dbModels.Gym + perms.submissionCells && database.models.Pokestop && database.models.Gym ? { enabled: defaultFilters.submissionCells.enabled, rings: defaultFilters.submissionCells.rings, @@ -144,14 +156,14 @@ module.exports = function buildDefault(perms, available, dbModels) { } : undefined, weather: - perms.weather && dbModels.Weather + perms.weather && database.models.Weather ? { enabled: defaultFilters.weather.enabled, filter: { global: new BaseFilter() }, } : undefined, spawnpoints: - perms.spawnpoints && dbModels.Spawnpoint + perms.spawnpoints && database.models.Spawnpoint ? { enabled: defaultFilters.spawnpoints.enabled, filter: { @@ -162,14 +174,14 @@ module.exports = function buildDefault(perms, available, dbModels) { } : undefined, scanCells: - perms.scanCells && dbModels.ScanCell + perms.scanCells && database.models.ScanCell ? { enabled: defaultFilters.scanCells.enabled, filter: { global: new BaseFilter() }, } : undefined, devices: - perms.devices && dbModels.Device + perms.devices && database.models.Device ? { enabled: defaultFilters.devices.enabled, filter: { @@ -181,3 +193,5 @@ module.exports = function buildDefault(perms, available, dbModels) { : undefined, } } + +module.exports = buildDefaultFilters diff --git a/server/src/services/filters/pokemon/Frontend.js b/server/src/services/filters/pokemon/Frontend.js index 8cf2714fe..63c9feb48 100644 --- a/server/src/services/filters/pokemon/Frontend.js +++ b/server/src/services/filters/pokemon/Frontend.js @@ -7,18 +7,18 @@ const BaseFilter = require('../Base') module.exports = class PokemonFilter extends BaseFilter { /** - * @param {boolean} enabled - * @param {'sm' | 'md' | 'lg' | 'xl'} size - * @param {number[]} iv - * @param {number[]} level - * @param {number[]} atk - * @param {number[]} def - * @param {number[]} sta - * @param {number[]} pvp - * @param {number} gender - * @param {number[]} cp - * @param {boolean} xxs - * @param {boolean} xxl + * @param {boolean} [enabled] + * @param {'sm' | 'md' | 'lg' | 'xl'} [size] + * @param {number[]} [iv] + * @param {number[]} [level] + * @param {number[]} [atk] + * @param {number[]} [def] + * @param {number[]} [sta] + * @param {number[]} [pvp] + * @param {number} [gender] + * @param {number[]} [cp] + * @param {boolean} [xxs] + * @param {boolean} [xxl] */ constructor( enabled, diff --git a/server/src/types.d.ts b/server/src/types.d.ts index 8f654f9c7..afb2c30e5 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -10,13 +10,16 @@ import { Knex } from 'knex' import { Model } from 'objection' import { Request, Response } from 'express' import { Transaction } from '@sentry/node' + import DbCheck = require('./services/DbCheck') import EventManager = require('./services/EventManager') import Pokemon = require('./models/Pokemon') +import Gym = require('./models/Gym') import Badge = require('./models/Badge') import Backup = require('./models/Backup') import Nest = require('./models/Nest') import NestSubmission = require('./models/NestSubmission') +import Pokestop = require('./models/Pokestop') export interface DbContext { isMad: boolean @@ -109,6 +112,13 @@ export interface AvailablePokemon { count: number } +export interface Available { + pokemon: Awaited> + gyms: Awaited> + pokestops: Awaited> + nests: Awaited> +} + export interface PvpEntry { pokemon: number form: number @@ -160,6 +170,9 @@ export interface DbCheckClass { rarityPercents: RarityPercents distanceUnit: 'km' | 'mi' reactMapDb: null | number + filterContext: { + Route: { maxDistance: number; maxDuration: number } + } } export interface RarityPercents { @@ -218,6 +231,7 @@ export interface Permissions { donor: boolean gymBadges: boolean backups: boolean + routes: boolean scanner: string[] areaRestrictions: string[] webhooks: string[] From 31f1d2ac5b28d047771d92cc3564ecf7199faffa Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Sat, 29 Jul 2023 14:35:08 -0400 Subject: [PATCH 41/54] feat: finish distance slider filter --- server/src/models/Route.js | 6 +++- server/src/services/filters/builder/base.js | 6 +++- .../layout/dialogs/filters/SliderTile.jsx | 3 +- src/components/layout/drawer/Extras.jsx | 31 +++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/server/src/models/Route.js b/server/src/models/Route.js index f7269e1b8..691a4e76a 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -26,16 +26,20 @@ class Route extends Model { */ static async getAll(perms, args, ctx) { const { areaRestrictions } = perms - const { onlyAreas } = args.filters + const { onlyAreas, onlyDistance } = args.filters + + const distanceInMeters = (onlyDistance || [0.5, 100]).map((x) => x * 1000) const query = this.query() .select(GET_ALL_SELECT) .whereBetween('start_lat', [args.minLat, args.maxLat]) .andWhereBetween('start_lon', [args.minLon, args.maxLon]) + .andWhereBetween('distance_meters', distanceInMeters) .union((qb) => { qb.select(GET_ALL_SELECT) .whereBetween('end_lat', [args.minLat, args.maxLat]) .andWhereBetween('end_lon', [args.minLon, args.maxLon]) + .andWhereBetween('distance_meters', distanceInMeters) .from('route') getAreaSql(qb, areaRestrictions, onlyAreas, ctx.isMad, 'route') diff --git a/server/src/services/filters/builder/base.js b/server/src/services/filters/builder/base.js index 02a3918a3..5d9886f5c 100644 --- a/server/src/services/filters/builder/base.js +++ b/server/src/services/filters/builder/base.js @@ -115,8 +115,12 @@ function buildDefaultFilters(perms, available, database) { perms.routes && database.models.Route ? { enabled: defaultFilters.routes.enabled, + distance: [ + 0, + Math.ceil(database.filterContext.Route.maxDistance / 1000) + 1, + ], filter: { - distance: [0, database.filterContext.Route.maxDistance], + global: new BaseFilter(), }, } : undefined, diff --git a/src/components/layout/dialogs/filters/SliderTile.jsx b/src/components/layout/dialogs/filters/SliderTile.jsx index 1ab10533a..dee7537f8 100644 --- a/src/components/layout/dialogs/filters/SliderTile.jsx +++ b/src/components/layout/dialogs/filters/SliderTile.jsx @@ -67,6 +67,7 @@ export default function SliderTile({ : 'inherit' const translated = t(i18nKey || `slider_${name}`) + return ( ) } + if (category === 'routes' && subItem === 'enabled') { + return ( + + + + setFilters({ + ...filters, + [category]: { + ...filters[category], + distance: values, + }, + }) + } + filterValues={filters[category]} + /> + + + ) + } return null } From 8f7d73aedd4dfd5122247fbebd335ec3b668f49d Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Sat, 29 Jul 2023 14:35:22 -0400 Subject: [PATCH 42/54] version bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e024aad60..66ab46f4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.22.1", + "version": "1.22.2", "description": "React based frontend map.", "main": "ReactMap.js", "author": "TurtIeSocks <58572875+TurtIeSocks@users.noreply.github.com>", From 5f4d1184837662018598009f925c9e9908dc47ad Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Sat, 29 Jul 2023 14:38:50 -0400 Subject: [PATCH 43/54] fix: min val --- src/components/layout/drawer/Extras.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/drawer/Extras.jsx b/src/components/layout/drawer/Extras.jsx index d89cd1704..00c4942e7 100644 --- a/src/components/layout/drawer/Extras.jsx +++ b/src/components/layout/drawer/Extras.jsx @@ -305,8 +305,8 @@ export default function Extras({ category, subItem, data }) { filterSlide={{ color: 'secondary', disabled: false, + min: staticFilters.routes.distance[0] || 0, max: staticFilters.routes.distance[1] || 25, - min: 0.5, i18nKey: 'distance', step: 0.5, name: 'distance', From 5d8d709fbd94471d5a3d87e7840bec6d6e0baf26 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 30 Jul 2023 20:23:09 -0400 Subject: [PATCH 44/54] fix: lock vite deps --- package.json | 10 +++++----- vite.config.js | 4 +++- yarn.lock | 10 +++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 66ab46f4e..80f59a622 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,10 @@ "yarn": "^1.22.x" }, "devDependencies": { - "@sentry/vite-plugin": "^2.2.1", + "@sentry/vite-plugin": "2.2.1", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", - "@vitejs/plugin-react": "^4.0.0", + "@vitejs/plugin-react": "4.0.0", "eslint": "^8.44.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^8.8.0", @@ -50,9 +50,9 @@ "nodemon": "^2.0.22", "prettier": "^2.8.8", "rollup-plugin-delete": "^2.0.0", - "vite": "^4.3.9", - "vite-plugin-checker": "^0.6.0", - "vite-plugin-static-copy": "^0.16.0" + "vite": "4.3.9", + "vite-plugin-checker": "0.6.0", + "vite-plugin-static-copy": "0.16.0" }, "dependencies": { "@apollo/client": "^3.7.15", diff --git a/vite.config.js b/vite.config.js index adb88d11f..ca3c77531 100644 --- a/vite.config.js +++ b/vite.config.js @@ -62,7 +62,7 @@ const localePlugin = () => ({ }, }) -module.exports = defineConfig(async ({ mode }) => { +const config = defineConfig(async ({ mode }) => { const env = loadEnv(mode, resolve(process.cwd(), './'), '') const isRelease = process.argv.includes('-r') @@ -210,3 +210,5 @@ module.exports = defineConfig(async ({ mode }) => { }, } }) + +module.exports = config diff --git a/yarn.lock b/yarn.lock index 6b688b1cc..7d23da480 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1197,7 +1197,7 @@ "@sentry/types" "7.53.1" tslib "^1.9.3" -"@sentry/vite-plugin@^2.2.1": +"@sentry/vite-plugin@2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-2.2.1.tgz#6a5cc370514a60aafa7e4fd545df616389cab618" integrity sha512-7UeDnMRMis8o68penA8cIb2ph0je4MaJUsglFtk5zocyKr5HGCA7lh50cGAZW6/MIFEKiOZ+1mR6kYdjgACTZA== @@ -1553,7 +1553,7 @@ dependencies: "@types/node" "*" -"@vitejs/plugin-react@^4.0.0": +"@vitejs/plugin-react@4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.0.0.tgz#46d1c37c507447d10467be1c111595174555ef28" integrity sha512-HX0XzMjL3hhOYm+0s95pb0Z7F8O81G7joUHgfDd/9J/ZZf5k4xX6QAMFkKsHFxaHlf6X7GD7+XuaZ66ULiJuhQ== @@ -5691,7 +5691,7 @@ vary@^1, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -vite-plugin-checker@^0.6.0: +vite-plugin-checker@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/vite-plugin-checker/-/vite-plugin-checker-0.6.0.tgz#f8d09c5f6af427787c657249f7ac775d63cff7e8" integrity sha512-DWZ9Hv2TkpjviPxAelNUt4Q3IhSGrx7xrwdM64NI+Q4dt8PaMWJJh4qGNtSrfEuiuIzWWo00Ksvh5It4Y3L9xQ== @@ -5714,7 +5714,7 @@ vite-plugin-checker@^0.6.0: vscode-languageserver-textdocument "^1.0.1" vscode-uri "^3.0.2" -vite-plugin-static-copy@^0.16.0: +vite-plugin-static-copy@0.16.0: version "0.16.0" resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-0.16.0.tgz#2f65227037f17fc99c0782fd0b344e962935e69e" integrity sha512-dMVEg5Z2SwYRgQnHZaeokvSKB4p/TOTf65JU4sP3U6ccSBsukqdtDOjpmT+xzTFHAA8WJjcS31RMLjUdWQCBzw== @@ -5724,7 +5724,7 @@ vite-plugin-static-copy@^0.16.0: fs-extra "^11.1.0" picocolors "^1.0.0" -vite@^4.3.9: +vite@4.3.9: version "4.3.9" resolved "https://registry.yarnpkg.com/vite/-/vite-4.3.9.tgz#db896200c0b1aa13b37cdc35c9e99ee2fdd5f96d" integrity sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg== From dfee19ceddd33abc6c609927738ca2ce1d6a6dbd Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Sun, 30 Jul 2023 20:56:42 -0400 Subject: [PATCH 45/54] feat: elevation stats --- locales/en.json | 5 ++-- src/components/popups/Route.jsx | 41 ++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/locales/en.json b/locales/en.json index c0da5f8c0..84defc69d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -635,5 +635,6 @@ "routes_subtitle": "View in game routes and relevant information about them on the map", "description": "Description", "additional_info": "Additional Info", - "duration": "Duration" -} \ No newline at end of file + "duration": "Duration", + "elevation": "Elevation" +} diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 5262e172b..578c4e738 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -16,6 +16,9 @@ import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' import ListItemText from '@mui/material/ListItemText' import Box from '@mui/material/Box' +import ArrowDropDown from '@mui/icons-material/ArrowDropDown' +import ArrowDropUp from '@mui/icons-material/ArrowDropUp' +import Typography from '@mui/material/Typography' import Query from '@services/Query' import formatInterval from '@services/functions/formatInterval' @@ -148,6 +151,23 @@ export default function RoutePopup({ end, ...props }) { } }, [data]) + const elevation = React.useMemo(() => { + const sum = { down: 0, up: 0 } + for (let i = 0; i < route.waypoints.length - 1; i += 1) { + const diff = + route.waypoints[i + 1].elevation_in_meters - + route.waypoints[i].elevation_in_meters + if (diff > 0) { + sum.up += diff + } else { + sum.down += Math.abs(diff) + } + } + sum.down = Math.round(sum.down) + sum.up = Math.round(sum.up) + return sum + }, [!!route.waypoints]) + const numFormatter = new Intl.NumberFormat(locale, { unitDisplay: 'short', unit: 'meter', @@ -211,9 +231,24 @@ export default function RoutePopup({ end, ...props }) { {`${formatInterval((route.duration_seconds || 0) * 1000).str}`} - - {route.waypoints.length} - + + + + + + {numFormatter.format(elevation.up)} + + + + + + {numFormatter.format(elevation.down)} + + + Date: Sun, 30 Jul 2023 21:13:17 -0400 Subject: [PATCH 46/54] fix: logic --- src/components/popups/Route.jsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/popups/Route.jsx b/src/components/popups/Route.jsx index 578c4e738..43749a3a4 100644 --- a/src/components/popups/Route.jsx +++ b/src/components/popups/Route.jsx @@ -153,7 +153,7 @@ export default function RoutePopup({ end, ...props }) { const elevation = React.useMemo(() => { const sum = { down: 0, up: 0 } - for (let i = 0; i < route.waypoints.length - 1; i += 1) { + for (let i = 1; i < route.waypoints.length - 2; i += 1) { const diff = route.waypoints[i + 1].elevation_in_meters - route.waypoints[i].elevation_in_meters @@ -163,8 +163,6 @@ export default function RoutePopup({ end, ...props }) { sum.down += Math.abs(diff) } } - sum.down = Math.round(sum.down) - sum.up = Math.round(sum.up) return sum }, [!!route.waypoints]) @@ -172,6 +170,7 @@ export default function RoutePopup({ end, ...props }) { unitDisplay: 'short', unit: 'meter', style: 'unit', + maximumFractionDigits: 1, }) const imagesAreEqual = From 43e797d950d372169fa0b44e12347826a4cb9a32 Mon Sep 17 00:00:00 2001 From: turtlesocks-bot Date: Sat, 29 Jul 2023 18:03:30 +0000 Subject: [PATCH 47/54] Sync CI/CD Config Items --- .configref | 2 +- server/src/configs/custom-environment-variables.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.configref b/.configref index c49f28d8f..fd73a620c 100644 --- a/.configref +++ b/.configref @@ -1 +1 @@ -22390 \ No newline at end of file +22414 \ No newline at end of file diff --git a/server/src/configs/custom-environment-variables.json b/server/src/configs/custom-environment-variables.json index 138e56150..f86066b1a 100644 --- a/server/src/configs/custom-environment-variables.json +++ b/server/src/configs/custom-environment-variables.json @@ -18,7 +18,8 @@ "__name": "DEV_OPTIONS_QUERY_DEBUG", "__format": "boolean" }, - "clientPath": "DEV_OPTIONS_CLIENT_PATH" + "clientPath": "DEV_OPTIONS_CLIENT_PATH", + "logLevel": "DEV_OPTIONS_LOG_LEVEL" }, "api": { "sessionSecret": "API_SESSION_SECRET", From 5d5142a29fb0055c14e043016767d70960593ada Mon Sep 17 00:00:00 2001 From: acocalypso Date: Sun, 30 Jul 2023 10:44:03 +0200 Subject: [PATCH 48/54] Update de.json Fix typo --- locales/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/de.json b/locales/de.json index 6d93ebd58..b962cc0da 100644 --- a/locales/de.json +++ b/locales/de.json @@ -354,7 +354,7 @@ "manage_webhook": "Organisiere {{name}}", "drag_and_drop": "Ziehen Sie die Markierung, um Ihren Standort festzulegen", "click_to_select": "Zum Auswählen klicken", - "add_new": "Füge neuen {{category}} Alaram hinzu", + "add_new": "Füge neuen {{category}} Alarm hinzu", "choose_on_map": "Wähle auf der Karte", "select_profile": "Profil auswählen", "distance_radius": "Vorschau der Radiusabstände", @@ -632,4 +632,4 @@ "remove_webhook_entry": "Entferne von {{name}}", "event_stops": "Event Stops", "event_stop_timers": "Event Stop Timer" -} \ No newline at end of file +} From 29e221b6306e46d66a14d40764afdcc63d7dd8a8 Mon Sep 17 00:00:00 2001 From: lenisko <10072920+lenisko@users.noreply.github.com> Date: Tue, 1 Aug 2023 01:02:58 +0200 Subject: [PATCH 49/54] feat: add discord prompt --- server/scripts/configMigration.js | 1 + server/src/configs/default.json | 1 + server/src/services/DiscordClient.js | 1 + 3 files changed, 3 insertions(+) diff --git a/server/scripts/configMigration.js b/server/scripts/configMigration.js index 2129da4e7..49a6ab943 100644 --- a/server/scripts/configMigration.js +++ b/server/scripts/configMigration.js @@ -153,6 +153,7 @@ const mergeAuth = async () => { allowedGuilds: obj?.allowedGuilds, blockedGuilds: obj?.blockedGuilds, allowedUsers: obj?.allowedUsers, + clientPrompt: obj?.clientPrompt, }) const telegramObj = (obj, name) => ({ diff --git a/server/src/configs/default.json b/server/src/configs/default.json index cc802da07..4ca5a749c 100644 --- a/server/src/configs/default.json +++ b/server/src/configs/default.json @@ -666,6 +666,7 @@ "allowedGuilds": [], "blockedGuilds": [], "allowedUsers": [], + "clientPrompt": "consent", "thumbnailUrl": "https://user-images.githubusercontent.com/58572875/167069223-745a139d-f485-45e3-a25c-93ec4d09779c.png", "trialPeriod": { "start": { diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 980944b99..31ece7ae4 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -343,6 +343,7 @@ module.exports = class DiscordClient { callbackURL: this.strategy.redirectUri, scope: ['identify', 'guilds'], passReqToCallback: true, + prompt: this.strategy.clientPrompt, }, (...args) => this.authHandler(...args), ), From 35bebd44d61457995cccb8f473273991c7f5aab1 Mon Sep 17 00:00:00 2001 From: lenisko <10072920+lenisko@users.noreply.github.com> Date: Tue, 1 Aug 2023 01:17:08 +0200 Subject: [PATCH 50/54] fix: missing place --- server/src/routes/authRouter.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/server/src/routes/authRouter.js b/server/src/routes/authRouter.js index 3669654a6..e83c93122 100644 --- a/server/src/routes/authRouter.js +++ b/server/src/routes/authRouter.js @@ -13,13 +13,14 @@ strategies.forEach((strategy, i) => { strategy.type === 'discord' || strategy.type === 'telegram' ? 'get' : 'post' if (strategy.enabled) { const name = strategy.name ?? `${strategy.type}-${i}` - router[method]( - `/${name}`, - passport.authenticate(name, { - failureRedirect: '/', - successRedirect: '/', - }), - ) + const authenticateOptions = { + failureRedirect: '/', + successRedirect: '/', + }; + if (strategy.type === 'discord') { + authenticateOptions.prompt = strategy.clientPrompt; + } + router[method](`/${name}`, passport.authenticate(name, authenticateOptions)); router[method](`/${name}/callback`, async (req, res, next) => passport.authenticate(name, async (err, user, info) => { if (err) { From 0932f3534239d81a185d4e3655f2ae50e2923d9f Mon Sep 17 00:00:00 2001 From: lenisko <10072920+lenisko@users.noreply.github.com> Date: Tue, 1 Aug 2023 01:23:24 +0200 Subject: [PATCH 51/54] fix --- server/src/routes/authRouter.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/routes/authRouter.js b/server/src/routes/authRouter.js index e83c93122..14c04cf53 100644 --- a/server/src/routes/authRouter.js +++ b/server/src/routes/authRouter.js @@ -13,16 +13,17 @@ strategies.forEach((strategy, i) => { strategy.type === 'discord' || strategy.type === 'telegram' ? 'get' : 'post' if (strategy.enabled) { const name = strategy.name ?? `${strategy.type}-${i}` + const callbackOptions = {}; const authenticateOptions = { failureRedirect: '/', successRedirect: '/', }; if (strategy.type === 'discord') { - authenticateOptions.prompt = strategy.clientPrompt; + callbackOptions.prompt = strategy.clientPrompt; } router[method](`/${name}`, passport.authenticate(name, authenticateOptions)); router[method](`/${name}/callback`, async (req, res, next) => - passport.authenticate(name, async (err, user, info) => { + passport.authenticate(name, callbackOptions, async (err, user, info) => { if (err) { return next(err) } From a562d80e44d8c6588cdf7d03a7b2cafa40779135 Mon Sep 17 00:00:00 2001 From: lenisko <10072920+lenisko@users.noreply.github.com> Date: Tue, 1 Aug 2023 01:34:39 +0200 Subject: [PATCH 52/54] fix: lint --- server/src/routes/authRouter.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/routes/authRouter.js b/server/src/routes/authRouter.js index 14c04cf53..e25cb67c9 100644 --- a/server/src/routes/authRouter.js +++ b/server/src/routes/authRouter.js @@ -13,15 +13,15 @@ strategies.forEach((strategy, i) => { strategy.type === 'discord' || strategy.type === 'telegram' ? 'get' : 'post' if (strategy.enabled) { const name = strategy.name ?? `${strategy.type}-${i}` - const callbackOptions = {}; + const callbackOptions = {} const authenticateOptions = { failureRedirect: '/', successRedirect: '/', - }; + } if (strategy.type === 'discord') { - callbackOptions.prompt = strategy.clientPrompt; + callbackOptions.prompt = strategy.clientPrompt } - router[method](`/${name}`, passport.authenticate(name, authenticateOptions)); + router[method](`/${name}`, passport.authenticate(name, authenticateOptions)) router[method](`/${name}/callback`, async (req, res, next) => passport.authenticate(name, callbackOptions, async (err, user, info) => { if (err) { From 295e17df4b957925ddcc870f7f95d299c4c3f72c Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 31 Jul 2023 20:01:18 -0400 Subject: [PATCH 53/54] Update types.d.ts --- server/src/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/types.d.ts b/server/src/types.d.ts index afb2c30e5..dab985a48 100644 --- a/server/src/types.d.ts +++ b/server/src/types.d.ts @@ -270,7 +270,7 @@ export interface Route { image: string image_border_color: string reversible: boolean - tags: string[] + tags?: string[] type: number updated: number version: number From 4d9add518208b2ba2ade3ae7a005bb6d0b1bd084 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 31 Jul 2023 20:13:11 -0400 Subject: [PATCH 54/54] fix: color consistency --- src/components/tiles/Route.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tiles/Route.jsx b/src/components/tiles/Route.jsx index d8692f812..823317ec1 100644 --- a/src/components/tiles/Route.jsx +++ b/src/components/tiles/Route.jsx @@ -49,7 +49,7 @@ const RouteTile = ({ item, Icons }) => { const [color, darkened] = React.useMemo( () => [ `#${item.image_border_color}`, - darken(`#${item.image_border_color}`, 0.3), + darken(`#${item.image_border_color}`, 0.2), ], [item.image_border_color], ) @@ -129,7 +129,7 @@ const RouteTile = ({ item, Icons }) => { waypoint.lng_degrees, ])} pathOptions={{ - color: clicked ? darkened : color, + color: clicked || hover ? darkened : color, opacity: clicked || hover ? 1 : OPACITY, }} />