1
Fork 0

Merge pull request #90 from viktorstrate/geographic-map-page

Geographic map page
This commit is contained in:
Viktor Strate Kløvedal 2020-09-27 20:54:15 +02:00 committed by GitHub
commit 54bfc3d480
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 941 additions and 80 deletions

View File

@ -14,6 +14,10 @@ SERVE_UI=0
# When SERVE_UI is 1, PUBLIC_ENDPOINT is used instead of API_ENDPOINT and UI_ENDPOINT # When SERVE_UI is 1, PUBLIC_ENDPOINT is used instead of API_ENDPOINT and UI_ENDPOINT
#PUBLIC_ENDPOINT=http://localhost:4001/ #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=<insert mapbox token here>
# Set to 1 to set server in development mode, this enables graphql playground # Set to 1 to set server in development mode, this enables graphql playground
# Remove this if running in production # Remove this if running in production
DEVELOPMENT=1 DEVELOPMENT=1

View File

@ -144,9 +144,12 @@ type ComplexityRoot struct {
Query struct { Query struct {
Album func(childComplexity int, id int) int Album func(childComplexity int, id int) int
MapboxToken func(childComplexity int) int
Media func(childComplexity int, id 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 MyAlbums func(childComplexity int, filter *models.Filter, onlyRoot *bool, showEmpty *bool, onlyWithFavorites *bool) int
MyMedia func(childComplexity int, filter *models.Filter) int MyMedia func(childComplexity int, filter *models.Filter) int
MyMediaGeoJSON func(childComplexity int) int
MyUser func(childComplexity int) int MyUser func(childComplexity int) int
Search func(childComplexity int, query string, limitMedia *int, limitAlbums *int) int Search func(childComplexity int, query string, limitMedia *int, limitAlbums *int) int
ShareToken func(childComplexity int, token string, password *string) int ShareToken func(childComplexity int, token string, password *string) int
@ -255,6 +258,9 @@ type QueryResolver interface {
Album(ctx context.Context, id int) (*models.Album, error) Album(ctx context.Context, id int) (*models.Album, error)
MyMedia(ctx context.Context, filter *models.Filter) ([]*models.Media, error) MyMedia(ctx context.Context, filter *models.Filter) ([]*models.Media, error)
Media(ctx context.Context, id int) (*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) ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error)
ShareTokenValidatePassword(ctx context.Context, token string, password *string) (bool, error) ShareTokenValidatePassword(ctx context.Context, token string, password *string) (bool, error)
Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error) Search(ctx context.Context, query string, limitMedia *int, limitAlbums *int) (*models.SearchResult, error)
@ -846,6 +852,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.Album(childComplexity, args["id"].(int)), true 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": case "Query.media":
if e.complexity.Query.Media == nil { if e.complexity.Query.Media == nil {
break break
@ -858,6 +871,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.Media(childComplexity, args["id"].(int)), true 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": case "Query.myAlbums":
if e.complexity.Query.MyAlbums == nil { if e.complexity.Query.MyAlbums == nil {
break break
@ -882,6 +907,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.MyMedia(childComplexity, args["filter"].(*models.Filter)), true 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": case "Query.myUser":
if e.complexity.Query.MyUser == nil { if e.complexity.Query.MyUser == nil {
break break
@ -1252,6 +1284,7 @@ var sources = []*ast.Source{
{Name: "graphql/schema.graphql", Input: `directive @isAdmin on FIELD_DEFINITION {Name: "graphql/schema.graphql", Input: `directive @isAdmin on FIELD_DEFINITION
scalar Time scalar Time
scalar Any
enum OrderDirection { enum OrderDirection {
ASC ASC
@ -1291,6 +1324,14 @@ type Query {
"Get media by id, user must own the media or be admin" "Get media by id, user must own the media or be admin"
media(id: Int!): Media! 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"
mapboxToken: String
shareToken(token: String!, password: String): ShareToken! shareToken(token: String!, password: String): ShareToken!
shareTokenValidatePassword(token: String!, password: String): Boolean! shareTokenValidatePassword(token: String!, password: String): Boolean!
@ -1560,6 +1601,7 @@ func (ec *executionContext) field_Album_media_args(ctx context.Context, rawArgs
args["filter"] = arg0 args["filter"] = arg0
var arg1 *bool var arg1 *bool
if tmp, ok := rawArgs["onlyFavorites"]; ok { if tmp, ok := rawArgs["onlyFavorites"]; ok {
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyFavorites"))
arg1, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) arg1, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1986,6 +2028,21 @@ func (ec *executionContext) field_Query_album_args(ctx context.Context, rawArgs
return args, nil 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) { func (ec *executionContext) field_Query_media_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error var err error
args := map[string]interface{}{} args := map[string]interface{}{}
@ -2033,6 +2090,7 @@ func (ec *executionContext) field_Query_myAlbums_args(ctx context.Context, rawAr
args["showEmpty"] = arg2 args["showEmpty"] = arg2
var arg3 *bool var arg3 *bool
if tmp, ok := rawArgs["onlyWithFavorites"]; ok { if tmp, ok := rawArgs["onlyWithFavorites"]; ok {
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyWithFavorites"))
arg3, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) arg3, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp)
if err != nil { if err != nil {
return nil, err return nil, err
@ -4941,6 +4999,115 @@ 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) 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 {
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) { func (ec *executionContext) _Query_shareToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -8113,6 +8280,45 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
} }
return res 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) {
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": case "shareToken":
field := field field := field
out.Concurrently(i, func() (res graphql.Marshaler) { out.Concurrently(i, func() (res graphql.Marshaler) {
@ -8779,6 +8985,27 @@ func (ec *executionContext) marshalNAlbum2ᚖgithubᚗcomᚋviktorstrateᚋphoto
return ec._Album(ctx, sel, v) 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 { 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) return ec._AuthorizeResult(ctx, sel, &v)
} }
@ -8838,6 +9065,36 @@ func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.Selecti
return res 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 { 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) return ec._Media(ctx, sel, &v)
} }

View File

@ -60,6 +60,44 @@ func (r *queryResolver) Media(ctx context.Context, id int) (*models.Media, error
return media, nil 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 { type mediaResolver struct {
*Resolver *Resolver
} }

View File

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

View File

@ -1,6 +1,7 @@
directive @isAdmin on FIELD_DEFINITION directive @isAdmin on FIELD_DEFINITION
scalar Time scalar Time
scalar Any
enum OrderDirection { enum OrderDirection {
ASC ASC
@ -40,6 +41,14 @@ type Query {
"Get media by id, user must own the media or be admin" "Get media by id, user must own the media or be admin"
media(id: Int!): Media! 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"
mapboxToken: String
shareToken(token: String!, password: String): ShareToken! shareToken(token: String!, password: String): ShareToken!
shareTokenValidatePassword(token: String!, password: String): Boolean! shareTokenValidatePassword(token: String!, password: String): Boolean!

View File

@ -31,6 +31,11 @@ services:
# change this value to http://example.com/ # change this value to http://example.com/
- PUBLIC_ENDPOINT=http://localhost:8000/ - 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=<YOUR TOKEN HERE>
volumes: volumes:
- api_cache:/app/cache - api_cache:/app/cache

View File

@ -47,6 +47,7 @@
"eslint-plugin-react-hooks": "^4.1.2", "eslint-plugin-react-hooks": "^4.1.2",
"husky": "^4.3.0", "husky": "^4.3.0",
"lint-staged": "^10.4.0", "lint-staged": "^10.4.0",
"mapbox-gl": "^1.12.0",
"parcel-plugin-sw-cache": "^0.3.1", "parcel-plugin-sw-cache": "^0.3.1",
"prettier": "^2.1.2", "prettier": "^2.1.2",
"react-router-prop-types": "^1.0.5" "react-router-prop-types": "^1.0.5"

View File

@ -4,13 +4,13 @@ import styled from 'styled-components'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { Icon } from 'semantic-ui-react' import { Icon } from 'semantic-ui-react'
import Sidebar from './components/sidebar/Sidebar' import Sidebar from './components/sidebar/Sidebar'
import { Query } from 'react-apollo' import { useQuery } from 'react-apollo'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { Authorized } from './AuthorizedRoute' import { Authorized } from './AuthorizedRoute'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import Header from './components/header/Header' import Header from './components/header/Header'
const adminQuery = gql` const ADMIN_QUERY = gql`
query adminQuery { query adminQuery {
myUser { myUser {
admin admin
@ -18,6 +18,12 @@ const adminQuery = gql`
} }
` `
const MAPBOX_QUERY = gql`
query mapboxEnabledQuery {
mapboxToken
}
`
const Container = styled.div` const Container = styled.div`
height: 100%; height: 100%;
display: flex; display: flex;
@ -88,50 +94,58 @@ const SideButtonLabel = styled.div`
font-size: 16px; font-size: 16px;
` `
const Layout = ({ children, title }) => ( const Layout = ({ children, title }) => {
<Container> const adminQuery = useQuery(ADMIN_QUERY)
<Helmet> const mapboxQuery = useQuery(MAPBOX_QUERY)
<title>{title ? `${title} - Photoview` : `Photoview`}</title>
</Helmet>
<Authorized>
<SideMenu>
<SideButton to="/photos" exact>
<Icon name="image outline" />
<SideButtonLabel>Photos</SideButtonLabel>
</SideButton>
<SideButton to="/albums" exact>
<Icon name="images outline" />
<SideButtonLabel>Albums</SideButtonLabel>
</SideButton>
<Query query={adminQuery}>
{({ loading, error, data }) => {
if (data && data.myUser && data.myUser.admin) {
return (
<SideButton to="/settings" exact>
<Icon name="settings" />
<SideButtonLabel>Settings</SideButtonLabel>
</SideButton>
)
}
return null const isAdmin =
}} adminQuery.data && adminQuery.data.myUser && adminQuery.data.myUser.admin
</Query>
<SideButton to="/logout"> const mapboxEnabled = mapboxQuery.data && mapboxQuery.data.mapboxToken != null
<Icon name="lock" />
<SideButtonLabel>Log out</SideButtonLabel> return (
</SideButton> <Container>
</SideMenu> <Helmet>
</Authorized> <title>{title ? `${title} - Photoview` : `Photoview`}</title>
<Sidebar> </Helmet>
<Content id="layout-content"> <Authorized>
{children} <SideMenu>
<div style={{ height: 24 }}></div> <SideButton to="/photos" exact>
</Content> <Icon name="image outline" />
</Sidebar> <SideButtonLabel>Photos</SideButtonLabel>
<Header /> </SideButton>
</Container> <SideButton to="/albums" exact>
) <Icon name="images outline" />
<SideButtonLabel>Albums</SideButtonLabel>
</SideButton>
{mapboxEnabled ? (
<SideButton to="/places" exact>
<Icon name="map outline" />
<SideButtonLabel>Places</SideButtonLabel>
</SideButton>
) : null}
{isAdmin ? (
<SideButton to="/settings" exact>
<Icon name="settings" />
<SideButtonLabel>Settings</SideButtonLabel>
</SideButton>
) : null}
<SideButton to="/logout">
<Icon name="lock" />
<SideButtonLabel>Log out</SideButtonLabel>
</SideButton>
</SideMenu>
</Authorized>
<Sidebar>
<Content id="layout-content">
{children}
<div style={{ height: 24 }}></div>
</Content>
</Sidebar>
<Header />
</Container>
)
}
Layout.propTypes = { Layout.propTypes = {
children: PropTypes.any.isRequired, children: PropTypes.any.isRequired,

View File

@ -0,0 +1,80 @@
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;
cursor: pointer;
`
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,
cluster_id,
media_id,
setPresentMarker,
}) => {
const thumbnail = JSON.parse(thumbJson)
const presentMedia = () => {
setPresentMarker({
cluster: !!cluster,
id: cluster ? cluster_id : media_id,
})
}
return (
<Wrapper onClick={presentMedia}>
<PopupImage src={imagePopupSrc} />
<ThumbnailImage src={thumbnail.url} />
{cluster && (
<PointCountCircle>{point_count_abbreviated}</PointCountCircle>
)}
</Wrapper>
)
}
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

View File

@ -0,0 +1,114 @@
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($mediaIDs: [Int!]!) {
mediaList(ids: $mediaIDs) {
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 [mediaMarkers, setMediaMarkers] = useState(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [loadMedia, { data: loadedMedia }] = useLazyQuery(QUERY_MEDIA)
useEffect(() => {
if (presentMarker == null || map == null) {
setMediaMarkers(null)
return
}
getMediaFromMarker(map, presentMarker).then(setMediaMarkers)
}, [presentMarker])
useEffect(() => {
if (!mediaMarkers) return
setCurrentIndex(0)
loadMedia({
variables: {
mediaIDs: mediaMarkers.map(x => x.media_id),
},
})
}, [mediaMarkers])
if (presentMarker == null || map == null) {
return null
}
if (loadedMedia == null) {
return null
}
return (
<PresentView
media={loadedMedia.mediaList[currentIndex]}
nextImage={() => {
setCurrentIndex(i => Math.min(mediaMarkers.length - 1, i + 1))
}}
previousImage={() => {
setCurrentIndex(i => Math.max(0, i - 1))
}}
setPresenting={presenting => {
if (!presenting) {
setCurrentIndex(0)
setPresentMarker(null)
}
}}
/>
)
}
MapPresentMarker.propTypes = {
map: PropTypes.object,
presentMarker: PropTypes.object,
setPresentMarker: PropTypes.func.isRequired,
}
export default MapPresentMarker

View File

@ -0,0 +1,126 @@
import React, { useEffect, useRef, useState } from 'react'
import { useQuery } from 'react-apollo'
import gql from 'graphql-tag'
import styled from 'styled-components'
import 'mapbox-gl/dist/mapbox-gl.css'
import Layout from '../../Layout'
import { makeUpdateMarkers } from './mapboxHelperFunctions'
import MapPresentMarker from './MapPresentMarker'
const MapWrapper = styled.div`
width: 100%;
height: calc(100% - 24px);
`
const MapContainer = styled.div`
width: 100%;
height: 100%;
`
const MAPBOX_DATA_QUERY = gql`
query placePageMapboxToken {
mapboxToken
myMediaGeoJson
}
`
const MapPage = () => {
const [mapboxLibrary, setMapboxLibrary] = useState(null)
const [presentMarker, setPresentMarker] = 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')
setMapboxLibrary(mapbox)
}
loadMapboxLibrary()
}, [])
useEffect(() => {
if (
mapboxLibrary == null ||
mapContainer.current == null ||
mapboxData == null ||
map.current != null
) {
return
}
mapboxLibrary.accessToken = mapboxData.mapboxToken
map.current = new mapboxLibrary.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v11',
zoom: 1,
})
map.current.on('load', () => {
map.current.addSource('media', {
type: 'geojson',
data: mapboxData.myMediaGeoJson,
cluster: true,
clusterRadius: 50,
clusterProperties: {
thumbnail: ['coalesce', ['get', 'thumbnail'], false],
},
})
// Add dummy layer for features to be queryable
map.current.addLayer({
id: 'media-points',
type: 'circle',
source: 'media',
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()
})
}, [mapContainer, mapboxLibrary, mapboxData])
if (mapboxData && mapboxData.mapboxToken == null) {
return (
<Layout>
<h1>Mapbox token is not set</h1>
<p>
To use map related features a mapbox token is needed.
<br /> A mapbox token can be created for free at{' '}
<a href="https://account.mapbox.com/access-tokens/">mapbox.com</a>.
</p>
<p>
Make sure the access token is added as the MAPBOX_TOKEN environment
variable.
</p>
</Layout>
)
}
return (
<Layout>
<MapWrapper>
<MapContainer ref={mapContainer}></MapContainer>
</MapWrapper>
<MapPresentMarker
map={map.current}
presentMarker={presentMarker}
setPresentMarker={setPresentMarker}
/>
</Layout>
)
}
export default MapPage

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 56 68" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.875,0,0,1.0625,0,0)">
<g transform="matrix(1,0,0,0.970588,0,0)">
<g transform="matrix(0.857143,0,0,1.55496,8,-35.483)">
<g style="filter:url(#_Effect1);">
<ellipse cx="28" cy="61.172" rx="8" ry="0.828" style="fill-opacity:0.5;"/>
</g>
</g>
<g transform="matrix(1.14286,0,0,0.969697,0,0)">
<path d="M4,54C3.47,54 2.961,53.789 2.586,53.414C2.211,53.039 2,52.53 2,52C2,43.732 2,12.268 2,4C2,3.47 2.211,2.961 2.586,2.586C2.961,2.211 3.47,2 4,2L52,2C52.53,2 53.039,2.211 53.414,2.586C53.789,2.961 54,3.47 54,4L54,52C54,52.53 53.789,53.039 53.414,53.414C53.039,53.789 52.53,54 52,54C48.077,54 39.521,54 36.828,54C36.298,54 35.789,54.211 35.414,54.586C34.161,55.839 31.192,58.808 29.414,60.586C29.039,60.961 28.53,61.172 28,61.172C27.47,61.172 26.961,60.961 26.586,60.586C24.808,58.808 21.839,55.839 20.586,54.586C20.211,54.211 19.702,54 19.172,54C16.479,54 7.923,54 4,54Z" style="fill:white;"/>
</g>
</g>
</g>
<defs>
<filter id="_Effect1" filterUnits="userSpaceOnUse" x="8" y="54.7306" width="40" height="12.8819">
<feGaussianBlur in="SourceGraphic" stdDeviation="1.1983"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,50 @@
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
? `cluster_${props.cluster_id}`
: `media_${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(
<MapClusterMarker {...geojsonProps} setPresentMarker={setPresentMarker} />,
el
)
return el
}

View File

@ -9,6 +9,7 @@ const AlbumsPage = React.lazy(() => import('./Pages/AllAlbumsPage/AlbumsPage'))
const AlbumPage = React.lazy(() => import('./Pages/AlbumPage/AlbumPage')) const AlbumPage = React.lazy(() => import('./Pages/AlbumPage/AlbumPage'))
const AuthorizedRoute = React.lazy(() => import('./AuthorizedRoute')) const AuthorizedRoute = React.lazy(() => import('./AuthorizedRoute'))
const PhotosPage = React.lazy(() => import('./Pages/PhotosPage/PhotosPage')) 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 SharePage = React.lazy(() => import('./Pages/SharePage/SharePage'))
const LoginPage = React.lazy(() => import('./Pages/LoginPage/LoginPage')) const LoginPage = React.lazy(() => import('./Pages/LoginPage/LoginPage'))
@ -43,6 +44,7 @@ class Routes extends React.Component {
<AuthorizedRoute exact path="/albums" component={AlbumsPage} /> <AuthorizedRoute exact path="/albums" component={AlbumsPage} />
<AuthorizedRoute path="/album/:id/:subPage?" component={AlbumPage} /> <AuthorizedRoute path="/album/:id/:subPage?" component={AlbumPage} />
<AuthorizedRoute path="/photos/:subPage?" component={PhotosPage} /> <AuthorizedRoute path="/photos/:subPage?" component={PhotosPage} />
<AuthorizedRoute path="/places" component={PlacesPage} />
<AuthorizedRoute admin path="/settings" component={SettingsPage} /> <AuthorizedRoute admin path="/settings" component={SettingsPage} />
<Route path="/" exact render={() => <Redirect to="/photos" />} /> <Route path="/" exact render={() => <Redirect to="/photos" />} />
<Route render={() => <div>Page not found</div>} /> <Route render={() => <div>Page not found</div>} />

View File

@ -44,32 +44,6 @@ const PhotoGallery = ({
}) => { }) => {
const { updateSidebar } = useContext(SidebarContext) 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 activeImage = media && activeIndex != -1 && media[activeIndex]
const getPhotoElements = updateSidebar => { const getPhotoElements = updateSidebar => {

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React from 'react'
import styled, { createGlobalStyle } from 'styled-components' import styled, { createGlobalStyle } from 'styled-components'
import PresentNavigationOverlay from './PresentNavigationOverlay' import PresentNavigationOverlay from './PresentNavigationOverlay'
import PresentMedia from './PresentMedia' import PresentMedia from './PresentMedia'
@ -28,14 +28,43 @@ const PresentView = ({
nextImage, nextImage,
previousImage, previousImage,
setPresenting, setPresenting,
}) => ( }) => {
<StyledContainer {...className}> useEffect(() => {
<PreventScroll /> const keyDownEvent = e => {
<PresentNavigationOverlay {...{ nextImage, previousImage, setPresenting }}> if (e.key == 'ArrowRight') {
<PresentMedia media={media} imageLoaded={imageLoaded} /> nextImage && nextImage()
</PresentNavigationOverlay> e.stopPropagation()
</StyledContainer> }
)
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 (
<StyledContainer {...className}>
<PreventScroll />
<PresentNavigationOverlay
{...{ nextImage, previousImage, setPresenting }}
>
<PresentMedia media={media} imageLoaded={imageLoaded} />
</PresentNavigationOverlay>
</StyledContainer>
)
}
PresentView.propTypes = { PresentView.propTypes = {
media: PropTypes.object.isRequired, media: PropTypes.object.isRequired,