From 41febdb180854f69a1414883c5d69f78987c5303 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sat, 26 Sep 2020 21:19:47 +0200 Subject: [PATCH 1/6] Start on map page web ui --- .prettierrc => ui/.prettierrc | 0 ui/package.json | 1 + ui/src/Layout.js | 4 ++ ui/src/Pages/PlacesPage/PlacesPage.js | 56 +++++++++++++++++++++++++++ ui/src/Routes.js | 2 + 5 files changed, 63 insertions(+) rename .prettierrc => ui/.prettierrc (100%) create mode 100644 ui/src/Pages/PlacesPage/PlacesPage.js diff --git a/.prettierrc b/ui/.prettierrc similarity index 100% rename from .prettierrc rename to ui/.prettierrc diff --git a/ui/package.json b/ui/package.json index 1897e406..ec0c4eed 100644 --- a/ui/package.json +++ b/ui/package.json @@ -47,6 +47,7 @@ "eslint-plugin-react-hooks": "^4.1.2", "husky": "^4.3.0", "lint-staged": "^10.4.0", + "mapbox-gl": "^1.12.0", "parcel-plugin-sw-cache": "^0.3.1", "prettier": "^2.1.2", "react-router-prop-types": "^1.0.5" diff --git a/ui/src/Layout.js b/ui/src/Layout.js index f33f17a5..e0ae45f3 100644 --- a/ui/src/Layout.js +++ b/ui/src/Layout.js @@ -103,6 +103,10 @@ const Layout = ({ children, title }) => ( Albums + + + Places + {({ loading, error, data }) => { if (data && data.myUser && data.myUser.admin) { diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js new file mode 100644 index 00000000..0add2ac2 --- /dev/null +++ b/ui/src/Pages/PlacesPage/PlacesPage.js @@ -0,0 +1,56 @@ +import React, { useEffect, useRef, useState } from 'react' +import Layout from '../../Layout' + +import 'mapbox-gl/dist/mapbox-gl.css' +import styled from 'styled-components' + +const MapWrapper = styled.div` + width: 100%; + height: calc(100% - 24px); +` + +const MapContainer = styled.div` + width: 100%; + height: 100%; +` + +const MapPage = () => { + const [mapboxLibrary, setMapboxLibrary] = useState(null) + const mapContainer = useRef() + const map = useRef() + + useEffect(() => { + async function loadMapboxLibrary() { + const mapbox = await import('mapbox-gl') + // mapbox.accessToken = + setMapboxLibrary(mapbox) + } + loadMapboxLibrary() + }, []) + + useEffect(() => { + if ( + mapboxLibrary == null || + mapContainer.current == null || + map.current != null + ) + return + + map.current = new mapboxLibrary.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/streets-v11', + // center: [this.state.lng, this.state.lat], + // zoom: this.state.zoom + }) + }, [mapContainer, mapboxLibrary]) + + return ( + + + + + + ) +} + +export default MapPage diff --git a/ui/src/Routes.js b/ui/src/Routes.js index 63a568a3..85a17ed1 100644 --- a/ui/src/Routes.js +++ b/ui/src/Routes.js @@ -9,6 +9,7 @@ const AlbumsPage = React.lazy(() => import('./Pages/AllAlbumsPage/AlbumsPage')) const AlbumPage = React.lazy(() => import('./Pages/AlbumPage/AlbumPage')) const AuthorizedRoute = React.lazy(() => import('./AuthorizedRoute')) const PhotosPage = React.lazy(() => import('./Pages/PhotosPage/PhotosPage')) +const PlacesPage = React.lazy(() => import('./Pages/PlacesPage/PlacesPage')) const SharePage = React.lazy(() => import('./Pages/SharePage/SharePage')) const LoginPage = React.lazy(() => import('./Pages/LoginPage/LoginPage')) @@ -43,6 +44,7 @@ class Routes extends React.Component { + } />
Page not found
} /> From 0a314b8e1d02117f0f1b182e0b15784a29e84e79 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2020 00:46:01 +0200 Subject: [PATCH 2/6] Add geojson graphql endpoint, work on mapbox --- api/example.env | 4 + api/graphql/generated.go | 137 ++++++++++++++++++++++++ api/graphql/resolvers/mediaGeoJson.go | 137 ++++++++++++++++++++++++ api/graphql/schema.graphql | 6 ++ ui/src/Pages/PlacesPage/PlacesPage.js | 103 +++++++++++++++++- ui/src/Pages/PlacesPage/image-popup.png | Bin 0 -> 1608 bytes 6 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 api/graphql/resolvers/mediaGeoJson.go create mode 100644 ui/src/Pages/PlacesPage/image-popup.png diff --git a/api/example.env b/api/example.env index 86b0e785..46d4f961 100644 --- a/api/example.env +++ b/api/example.env @@ -14,6 +14,10 @@ SERVE_UI=0 # When SERVE_UI is 1, PUBLIC_ENDPOINT is used instead of API_ENDPOINT and UI_ENDPOINT #PUBLIC_ENDPOINT=http://localhost:4001/ +# Enter a valid mapbox token, to enable maps feature +# A token can be created for free at https://mapbox.com +#MAPBOX_TOKEN= + # Set to 1 to set server in development mode, this enables graphql playground # Remove this if running in production DEVELOPMENT=1 diff --git a/api/graphql/generated.go b/api/graphql/generated.go index cb9f8ff8..e500f138 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -144,9 +144,11 @@ type ComplexityRoot struct { Query struct { Album func(childComplexity int, id int) int + MapboxToken func(childComplexity int) int Media func(childComplexity int, id int) int MyAlbums func(childComplexity int, filter *models.Filter, onlyRoot *bool, showEmpty *bool) int MyMedia func(childComplexity int, filter *models.Filter) int + MyMediaGeoJSON func(childComplexity int) int MyUser func(childComplexity int) int Search func(childComplexity int, query string, limitMedia *int, limitAlbums *int) int ShareToken func(childComplexity int, token string, password *string) int @@ -255,6 +257,8 @@ type QueryResolver interface { Album(ctx context.Context, id int) (*models.Album, error) MyMedia(ctx context.Context, filter *models.Filter) ([]*models.Media, error) Media(ctx context.Context, id int) (*models.Media, error) + MyMediaGeoJSON(ctx context.Context) (interface{}, error) + MapboxToken(ctx context.Context) (*string, error) ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) ShareTokenValidatePassword(ctx context.Context, token string, password *string) (bool, error) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) @@ -846,6 +850,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.Album(childComplexity, args["id"].(int)), true + case "Query.mapboxToken": + if e.complexity.Query.MapboxToken == nil { + break + } + + return e.complexity.Query.MapboxToken(childComplexity), true + case "Query.media": if e.complexity.Query.Media == nil { break @@ -882,6 +893,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.MyMedia(childComplexity, args["filter"].(*models.Filter)), true + case "Query.myMediaGeoJson": + if e.complexity.Query.MyMediaGeoJSON == nil { + break + } + + return e.complexity.Query.MyMediaGeoJSON(childComplexity), true + case "Query.myUser": if e.complexity.Query.MyUser == nil { break @@ -1252,6 +1270,7 @@ var sources = []*ast.Source{ {Name: "graphql/schema.graphql", Input: `directive @isAdmin on FIELD_DEFINITION scalar Time +scalar Any enum OrderDirection { ASC @@ -1289,6 +1308,11 @@ type Query { "Get media by id, user must own the media or be admin" media(id: Int!): Media! + "Get media owned by the logged in user, returned in GeoJson format" + myMediaGeoJson: Any! + "Get the mapbox api token, returns null if mapbox is not enabled" + mapboxToken: String + shareToken(token: String!, password: String): ShareToken! shareTokenValidatePassword(token: String!, password: String): Boolean! @@ -4919,6 +4943,73 @@ func (ec *executionContext) _Query_media(ctx context.Context, field graphql.Coll return ec.marshalNMedia2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res) } +func (ec *executionContext) _Query_myMediaGeoJson(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().MyMediaGeoJSON(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(interface{}) + fc.Result = res + return ec.marshalNAny2interface(ctx, field.Selections, res) +} + +func (ec *executionContext) _Query_mapboxToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().MapboxToken(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + func (ec *executionContext) _Query_shareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -8091,6 +8182,31 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "myMediaGeoJson": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_myMediaGeoJson(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) + case "mapboxToken": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_mapboxToken(ctx, field) + return res + }) case "shareToken": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -8757,6 +8873,27 @@ func (ec *executionContext) marshalNAlbum2ᚖgithubᚗcomᚋviktorstrateᚋphoto return ec._Album(ctx, sel, v) } +func (ec *executionContext) unmarshalNAny2interface(ctx context.Context, v interface{}) (interface{}, error) { + res, err := graphql.UnmarshalAny(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNAny2interface(ctx context.Context, sel ast.SelectionSet, v interface{}) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := graphql.MarshalAny(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + } + return res +} + func (ec *executionContext) marshalNAuthorizeResult2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐAuthorizeResult(ctx context.Context, sel ast.SelectionSet, v models.AuthorizeResult) graphql.Marshaler { return ec._AuthorizeResult(ctx, sel, &v) } diff --git a/api/graphql/resolvers/mediaGeoJson.go b/api/graphql/resolvers/mediaGeoJson.go new file mode 100644 index 00000000..ea4de3ec --- /dev/null +++ b/api/graphql/resolvers/mediaGeoJson.go @@ -0,0 +1,137 @@ +package resolvers + +import ( + "context" + "errors" + "os" + "path" + + "github.com/viktorstrate/photoview/api/graphql/auth" + "github.com/viktorstrate/photoview/api/utils" +) + +type geoJSONFeatureCollection struct { + Type string `json:"type"` + Features []geoJSONFeature `json:"features"` +} + +type geoJSONFeature struct { + Type string `json:"type"` + Properties interface{} `json:"properties"` + Geometry geoJSONFeatureGeometry `json:"geometry"` +} + +type geoJSONMediaProperties struct { + MediaID int `json:"media_id"` + MediaTitle string `json:"media_title"` + Thumbnail struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + } `json:"thumbnail"` +} + +type geoJSONFeatureGeometry struct { + Type string `json:"type"` + Coordinates [2]float64 `json:"coordinates"` +} + +func makeGeoJSONFeatureCollection(features []geoJSONFeature) geoJSONFeatureCollection { + return geoJSONFeatureCollection{ + Type: "FeatureCollection", + Features: features, + } +} + +func makeGeoJSONFeature(properties interface{}, geometry geoJSONFeatureGeometry) geoJSONFeature { + return geoJSONFeature{ + Type: "Feature", + Properties: properties, + Geometry: geometry, + } +} + +func makeGeoJSONFeatureGeometryPoint(lat float64, long float64) geoJSONFeatureGeometry { + coordinates := [2]float64{long, lat} + + return geoJSONFeatureGeometry{ + Type: "Point", + Coordinates: coordinates, + } +} + +func (r *queryResolver) MyMediaGeoJSON(ctx context.Context) (interface{}, error) { + + user := auth.UserFromContext(ctx) + if user == nil { + return nil, errors.New("unauthorized") + } + + rows, err := r.Database.Query(` + SELECT media.media_id, media.title, + url.media_name AS thumbnail_name, url.width AS thumbnail_width, url.height AS thumbnail_height, + exif.gps_latitude, exif.gps_longitude FROM media_exif exif + INNER JOIN media ON exif.exif_id = media.exif_id + INNER JOIN media_url url ON media.media_id = url.media_id + INNER JOIN album ON media.album_id = album.album_id + WHERE exif.gps_latitude IS NOT NULL + AND exif.gps_longitude IS NOT NULL + AND url.purpose = 'thumbnail' + AND album.owner_id = ?; + `, user.UserID) + defer rows.Close() + if err != nil { + return nil, err + } + + features := make([]geoJSONFeature, 0) + + for rows.Next() { + + var mediaID int + var mediaTitle string + var thumbnailName string + var thumbnailWidth int + var thumbnailHeight int + var latitude float64 + var longitude float64 + + if err := rows.Scan(&mediaID, &mediaTitle, &thumbnailName, &thumbnailWidth, &thumbnailHeight, &latitude, &longitude); err != nil { + return nil, err + } + + geoPoint := makeGeoJSONFeatureGeometryPoint(latitude, longitude) + + thumbnailURL := utils.ApiEndpointUrl() + thumbnailURL.Path = path.Join(thumbnailURL.Path, "photo", thumbnailName) + + properties := geoJSONMediaProperties{ + MediaID: mediaID, + MediaTitle: mediaTitle, + Thumbnail: struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` + }{ + URL: thumbnailURL.String(), + Width: thumbnailWidth, + Height: thumbnailHeight, + }, + } + + features = append(features, makeGeoJSONFeature(properties, geoPoint)) + } + + featureCollection := makeGeoJSONFeatureCollection(features) + + return featureCollection, nil +} + +func (r *queryResolver) MapboxToken(ctx context.Context) (*string, error) { + mapboxTokenEnv := os.Getenv("MAPBOX_TOKEN") + if mapboxTokenEnv == "" { + return nil, nil + } + + return &mapboxTokenEnv, nil +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index cd97f616..5774963e 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -1,6 +1,7 @@ directive @isAdmin on FIELD_DEFINITION scalar Time +scalar Any enum OrderDirection { ASC @@ -38,6 +39,11 @@ type Query { "Get media by id, user must own the media or be admin" media(id: Int!): Media! + "Get media owned by the logged in user, returned in GeoJson format" + myMediaGeoJson: Any! + "Get the mapbox api token, returns null if mapbox is not enabled" + mapboxToken: String + shareToken(token: String!, password: String): ShareToken! shareTokenValidatePassword(token: String!, password: String): Boolean! diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js index 0add2ac2..5bdfdfee 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.js +++ b/ui/src/Pages/PlacesPage/PlacesPage.js @@ -1,8 +1,12 @@ import React, { useEffect, useRef, useState } from 'react' -import Layout from '../../Layout' +import { useQuery } from 'react-apollo' +import gql from 'graphql-tag' +import styled from 'styled-components' import 'mapbox-gl/dist/mapbox-gl.css' -import styled from 'styled-components' + +import Layout from '../../Layout' +import imagePopup from './image-popup.png' const MapWrapper = styled.div` width: 100%; @@ -14,11 +18,20 @@ const MapContainer = styled.div` height: 100%; ` +const MAPBOX_DATA_QUERY = gql` + query placePageMapboxToken { + mapboxToken + myMediaGeoJson + } +` + const MapPage = () => { const [mapboxLibrary, setMapboxLibrary] = useState(null) const mapContainer = useRef() const map = useRef() + const { data: mapboxData } = useQuery(MAPBOX_DATA_QUERY) + useEffect(() => { async function loadMapboxLibrary() { const mapbox = await import('mapbox-gl') @@ -32,9 +45,13 @@ const MapPage = () => { if ( mapboxLibrary == null || mapContainer.current == null || + mapboxData == null || map.current != null - ) + ) { return + } + + mapboxLibrary.accessToken = mapboxData.mapboxToken map.current = new mapboxLibrary.Map({ container: mapContainer.current, @@ -42,7 +59,85 @@ const MapPage = () => { // center: [this.state.lng, this.state.lat], // zoom: this.state.zoom }) - }, [mapContainer, mapboxLibrary]) + + map.current.on('load', () => { + console.log(mapboxData.myMediaGeoJson) + map.current.addSource('media', { + type: 'geojson', + data: mapboxData.myMediaGeoJson, + cluster: true, + clusterMaxZoom: 14, // Max zoom to cluster points on + clusterRadius: 50, + clusterProperties: { + thumbnail_url: [ + 'coalesce', + ['get', 'url', ['get', 'thumbnail']], + false, + ], + }, + }) + + map.current.loadImage(imagePopup, (error, image) => { + console.log(error, image) + map.current.addImage('media-popup-bg', image) + + map.current.addLayer({ + id: 'media-cluster-popup', + type: 'symbol', + source: 'media', + filter: ['has', 'point_count'], + layout: { + 'icon-image': 'media-popup-bg', + 'icon-size': 0.5, + 'icon-allow-overlap': true, + }, + }) + + map.current.addLayer({ + id: 'media-cluster-count-bg', + type: 'circle', + source: 'media', + filter: ['has', 'point_count'], + paint: { + 'circle-color': '#11b4da', + 'circle-radius': 11, + 'circle-translate': [22, -24], + }, + }) + + map.current.addLayer({ + id: 'media-cluster-count', + type: 'symbol', + source: 'media', + filter: ['has', 'point_count'], + layout: { + 'text-field': '{point_count_abbreviated}', + 'text-size': 12, + 'text-allow-overlap': true, + 'text-offset': [22 / 12, -24 / 12], + }, + paint: { + 'text-color': '#ffffff', + }, + }) + }) + + // map.current.addLayer({ + // id: 'media-points', + // type: 'circle', + // source: 'media', + // filter: ['!', ['has', 'point_count']], + // paint: { + // 'circle-color': '#11b4da', + // 'circle-radius': 4, + // 'circle-stroke-width': 1, + // 'circle-stroke-color': '#fff', + // }, + // }) + + console.log(map.current) + }) + }, [mapContainer, mapboxLibrary, mapboxData]) return ( diff --git a/ui/src/Pages/PlacesPage/image-popup.png b/ui/src/Pages/PlacesPage/image-popup.png new file mode 100644 index 0000000000000000000000000000000000000000..43807d0e300d20cdbba5f1c1b62fa04b6c2ff916 GIT binary patch literal 1608 zcmds%`8(7J0LH&xL(U<}eHN44IhrU#;}};9PAiRP3xRk@_Y`q>h2EJ^-A}!e{ksSzSHZV{T|kxFCe^xgt_7 ztA8t)MoxZilzx>E74l-cc)n-3HQ$J(Ii_JBx#!D~uJ#zQ7uWFd@jfB{dcl;2F<6LQ z-5y_SU5Ht041yzao&^qzmBn}pJ8dc8eh5E#uDf^UJQ`A%T%YBAtb*4QL;(=0T;<4d((bMv78PB#@vvH~Bjw2Qw z`qfC`OEc}p>uEX3xe~b)_7w!~OMe1`VuTs0Gn8olc!?=!kSNCQMmBsz0UzDmoaweK zs(6z*d01%fh`B47@39_MaaH_wD=PWZO~=+PUmLP3)~QoL$d< z3t8-aj+_DOJgbzn0`?8f?J>+mT2lkhM)aYot2!60ebOR3S;yW_mS5M|(_wwAtdVI^ zupBad?~mROE(-q0FXlb2WaQ2 z#sL7{NINUEdmMFz)qBg&My4AI4en9K3!LZuidn%FBuTBpW0+}>PE3`WTIR`Gp+us} zSfE9-cw5SS)uzESuL3o+`2M7_8-)9C5^|bP)l+GaxWvox zYcjld>;M*kbXZ}Sa06OY`NNhAGMb|M8cpiP5$@uM%j>+hFM!V{RN{HmoQbo7nWo@V z_5Zz`1NukIgz>|2NE3SYaKGh5b#@HN;a(n#3pjxa@2*=cEw%DvXQ>aQ*CMHj2ZH9{ zmSPh%cR*HGTQ1HzG=x87vo3!>ykJ?(ob=F9S?nu=kTD)``(mLPh#k#nlx)p7PH`b~1x4w)L=>=_{dsoL5jvCo*$vnwR0J5E zO4*W~aT9=bN3zy+!0KHNwZgA#zYc~1JEx3 zN>4B}idUC>Iz|sP-Ka{K>SFctm%H)Fx?^}dy(oztA* zn=nyQr&p!t^7#--Z-BYy?>}`%1euafYe^0uxNN<1kgqZ6ujqyc4?CuMU!nOQb`-=m zPjm*ioFhEW>nqE%=M(9oRK=mLoPy0el@7&^t#OMyJpMT);_5L>BD5b?3@x_6`_lp& z3d#8|60AR@*cbZed9h1jA>L3FJCx33GCLp$$^ZtI|2Z&2p$Y+Gt{?m*4~KdC@Jxg9 zJ{!Lw2UO=I%*e+^fcigg=1oQ`PHnzc$98G6`m!s#O-D@DiBhQcK5}-OGn6ALqx@-W zid0u&uOxhJcSuhK!igBth3X7-FzJ(ZVV_(<__m=YH5|GzO(!`-t*+%S=1ach2^5ko z%7Unm%iZZNM6$RRqbS{vaII-`u8%(Mn-{GSn1wAK1QS^xx!rbFx0JGfVY(I{!@Tbu zQ$i4im$diHv}eq(mbS~Y2k|3*eYHthvhQoj-E7$ci#YX9U<;J6XN62uk`Fy>sQ_ literal 0 HcmV?d00001 From c73c962fc386a2b2ac17d12dd193837d8e9d1e49 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2020 16:20:55 +0200 Subject: [PATCH 3/6] Show media with thumbnail on map --- ui/src/Pages/PlacesPage/MapClusterMarker.js | 66 +++++++++++ ui/src/Pages/PlacesPage/PlacesPage.js | 115 +++++++++----------- ui/src/Pages/PlacesPage/image-popup.svg | 21 ++++ 3 files changed, 140 insertions(+), 62 deletions(-) create mode 100644 ui/src/Pages/PlacesPage/MapClusterMarker.js create mode 100644 ui/src/Pages/PlacesPage/image-popup.svg diff --git a/ui/src/Pages/PlacesPage/MapClusterMarker.js b/ui/src/Pages/PlacesPage/MapClusterMarker.js new file mode 100644 index 00000000..d2ed8370 --- /dev/null +++ b/ui/src/Pages/PlacesPage/MapClusterMarker.js @@ -0,0 +1,66 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' + +import imagePopupSrc from './image-popup.svg' + +const Wrapper = styled.div` + width: 56px; + height: 68px; + position: relative; + margin-top: -54px; +` + +const ThumbnailImage = styled.img` + position: absolute; + width: 48px; + height: 48px; + top: 4px; + left: 4px; + border-radius: 2px; + object-fit: cover; +` + +const PopupImage = styled.img` + width: 100%; + height: 100%; +` + +const PointCountCircle = styled.div` + position: absolute; + top: -10px; + right: -10px; + width: 24px; + height: 24px; + background-color: #00b3dc; + border-radius: 50%; + color: white; + text-align: center; + padding-top: 2px; +` + +const MapClusterMarker = ({ + thumbnail: thumbJson, + point_count_abbreviated, + cluster, +}) => { + const thumbnail = JSON.parse(thumbJson) + + return ( + + + + {cluster && ( + {point_count_abbreviated} + )} + + ) +} + +MapClusterMarker.propTypes = { + thumbnail: PropTypes.string, + cluster: PropTypes.bool, + point_count_abbreviated: PropTypes.number, +} + +export default MapClusterMarker diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js index 5bdfdfee..294e8bc8 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.js +++ b/ui/src/Pages/PlacesPage/PlacesPage.js @@ -1,4 +1,6 @@ import React, { useEffect, useRef, useState } from 'react' +import ReactDOM from 'react-dom' +import PropTypes from 'prop-types' import { useQuery } from 'react-apollo' import gql from 'graphql-tag' import styled from 'styled-components' @@ -6,7 +8,8 @@ import styled from 'styled-components' import 'mapbox-gl/dist/mapbox-gl.css' import Layout from '../../Layout' -import imagePopup from './image-popup.png' + +import MapClusterMarker from './MapClusterMarker' const MapWrapper = styled.div` width: 100%; @@ -66,74 +69,62 @@ const MapPage = () => { type: 'geojson', data: mapboxData.myMediaGeoJson, cluster: true, - clusterMaxZoom: 14, // Max zoom to cluster points on + // clusterMaxZoom: 14, // Max zoom to cluster points on clusterRadius: 50, clusterProperties: { - thumbnail_url: [ - 'coalesce', - ['get', 'url', ['get', 'thumbnail']], - false, - ], + thumbnail: ['coalesce', ['get', 'thumbnail'], false], }, }) - map.current.loadImage(imagePopup, (error, image) => { - console.log(error, image) - map.current.addImage('media-popup-bg', image) - - map.current.addLayer({ - id: 'media-cluster-popup', - type: 'symbol', - source: 'media', - filter: ['has', 'point_count'], - layout: { - 'icon-image': 'media-popup-bg', - 'icon-size': 0.5, - 'icon-allow-overlap': true, - }, - }) - - map.current.addLayer({ - id: 'media-cluster-count-bg', - type: 'circle', - source: 'media', - filter: ['has', 'point_count'], - paint: { - 'circle-color': '#11b4da', - 'circle-radius': 11, - 'circle-translate': [22, -24], - }, - }) - - map.current.addLayer({ - id: 'media-cluster-count', - type: 'symbol', - source: 'media', - filter: ['has', 'point_count'], - layout: { - 'text-field': '{point_count_abbreviated}', - 'text-size': 12, - 'text-allow-overlap': true, - 'text-offset': [22 / 12, -24 / 12], - }, - paint: { - 'text-color': '#ffffff', - }, - }) + // Add dummy layer for features to be queryable + map.current.addLayer({ + id: 'media-points', + type: 'circle', + source: 'media', + filter: ['!', true], }) - // map.current.addLayer({ - // id: 'media-points', - // type: 'circle', - // source: 'media', - // filter: ['!', ['has', 'point_count']], - // paint: { - // 'circle-color': '#11b4da', - // 'circle-radius': 4, - // 'circle-stroke-width': 1, - // 'circle-stroke-color': '#fff', - // }, - // }) + map.current.on('move', updateMarkers) + map.current.on('moveend', updateMarkers) + updateMarkers() + + var markers = {} + var markersOnScreen = {} + + function updateMarkers() { + var newMarkers = {} + var features = map.current.querySourceFeatures('media') + + // for every media on the screen, create an HTML marker for it (if we didn't yet), + // and add it to the map if it's not there already + for (var i = 0; i < features.length; i++) { + var coords = features[i].geometry.coordinates + var props = features[i].properties + var id = props.cluster ? props.cluster_id : props.media_id + + var marker = markers[id] + if (!marker) { + var el = createClusterPopupElement(props) + marker = markers[id] = new mapboxLibrary.Marker({ + element: el, + }).setLngLat(coords) + } + newMarkers[id] = marker + + if (!markersOnScreen[id]) marker.addTo(map.current) + } + // for every marker we've added previously, remove those that are no longer visible + for (id in markersOnScreen) { + if (!newMarkers[id]) markersOnScreen[id].remove() + } + markersOnScreen = newMarkers + } + + function createClusterPopupElement(props) { + const el = document.createElement('div') + ReactDOM.render(, el) + return el + } console.log(map.current) }) diff --git a/ui/src/Pages/PlacesPage/image-popup.svg b/ui/src/Pages/PlacesPage/image-popup.svg new file mode 100644 index 00000000..da1249ea --- /dev/null +++ b/ui/src/Pages/PlacesPage/image-popup.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + From 539b1c12451f47d54433eb02a9bee7da3fd84181 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2020 17:54:50 +0200 Subject: [PATCH 4/6] When map marker is clicked a present view is shown --- ui/src/Pages/PlacesPage/MapClusterMarker.js | 16 +- ui/src/Pages/PlacesPage/MapPresentMarker.js | 137 ++++++++++++++++++ ui/src/Pages/PlacesPage/PlacesPage.js | 62 ++------ ui/src/Pages/PlacesPage/image-popup.png | Bin 1608 -> 0 bytes .../Pages/PlacesPage/mapboxHelperFunctions.js | 48 ++++++ 5 files changed, 216 insertions(+), 47 deletions(-) create mode 100644 ui/src/Pages/PlacesPage/MapPresentMarker.js delete mode 100644 ui/src/Pages/PlacesPage/image-popup.png create mode 100644 ui/src/Pages/PlacesPage/mapboxHelperFunctions.js diff --git a/ui/src/Pages/PlacesPage/MapClusterMarker.js b/ui/src/Pages/PlacesPage/MapClusterMarker.js index d2ed8370..52e10bcb 100644 --- a/ui/src/Pages/PlacesPage/MapClusterMarker.js +++ b/ui/src/Pages/PlacesPage/MapClusterMarker.js @@ -9,6 +9,7 @@ const Wrapper = styled.div` height: 68px; position: relative; margin-top: -54px; + cursor: pointer; ` const ThumbnailImage = styled.img` @@ -43,11 +44,21 @@ const MapClusterMarker = ({ thumbnail: thumbJson, point_count_abbreviated, cluster, + cluster_id, + media_id, + setPresentMarker, }) => { const thumbnail = JSON.parse(thumbJson) + const presentMedia = () => { + setPresentMarker({ + cluster: !!cluster, + id: cluster ? cluster_id : media_id, + }) + } + return ( - + {cluster && ( @@ -61,6 +72,9 @@ MapClusterMarker.propTypes = { thumbnail: PropTypes.string, cluster: PropTypes.bool, point_count_abbreviated: PropTypes.number, + cluster_id: PropTypes.number, + media_id: PropTypes.number, + setPresentMarker: PropTypes.func, } export default MapClusterMarker diff --git a/ui/src/Pages/PlacesPage/MapPresentMarker.js b/ui/src/Pages/PlacesPage/MapPresentMarker.js new file mode 100644 index 00000000..ef2c992c --- /dev/null +++ b/ui/src/Pages/PlacesPage/MapPresentMarker.js @@ -0,0 +1,137 @@ +import React, { useEffect, useState, useRef } from 'react' +import PropTypes from 'prop-types' +import gql from 'graphql-tag' +import { useLazyQuery } from 'react-apollo' +import PresentView from '../../components/photoGallery/presentView/PresentView' + +const QUERY_MEDIA = gql` + query placePageQueryMedia($mediaID: Int!) { + media(id: $mediaID) { + id + title + thumbnail { + url + width + height + } + highRes { + url + width + height + } + videoWeb { + url + width + height + } + type + } + } +` + +const getMediaFromMarker = (map, presentMarker) => + new Promise((resolve, reject) => { + const { cluster, id } = presentMarker + + if (cluster) { + map + .getSource('media') + .getClusterLeaves(id, 1000, 0, (error, features) => { + if (error) { + reject(error) + return + } + + const media = features.map(feat => feat.properties) + resolve(media) + }) + } else { + const features = map.querySourceFeatures('media') + const media = features.find(f => f.properties.media_id == id).properties + resolve([media]) + } + }) + +const MapPresentMarker = ({ map, presentMarker, setPresentMarker }) => { + const [media, setMedia] = useState(null) + const [currentIndex, setCurrentIndex] = useState(0) + + const previousLoadedMedia = useRef(null) + const [loadMedia, { data: loadedMedia }] = useLazyQuery(QUERY_MEDIA, { + onCompleted(data) { + previousLoadedMedia.current = data + }, + }) + + useEffect(() => { + if (presentMarker == null || map == null) { + setMedia(null) + return + } + + getMediaFromMarker(map, presentMarker).then(setMedia) + }, [presentMarker]) + + useEffect(() => { + if (!media) return + + setCurrentIndex(0) + loadMedia({ + variables: { + mediaID: media[0].media_id, + }, + }) + }, [media]) + + useEffect(() => { + if (!media) return + + console.log('Current index change', currentIndex, media) + + loadMedia({ + variables: { + mediaID: media[currentIndex].media_id, + }, + }) + }, [currentIndex]) + + if (presentMarker == null || map == null) { + return null + } + + if (loadedMedia == null && previousLoadedMedia.current == null) { + return null + } + + const displayMedia = loadedMedia + ? loadedMedia.media + : previousLoadedMedia.current.media + + console.log('diaplay media', displayMedia) + + return ( + { + setCurrentIndex(i => Math.min(media.length - 1, i + 1)) + }} + previousImage={() => { + setCurrentIndex(i => Math.max(0, i - 1)) + }} + setPresenting={presenting => { + if (!presenting) { + previousLoadedMedia.current = null + setPresentMarker(null) + } + }} + /> + ) +} + +MapPresentMarker.propTypes = { + map: PropTypes.object, + presentMarker: PropTypes.object, + setPresentMarker: PropTypes.func.isRequired, +} + +export default MapPresentMarker diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js index 294e8bc8..5b817992 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.js +++ b/ui/src/Pages/PlacesPage/PlacesPage.js @@ -1,6 +1,4 @@ import React, { useEffect, useRef, useState } from 'react' -import ReactDOM from 'react-dom' -import PropTypes from 'prop-types' import { useQuery } from 'react-apollo' import gql from 'graphql-tag' import styled from 'styled-components' @@ -8,8 +6,8 @@ import styled from 'styled-components' import 'mapbox-gl/dist/mapbox-gl.css' import Layout from '../../Layout' - -import MapClusterMarker from './MapClusterMarker' +import { makeUpdateMarkers } from './mapboxHelperFunctions' +import MapPresentMarker from './MapPresentMarker' const MapWrapper = styled.div` width: 100%; @@ -30,6 +28,7 @@ const MAPBOX_DATA_QUERY = gql` const MapPage = () => { const [mapboxLibrary, setMapboxLibrary] = useState(null) + const [presentMarker, setPresentMarker] = useState(null) const mapContainer = useRef() const map = useRef() @@ -59,8 +58,7 @@ const MapPage = () => { map.current = new mapboxLibrary.Map({ container: mapContainer.current, style: 'mapbox://styles/mapbox/streets-v11', - // center: [this.state.lng, this.state.lat], - // zoom: this.state.zoom + zoom: 1, }) map.current.on('load', () => { @@ -84,49 +82,16 @@ const MapPage = () => { filter: ['!', true], }) + const updateMarkers = makeUpdateMarkers({ + map: map.current, + mapboxLibrary, + setPresentMarker, + }) + map.current.on('move', updateMarkers) map.current.on('moveend', updateMarkers) + map.current.on('sourcedata', updateMarkers) updateMarkers() - - var markers = {} - var markersOnScreen = {} - - function updateMarkers() { - var newMarkers = {} - var features = map.current.querySourceFeatures('media') - - // for every media on the screen, create an HTML marker for it (if we didn't yet), - // and add it to the map if it's not there already - for (var i = 0; i < features.length; i++) { - var coords = features[i].geometry.coordinates - var props = features[i].properties - var id = props.cluster ? props.cluster_id : props.media_id - - var marker = markers[id] - if (!marker) { - var el = createClusterPopupElement(props) - marker = markers[id] = new mapboxLibrary.Marker({ - element: el, - }).setLngLat(coords) - } - newMarkers[id] = marker - - if (!markersOnScreen[id]) marker.addTo(map.current) - } - // for every marker we've added previously, remove those that are no longer visible - for (id in markersOnScreen) { - if (!newMarkers[id]) markersOnScreen[id].remove() - } - markersOnScreen = newMarkers - } - - function createClusterPopupElement(props) { - const el = document.createElement('div') - ReactDOM.render(, el) - return el - } - - console.log(map.current) }) }, [mapContainer, mapboxLibrary, mapboxData]) @@ -135,6 +100,11 @@ const MapPage = () => { + ) } diff --git a/ui/src/Pages/PlacesPage/image-popup.png b/ui/src/Pages/PlacesPage/image-popup.png deleted file mode 100644 index 43807d0e300d20cdbba5f1c1b62fa04b6c2ff916..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1608 zcmds%`8(7J0LH&xL(U<}eHN44IhrU#;}};9PAiRP3xRk@_Y`q>h2EJ^-A}!e{ksSzSHZV{T|kxFCe^xgt_7 ztA8t)MoxZilzx>E74l-cc)n-3HQ$J(Ii_JBx#!D~uJ#zQ7uWFd@jfB{dcl;2F<6LQ z-5y_SU5Ht041yzao&^qzmBn}pJ8dc8eh5E#uDf^UJQ`A%T%YBAtb*4QL;(=0T;<4d((bMv78PB#@vvH~Bjw2Qw z`qfC`OEc}p>uEX3xe~b)_7w!~OMe1`VuTs0Gn8olc!?=!kSNCQMmBsz0UzDmoaweK zs(6z*d01%fh`B47@39_MaaH_wD=PWZO~=+PUmLP3)~QoL$d< z3t8-aj+_DOJgbzn0`?8f?J>+mT2lkhM)aYot2!60ebOR3S;yW_mS5M|(_wwAtdVI^ zupBad?~mROE(-q0FXlb2WaQ2 z#sL7{NINUEdmMFz)qBg&My4AI4en9K3!LZuidn%FBuTBpW0+}>PE3`WTIR`Gp+us} zSfE9-cw5SS)uzESuL3o+`2M7_8-)9C5^|bP)l+GaxWvox zYcjld>;M*kbXZ}Sa06OY`NNhAGMb|M8cpiP5$@uM%j>+hFM!V{RN{HmoQbo7nWo@V z_5Zz`1NukIgz>|2NE3SYaKGh5b#@HN;a(n#3pjxa@2*=cEw%DvXQ>aQ*CMHj2ZH9{ zmSPh%cR*HGTQ1HzG=x87vo3!>ykJ?(ob=F9S?nu=kTD)``(mLPh#k#nlx)p7PH`b~1x4w)L=>=_{dsoL5jvCo*$vnwR0J5E zO4*W~aT9=bN3zy+!0KHNwZgA#zYc~1JEx3 zN>4B}idUC>Iz|sP-Ka{K>SFctm%H)Fx?^}dy(oztA* zn=nyQr&p!t^7#--Z-BYy?>}`%1euafYe^0uxNN<1kgqZ6ujqyc4?CuMU!nOQb`-=m zPjm*ioFhEW>nqE%=M(9oRK=mLoPy0el@7&^t#OMyJpMT);_5L>BD5b?3@x_6`_lp& z3d#8|60AR@*cbZed9h1jA>L3FJCx33GCLp$$^ZtI|2Z&2p$Y+Gt{?m*4~KdC@Jxg9 zJ{!Lw2UO=I%*e+^fcigg=1oQ`PHnzc$98G6`m!s#O-D@DiBhQcK5}-OGn6ALqx@-W zid0u&uOxhJcSuhK!igBth3X7-FzJ(ZVV_(<__m=YH5|GzO(!`-t*+%S=1ach2^5ko z%7Unm%iZZNM6$RRqbS{vaII-`u8%(Mn-{GSn1wAK1QS^xx!rbFx0JGfVY(I{!@Tbu zQ$i4im$diHv}eq(mbS~Y2k|3*eYHthvhQoj-E7$ci#YX9U<;J6XN62uk`Fy>sQ_ diff --git a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js new file mode 100644 index 00000000..62c8964b --- /dev/null +++ b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js @@ -0,0 +1,48 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import MapClusterMarker from './MapClusterMarker' + +let markers = {} +let markersOnScreen = {} + +export const makeUpdateMarkers = ({ + map, + mapboxLibrary, + setPresentMarker, +}) => () => { + let newMarkers = {} + const features = map.querySourceFeatures('media') + + // for every media on the screen, create an HTML marker for it (if we didn't yet), + // and add it to the map if it's not there already + for (let i = 0; i < features.length; i++) { + const coords = features[i].geometry.coordinates + const props = features[i].properties + const id = props.cluster ? props.cluster_id : props.media_id + + let marker = markers[id] + if (!marker) { + let el = createClusterPopupElement(props, setPresentMarker) + marker = markers[id] = new mapboxLibrary.Marker({ + element: el, + }).setLngLat(coords) + } + newMarkers[id] = marker + + if (!markersOnScreen[id]) marker.addTo(map) + } + // for every marker we've added previously, remove those that are no longer visible + for (const id in markersOnScreen) { + if (!newMarkers[id]) markersOnScreen[id].remove() + } + markersOnScreen = newMarkers +} + +function createClusterPopupElement(geojsonProps, setPresentMarker) { + const el = document.createElement('div') + ReactDOM.render( + , + el + ) + return el +} From 1b55caa0a2fdcd7dd8aca38ab5e1a923622fc07b Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2020 18:18:30 +0200 Subject: [PATCH 5/6] Disable places page when mapbox token isn't defined --- docker-compose.example.yml | 5 ++ ui/src/Layout.js | 108 ++++++++++++++------------ ui/src/Pages/PlacesPage/PlacesPage.js | 18 ++++- 3 files changed, 81 insertions(+), 50 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 51d5443d..e18046ef 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -31,6 +31,11 @@ services: # change this value to http://example.com/ - PUBLIC_ENDPOINT=http://localhost:8000/ + # Optional: To enable map related features, you need to create a mapbox token. + # A token can be generated for free here https://account.mapbox.com/access-tokens/ + # It's a good idea to limit the scope of the token to your own domain, to prevent others from using it. + # - MAPBOX_TOKEN= + volumes: - api_cache:/app/cache diff --git a/ui/src/Layout.js b/ui/src/Layout.js index e0ae45f3..bff6587e 100644 --- a/ui/src/Layout.js +++ b/ui/src/Layout.js @@ -4,13 +4,13 @@ import styled from 'styled-components' import { NavLink } from 'react-router-dom' import { Icon } from 'semantic-ui-react' import Sidebar from './components/sidebar/Sidebar' -import { Query } from 'react-apollo' +import { useQuery } from 'react-apollo' import gql from 'graphql-tag' import { Authorized } from './AuthorizedRoute' import { Helmet } from 'react-helmet' import Header from './components/header/Header' -const adminQuery = gql` +const ADMIN_QUERY = gql` query adminQuery { myUser { admin @@ -18,6 +18,12 @@ const adminQuery = gql` } ` +const MAPBOX_QUERY = gql` + query mapboxEnabledQuery { + mapboxToken + } +` + const Container = styled.div` height: 100%; display: flex; @@ -88,54 +94,58 @@ const SideButtonLabel = styled.div` font-size: 16px; ` -const Layout = ({ children, title }) => ( - - - {title ? `${title} - Photoview` : `Photoview`} - - - - - - Photos - - - - Albums - - - - Places - - - {({ loading, error, data }) => { - if (data && data.myUser && data.myUser.admin) { - return ( - - - Settings - - ) - } +const Layout = ({ children, title }) => { + const adminQuery = useQuery(ADMIN_QUERY) + const mapboxQuery = useQuery(MAPBOX_QUERY) - return null - }} - - - - Log out - - - - - - {children} -
-
-
-
- -) + const isAdmin = + adminQuery.data && adminQuery.data.myUser && adminQuery.data.myUser.admin + + const mapboxEnabled = mapboxQuery.data && mapboxQuery.data.mapboxToken != null + + return ( + + + {title ? `${title} - Photoview` : `Photoview`} + + + + + + Photos + + + + Albums + + {mapboxEnabled ? ( + + + Places + + ) : null} + {isAdmin ? ( + + + Settings + + ) : null} + + + Log out + + + + + + {children} +
+
+
+
+ + ) +} Layout.propTypes = { children: PropTypes.any.isRequired, diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js index 5b817992..05808fa4 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.js +++ b/ui/src/Pages/PlacesPage/PlacesPage.js @@ -37,7 +37,6 @@ const MapPage = () => { useEffect(() => { async function loadMapboxLibrary() { const mapbox = await import('mapbox-gl') - // mapbox.accessToken = setMapboxLibrary(mapbox) } loadMapboxLibrary() @@ -95,6 +94,23 @@ const MapPage = () => { }) }, [mapContainer, mapboxLibrary, mapboxData]) + if (mapboxData && mapboxData.mapboxToken == null) { + return ( + +

Mapbox token is not set

+

+ To use map related features a mapbox token is needed. +
A mapbox token can be created for free at{' '} + mapbox.com. +

+

+ Make sure the access token is added as the MAPBOX_TOKEN environment + variable. +

+
+ ) + } + return ( From b5e640c1bda8ae79bfafa07f4200d5427d2cf2a7 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2020 20:52:09 +0200 Subject: [PATCH 6/6] Add graphql query for getting media from an array of ids - Add keyboard navigation when presenting images in map view --- api/graphql/generated.go | 120 ++++++++++++++++++ api/graphql/resolvers/media.go | 38 ++++++ api/graphql/schema.graphql | 3 + ui/src/Pages/PlacesPage/MapPresentMarker.js | 49 ++----- ui/src/Pages/PlacesPage/PlacesPage.js | 2 - .../Pages/PlacesPage/mapboxHelperFunctions.js | 4 +- .../components/photoGallery/PhotoGallery.js | 26 ---- .../photoGallery/presentView/PresentView.js | 47 +++++-- 8 files changed, 215 insertions(+), 74 deletions(-) diff --git a/api/graphql/generated.go b/api/graphql/generated.go index e0d50de0..1b3171e4 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -146,6 +146,7 @@ type ComplexityRoot struct { Album func(childComplexity int, id int) int MapboxToken func(childComplexity int) int Media func(childComplexity int, id int) int + MediaList func(childComplexity int, ids []int) int MyAlbums func(childComplexity int, filter *models.Filter, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) int MyMedia func(childComplexity int, filter *models.Filter) int MyMediaGeoJSON func(childComplexity int) int @@ -257,6 +258,7 @@ type QueryResolver interface { Album(ctx context.Context, id int) (*models.Album, error) MyMedia(ctx context.Context, filter *models.Filter) ([]*models.Media, error) Media(ctx context.Context, id int) (*models.Media, error) + MediaList(ctx context.Context, ids []int) ([]*models.Media, error) MyMediaGeoJSON(ctx context.Context) (interface{}, error) MapboxToken(ctx context.Context) (*string, error) ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) @@ -869,6 +871,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.Media(childComplexity, args["id"].(int)), true + case "Query.mediaList": + if e.complexity.Query.MediaList == nil { + break + } + + args, err := ec.field_Query_mediaList_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.MediaList(childComplexity, args["ids"].([]int)), true + case "Query.myAlbums": if e.complexity.Query.MyAlbums == nil { break @@ -1310,6 +1324,9 @@ type Query { "Get media by id, user must own the media or be admin" media(id: Int!): Media! + "Get a list of media by their ids, user must own the media or be admin" + mediaList(ids: [Int!]!): [Media!]! + "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! "Get the mapbox api token, returns null if mapbox is not enabled" @@ -1584,6 +1601,7 @@ func (ec *executionContext) field_Album_media_args(ctx context.Context, rawArgs args["filter"] = arg0 var arg1 *bool if tmp, ok := rawArgs["onlyFavorites"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyFavorites")) arg1, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) if err != nil { return nil, err @@ -2010,6 +2028,21 @@ func (ec *executionContext) field_Query_album_args(ctx context.Context, rawArgs return args, nil } +func (ec *executionContext) field_Query_mediaList_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 []int + if tmp, ok := rawArgs["ids"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ids")) + arg0, err = ec.unmarshalNInt2ᚕintᚄ(ctx, tmp) + if err != nil { + return nil, err + } + } + args["ids"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_media_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2057,6 +2090,7 @@ func (ec *executionContext) field_Query_myAlbums_args(ctx context.Context, rawAr args["showEmpty"] = arg2 var arg3 *bool if tmp, ok := rawArgs["onlyWithFavorites"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyWithFavorites")) arg3, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) if err != nil { return nil, err @@ -4965,6 +4999,48 @@ func (ec *executionContext) _Query_media(ctx context.Context, field graphql.Coll return ec.marshalNMedia2ᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx, field.Selections, res) } +func (ec *executionContext) _Query_mediaList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_mediaList_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().MediaList(rctx, args["ids"].([]int)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.Media) + fc.Result = res + return ec.marshalNMedia2ᚕᚖgithubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMediaᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _Query_myMediaGeoJson(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -8204,6 +8280,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "mediaList": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_mediaList(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "myMediaGeoJson": field := field out.Concurrently(i, func() (res graphql.Marshaler) { @@ -8975,6 +9065,36 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti return res } +func (ec *executionContext) unmarshalNInt2ᚕintᚄ(ctx context.Context, v interface{}) ([]int, error) { + var vSlice []interface{} + if v != nil { + if tmp1, ok := v.([]interface{}); ok { + vSlice = tmp1 + } else { + vSlice = []interface{}{v} + } + } + var err error + res := make([]int, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNInt2int(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalNInt2ᚕintᚄ(ctx context.Context, sel ast.SelectionSet, v []int) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNInt2int(ctx, sel, v[i]) + } + + return ret +} + func (ec *executionContext) marshalNMedia2githubᚗcomᚋviktorstrateᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMedia(ctx context.Context, sel ast.SelectionSet, v models.Media) graphql.Marshaler { return ec._Media(ctx, sel, &v) } diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index 7d7f5331..07890cc6 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -60,6 +60,44 @@ func (r *queryResolver) Media(ctx context.Context, id int) (*models.Media, error return media, nil } +func (r *queryResolver) MediaList(ctx context.Context, ids []int) ([]*models.Media, error) { + user := auth.UserFromContext(ctx) + if user == nil { + return nil, auth.ErrUnauthorized + } + + if len(ids) == 0 { + return nil, errors.New("no ids provided") + } + + mediaIDQuestions := strings.Repeat("?,", len(ids))[:len(ids)*2-1] + + queryArgs := make([]interface{}, 0) + for _, id := range ids { + queryArgs = append(queryArgs, id) + } + queryArgs = append(queryArgs, user.UserID) + + rows, err := r.Database.Query(` + SELECT media.* FROM media + JOIN album ON media.album_id = album.album_id + WHERE media.media_id IN (`+mediaIDQuestions+`) AND album.owner_id = ? + AND media.media_id IN ( + SELECT media_id FROM media_url WHERE media_url.media_id = media.media_id + ) + `, queryArgs...) + if err != nil { + return nil, errors.Wrap(err, "could not get media list by media_id and user_id from database") + } + + media, err := models.NewMediaFromRows(rows) + if err != nil { + return nil, errors.Wrap(err, "could not convert database rows to media") + } + + return media, nil +} + type mediaResolver struct { *Resolver } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 3f0002ce..05544ba7 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -41,6 +41,9 @@ type Query { "Get media by id, user must own the media or be admin" media(id: Int!): Media! + "Get a list of media by their ids, user must own the media or be admin" + mediaList(ids: [Int!]!): [Media!]! + "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! "Get the mapbox api token, returns null if mapbox is not enabled" diff --git a/ui/src/Pages/PlacesPage/MapPresentMarker.js b/ui/src/Pages/PlacesPage/MapPresentMarker.js index ef2c992c..0f3e9b49 100644 --- a/ui/src/Pages/PlacesPage/MapPresentMarker.js +++ b/ui/src/Pages/PlacesPage/MapPresentMarker.js @@ -5,8 +5,8 @@ import { useLazyQuery } from 'react-apollo' import PresentView from '../../components/photoGallery/presentView/PresentView' const QUERY_MEDIA = gql` - query placePageQueryMedia($mediaID: Int!) { - media(id: $mediaID) { + query placePageQueryMedia($mediaIDs: [Int!]!) { + mediaList(ids: $mediaIDs) { id title thumbnail { @@ -53,74 +53,51 @@ const getMediaFromMarker = (map, presentMarker) => }) const MapPresentMarker = ({ map, presentMarker, setPresentMarker }) => { - const [media, setMedia] = useState(null) + const [mediaMarkers, setMediaMarkers] = useState(null) const [currentIndex, setCurrentIndex] = useState(0) - const previousLoadedMedia = useRef(null) - const [loadMedia, { data: loadedMedia }] = useLazyQuery(QUERY_MEDIA, { - onCompleted(data) { - previousLoadedMedia.current = data - }, - }) + const [loadMedia, { data: loadedMedia }] = useLazyQuery(QUERY_MEDIA) useEffect(() => { if (presentMarker == null || map == null) { - setMedia(null) + setMediaMarkers(null) return } - getMediaFromMarker(map, presentMarker).then(setMedia) + getMediaFromMarker(map, presentMarker).then(setMediaMarkers) }, [presentMarker]) useEffect(() => { - if (!media) return + if (!mediaMarkers) return setCurrentIndex(0) loadMedia({ variables: { - mediaID: media[0].media_id, + mediaIDs: mediaMarkers.map(x => x.media_id), }, }) - }, [media]) - - useEffect(() => { - if (!media) return - - console.log('Current index change', currentIndex, media) - - loadMedia({ - variables: { - mediaID: media[currentIndex].media_id, - }, - }) - }, [currentIndex]) + }, [mediaMarkers]) if (presentMarker == null || map == null) { return null } - if (loadedMedia == null && previousLoadedMedia.current == null) { + if (loadedMedia == null) { return null } - const displayMedia = loadedMedia - ? loadedMedia.media - : previousLoadedMedia.current.media - - console.log('diaplay media', displayMedia) - return ( { - setCurrentIndex(i => Math.min(media.length - 1, i + 1)) + setCurrentIndex(i => Math.min(mediaMarkers.length - 1, i + 1)) }} previousImage={() => { setCurrentIndex(i => Math.max(0, i - 1)) }} setPresenting={presenting => { if (!presenting) { - previousLoadedMedia.current = null + setCurrentIndex(0) setPresentMarker(null) } }} diff --git a/ui/src/Pages/PlacesPage/PlacesPage.js b/ui/src/Pages/PlacesPage/PlacesPage.js index 05808fa4..d31e555d 100644 --- a/ui/src/Pages/PlacesPage/PlacesPage.js +++ b/ui/src/Pages/PlacesPage/PlacesPage.js @@ -61,12 +61,10 @@ const MapPage = () => { }) map.current.on('load', () => { - console.log(mapboxData.myMediaGeoJson) map.current.addSource('media', { type: 'geojson', data: mapboxData.myMediaGeoJson, cluster: true, - // clusterMaxZoom: 14, // Max zoom to cluster points on clusterRadius: 50, clusterProperties: { thumbnail: ['coalesce', ['get', 'thumbnail'], false], diff --git a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js index 62c8964b..32e412c3 100644 --- a/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js +++ b/ui/src/Pages/PlacesPage/mapboxHelperFunctions.js @@ -18,7 +18,9 @@ export const makeUpdateMarkers = ({ for (let i = 0; i < features.length; i++) { const coords = features[i].geometry.coordinates const props = features[i].properties - const id = props.cluster ? props.cluster_id : props.media_id + const id = props.cluster + ? `cluster_${props.cluster_id}` + : `media_${props.media_id}` let marker = markers[id] if (!marker) { diff --git a/ui/src/components/photoGallery/PhotoGallery.js b/ui/src/components/photoGallery/PhotoGallery.js index f490c3a9..b064c354 100644 --- a/ui/src/components/photoGallery/PhotoGallery.js +++ b/ui/src/components/photoGallery/PhotoGallery.js @@ -44,32 +44,6 @@ const PhotoGallery = ({ }) => { const { updateSidebar } = useContext(SidebarContext) - useEffect(() => { - const keyDownEvent = e => { - if (!onSelectImage || activeIndex == -1) { - return - } - - if (e.key == 'ArrowRight') { - nextImage && nextImage() - } - - if (e.key == 'ArrowLeft') { - nextImage && previousImage() - } - - if (e.key == 'Escape' && presenting) { - setPresenting(false) - } - } - - document.addEventListener('keydown', keyDownEvent) - - return function cleanup() { - document.removeEventListener('keydown', keyDownEvent) - } - }) - const activeImage = media && activeIndex != -1 && media[activeIndex] const getPhotoElements = updateSidebar => { diff --git a/ui/src/components/photoGallery/presentView/PresentView.js b/ui/src/components/photoGallery/presentView/PresentView.js index dacb8c40..1650188c 100644 --- a/ui/src/components/photoGallery/presentView/PresentView.js +++ b/ui/src/components/photoGallery/presentView/PresentView.js @@ -1,5 +1,5 @@ +import React, { useEffect } from 'react' import PropTypes from 'prop-types' -import React from 'react' import styled, { createGlobalStyle } from 'styled-components' import PresentNavigationOverlay from './PresentNavigationOverlay' import PresentMedia from './PresentMedia' @@ -28,14 +28,43 @@ const PresentView = ({ nextImage, previousImage, setPresenting, -}) => ( - - - - - - -) +}) => { + useEffect(() => { + const keyDownEvent = e => { + if (e.key == 'ArrowRight') { + nextImage && nextImage() + e.stopPropagation() + } + + if (e.key == 'ArrowLeft') { + nextImage && previousImage() + e.stopPropagation() + } + + if (e.key == 'Escape') { + setPresenting(false) + e.stopPropagation() + } + } + + document.addEventListener('keydown', keyDownEvent) + + return function cleanup() { + document.removeEventListener('keydown', keyDownEvent) + } + }) + + return ( + + + + + + + ) +} PresentView.propTypes = { media: PropTypes.object.isRequired,