From a9edb148f1ff99d10acce534ff501179d1a1d8d7 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 12 Feb 2021 17:44:32 +0100 Subject: [PATCH 1/2] Start on infinite scroll pagination --- api/graphql/generated.go | 38 ++++++++--- api/graphql/resolvers/timeline.go | 10 ++- api/graphql/schema.graphql | 2 +- ui/src/Pages/AlbumPage/AlbumPage.js | 2 +- ui/src/apolloClient.js | 10 ++- .../timelineGallery/TimelineGallery.js | 32 +++++++-- ui/src/hooks/useScrollPagination.js | 65 +++++++++++++++++++ .../{components => hooks}/useURLParameters.js | 0 8 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 ui/src/hooks/useScrollPagination.js rename ui/src/{components => hooks}/useURLParameters.js (100%) diff --git a/api/graphql/generated.go b/api/graphql/generated.go index bc1be1b9..1268a467 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -152,7 +152,7 @@ type ComplexityRoot struct { 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 - MyTimeline func(childComplexity int, onlyFavorites *bool) int + MyTimeline func(childComplexity int, limit *int, offset *int, onlyFavorites *bool) 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 @@ -272,7 +272,7 @@ type QueryResolver interface { 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) - MyTimeline(ctx context.Context, onlyFavorites *bool) ([]*models.TimelineGroup, error) + MyTimeline(ctx context.Context, limit *int, offset *int, onlyFavorites *bool) ([]*models.TimelineGroup, error) MyMediaGeoJSON(ctx context.Context) (interface{}, error) MapboxToken(ctx context.Context) (*string, error) ShareToken(ctx context.Context, token string, password *string) (*models.ShareToken, error) @@ -950,7 +950,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Query.MyTimeline(childComplexity, args["onlyFavorites"].(*bool)), true + return e.complexity.Query.MyTimeline(childComplexity, args["limit"].(*int), args["offset"].(*int), args["onlyFavorites"].(*bool)), true case "Query.myUser": if e.complexity.Query.MyUser == nil { @@ -1400,7 +1400,7 @@ type Query { "Get a list of media by their ids, user must own the media or be admin" mediaList(ids: [ID!]!): [Media!]! - myTimeline(onlyFavorites: Boolean): [TimelineGroup!]! + myTimeline(limit: Int, offset: Int, onlyFavorites: Boolean): [TimelineGroup!]! "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! @@ -2195,15 +2195,33 @@ func (ec *executionContext) field_Query_myMedia_args(ctx context.Context, rawArg func (ec *executionContext) field_Query_myTimeline_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - var arg0 *bool - if tmp, ok := rawArgs["onlyFavorites"]; ok { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyFavorites")) - arg0, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) + var arg0 *int + if tmp, ok := rawArgs["limit"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("limit")) + arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp) if err != nil { return nil, err } } - args["onlyFavorites"] = arg0 + args["limit"] = arg0 + var arg1 *int + if tmp, ok := rawArgs["offset"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("offset")) + arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp) + if err != nil { + return nil, err + } + } + args["offset"] = arg1 + var arg2 *bool + if tmp, ok := rawArgs["onlyFavorites"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("onlyFavorites")) + arg2, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) + if err != nil { + return nil, err + } + } + args["onlyFavorites"] = arg2 return args, nil } @@ -5231,7 +5249,7 @@ func (ec *executionContext) _Query_myTimeline(ctx context.Context, field graphql 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().MyTimeline(rctx, args["onlyFavorites"].(*bool)) + return ec.resolvers.Query().MyTimeline(rctx, args["limit"].(*int), args["offset"].(*int), args["onlyFavorites"].(*bool)) }) if err != nil { ec.Error(ctx, err) diff --git a/api/graphql/resolvers/timeline.go b/api/graphql/resolvers/timeline.go index 5fcdb3ed..ba8819a4 100644 --- a/api/graphql/resolvers/timeline.go +++ b/api/graphql/resolvers/timeline.go @@ -9,7 +9,7 @@ import ( "gorm.io/gorm" ) -func (r *queryResolver) MyTimeline(ctx context.Context, onlyFavorites *bool) ([]*models.TimelineGroup, error) { +func (r *queryResolver) MyTimeline(ctx context.Context, limit *int, offset *int, onlyFavorites *bool) ([]*models.TimelineGroup, error) { user := auth.UserFromContext(ctx) if user == nil { return nil, auth.ErrUnauthorized @@ -33,6 +33,14 @@ func (r *queryResolver) MyTimeline(ctx context.Context, onlyFavorites *bool) ([] daysQuery.Where("media.id IN (?)", tx.Table("user_media_data").Select("user_media_data.media_id").Where("user_media_data.user_id = ?", user.ID).Where("user_media_data.favorite = 1")) } + if limit != nil { + daysQuery.Limit(*limit) + } + + if offset != nil { + daysQuery.Offset(*offset) + } + rows, err := daysQuery.Group("albums.id, YEAR(media.date_shot), MONTH(media.date_shot), DAY(media.date_shot)"). Order("media.date_shot DESC"). Rows() diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index aac782d4..4317c95b 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -44,7 +44,7 @@ type Query { "Get a list of media by their ids, user must own the media or be admin" mediaList(ids: [ID!]!): [Media!]! - myTimeline(onlyFavorites: Boolean): [TimelineGroup!]! + myTimeline(limit: Int, offset: Int, onlyFavorites: Boolean): [TimelineGroup!]! "Get media owned by the logged in user, returned in GeoJson format" myMediaGeoJson: Any! diff --git a/ui/src/Pages/AlbumPage/AlbumPage.js b/ui/src/Pages/AlbumPage/AlbumPage.js index 22fd6876..157e7124 100644 --- a/ui/src/Pages/AlbumPage/AlbumPage.js +++ b/ui/src/Pages/AlbumPage/AlbumPage.js @@ -4,7 +4,7 @@ import { useQuery, gql } from '@apollo/client' import AlbumGallery from '../../components/albumGallery/AlbumGallery' import PropTypes from 'prop-types' import Layout from '../../Layout' -import useURLParameters from '../../components/useURLParameters' +import useURLParameters from '../../hooks/useURLParameters' const albumQuery = gql` query albumQuery( diff --git a/ui/src/apolloClient.js b/ui/src/apolloClient.js index cbd423ca..f56eb780 100644 --- a/ui/src/apolloClient.js +++ b/ui/src/apolloClient.js @@ -5,7 +5,10 @@ import { ApolloLink, HttpLink, } from '@apollo/client' -import { getMainDefinition } from '@apollo/client/utilities' +import { + getMainDefinition, + offsetLimitPagination, +} from '@apollo/client/utilities' import { onError } from '@apollo/client/link/error' import { WebSocketLink } from '@apollo/client/link/ws' @@ -111,6 +114,11 @@ const memoryCache = new InMemoryCache({ SiteInfo: { merge: true, }, + Query: { + fields: { + myTimeline: offsetLimitPagination(['onlyFavorites']), + }, + }, }, }) diff --git a/ui/src/components/timelineGallery/TimelineGallery.js b/ui/src/components/timelineGallery/TimelineGallery.js index f6197c87..870c6f55 100644 --- a/ui/src/components/timelineGallery/TimelineGallery.js +++ b/ui/src/components/timelineGallery/TimelineGallery.js @@ -5,12 +5,13 @@ import TimelineGroupDate from './TimelineGroupDate' import styled from 'styled-components' import PresentView from '../photoGallery/presentView/PresentView' import { Loader } from 'semantic-ui-react' -import useURLParameters from '../useURLParameters' +import useURLParameters from '../../hooks/useURLParameters' import { FavoritesCheckbox } from '../AlbumFilter' +import useScrollPagination from '../../hooks/useScrollPagination' const MY_TIMELINE_QUERY = gql` - query myTimeline($onlyFavorites: Boolean) { - myTimeline(onlyFavorites: $onlyFavorites) { + query myTimeline($onlyFavorites: Boolean, $limit: Int, $offset: Int) { + myTimeline(onlyFavorites: $onlyFavorites, limit: $limit, offset: $offset) { album { id title @@ -123,9 +124,26 @@ const TimelineGallery = () => { }) }, [activeIndex]) - const { data, error, loading, refetch } = useQuery(MY_TIMELINE_QUERY, { - variables: { - onlyFavorites, + const { data, error, loading, refetch, fetchMore } = useQuery( + MY_TIMELINE_QUERY, + { + variables: { + onlyFavorites, + offset: 0, + limit: 10, + }, + } + ) + + const { containerElem } = useScrollPagination({ + loading, + fetchMore: () => { + console.log('fetching more') + fetchMore({ + variables: { + offset: data.myTimeline.length, + }, + }) }, }) @@ -190,7 +208,7 @@ const TimelineGallery = () => { onlyFavorites={onlyFavorites} setOnlyFavorites={setOnlyFavorites} /> - {timelineGroups} + {timelineGroups} {presenting && ( { + const observer = useRef(null) + const observerElem = useRef(null) + + const reconfigureIntersectionObserver = () => { + var options = { + root: null, + rootMargin: '-100% 0px 0px 0px', + threshold: 0, + } + + // delete old observer + if (observer.current) observer.current.disconnect() + + // configure new observer + observer.current = new IntersectionObserver(entities => { + console.log('Observing', entities) + if (entities.find(x => x.isIntersecting == false)) { + console.log('load more') + fetchMore() + } + }, options) + + // activate new observer + if (observerElem.current && !loading) + observer.current.observe(observerElem.current) + } + + const containerElem = useCallback(node => { + observerElem.current = node + + // cleanup + if (observer.current != null) { + observer.current.disconnect() + } + + if (node != null) { + reconfigureIntersectionObserver() + } + }, []) + + // only observe when not loading + useEffect(() => { + if (observer.current != null) { + if (loading) { + observer.current.unobserve(observerElem.current) + } else { + observer.current.observe(observerElem.current) + } + } + }, [loading]) + + // reconfigure observer if fetchMore function changes + useEffect(() => { + reconfigureIntersectionObserver() + }, [fetchMore]) + + return { + containerElem, + } +} + +export default useScrollPagination diff --git a/ui/src/components/useURLParameters.js b/ui/src/hooks/useURLParameters.js similarity index 100% rename from ui/src/components/useURLParameters.js rename to ui/src/hooks/useURLParameters.js From 1dbf4e482134047acb7e99892ceeb5c729625bcd Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 12 Feb 2021 23:57:15 +0100 Subject: [PATCH 2/2] Add pagination for albums + add loading indicators --- ui/src/Pages/AlbumPage/AlbumPage.js | 25 +- ui/src/apolloClient.js | 14 ++ .../components/albumGallery/AlbumGallery.js | 235 +++++++++--------- .../timelineGallery/TimelineGallery.js | 22 +- ui/src/hooks/useScrollPagination.js | 28 ++- 5 files changed, 192 insertions(+), 132 deletions(-) diff --git a/ui/src/Pages/AlbumPage/AlbumPage.js b/ui/src/Pages/AlbumPage/AlbumPage.js index 157e7124..e513ea1d 100644 --- a/ui/src/Pages/AlbumPage/AlbumPage.js +++ b/ui/src/Pages/AlbumPage/AlbumPage.js @@ -5,6 +5,8 @@ import AlbumGallery from '../../components/albumGallery/AlbumGallery' import PropTypes from 'prop-types' import Layout from '../../Layout' import useURLParameters from '../../hooks/useURLParameters' +import useScrollPagination from '../../hooks/useScrollPagination' +import { Loader } from 'semantic-ui-react' const albumQuery = gql` query albumQuery( @@ -12,6 +14,8 @@ const albumQuery = gql` $onlyFavorites: Boolean $mediaOrderBy: String $mediaOrderDirection: OrderDirection + $limit: Int + $offset: Int ) { album(id: $id) { id @@ -27,6 +31,8 @@ const albumQuery = gql` } media( filter: { + limit: $limit + offset: $offset order_by: $mediaOrderBy order_direction: $mediaOrderDirection } @@ -80,15 +86,24 @@ function AlbumPage({ match }) { [setParams] ) - const { loading, error, data, refetch } = useQuery(albumQuery, { + const { loading, error, data, refetch, fetchMore } = useQuery(albumQuery, { variables: { id: albumId, onlyFavorites, mediaOrderBy: orderBy, mediaOrderDirection: orderDirection, + offset: 0, + limit: 200, }, }) + const { containerElem, finished: finishedLoadingMore } = useScrollPagination({ + loading, + fetchMore, + data, + getItems: data => data.album.media, + }) + const toggleFavorites = useCallback( onlyFavorites => { if ( @@ -115,6 +130,7 @@ function AlbumPage({ match }) { return ( + + Loading more media + ) } diff --git a/ui/src/apolloClient.js b/ui/src/apolloClient.js index f56eb780..db9eca24 100644 --- a/ui/src/apolloClient.js +++ b/ui/src/apolloClient.js @@ -114,6 +114,20 @@ const memoryCache = new InMemoryCache({ SiteInfo: { merge: true, }, + MediaURL: { + keyFields: ['url'], + }, + Album: { + fields: { + media: { + keyArgs: ['onlyFavorites'], + merge(existing = [], incoming) { + console.log('merge media', existing, incoming) + return [...existing, ...incoming] + }, + }, + }, + }, Query: { fields: { myTimeline: offsetLimitPagination(['onlyFavorites']), diff --git a/ui/src/components/albumGallery/AlbumGallery.js b/ui/src/components/albumGallery/AlbumGallery.js index 4bed3351..e4d7b89a 100644 --- a/ui/src/components/albumGallery/AlbumGallery.js +++ b/ui/src/components/albumGallery/AlbumGallery.js @@ -5,124 +5,129 @@ import PhotoGallery from '../photoGallery/PhotoGallery' import AlbumBoxes from './AlbumBoxes' import AlbumFilter from '../AlbumFilter' -const AlbumGallery = ({ - album, - loading = false, - customAlbumLink, - showFilter = false, - setOnlyFavorites, - setOrdering, - ordering, - onlyFavorites = false, - onFavorite, -}) => { - const [imageState, setImageState] = useState({ - activeImage: -1, - presenting: false, - }) +const AlbumGallery = React.forwardRef( + ( + { + album, + loading = false, + customAlbumLink, + showFilter = false, + setOnlyFavorites, + setOrdering, + ordering, + onlyFavorites = false, + onFavorite, + }, + ref + ) => { + const [imageState, setImageState] = useState({ + activeImage: -1, + presenting: false, + }) - const setPresenting = presenting => - setImageState(state => ({ ...state, presenting })) + const setPresenting = presenting => + setImageState(state => ({ ...state, presenting })) - const setPresentingWithHistory = presenting => { - setPresenting(presenting) - if (presenting) { - history.pushState({ imageState }, '') - } else { - history.back() - } - } - - const updateHistory = imageState => { - history.replaceState({ imageState }, '') - return imageState - } - - const setActiveImage = activeImage => { - setImageState(state => updateHistory({ ...state, activeImage })) - } - - const nextImage = () => { - setActiveImage((imageState.activeImage + 1) % album.media.length) - } - - const previousImage = () => { - if (imageState.activeImage <= 0) { - setActiveImage(album.media.length - 1) - } else { - setActiveImage(imageState.activeImage - 1) - } - } - - useEffect(() => { - const updateImageState = event => { - setImageState(event.state.imageState) - } - window.addEventListener('popstate', updateImageState) - - return () => { - window.removeEventListener('popstate', updateImageState) - } - }, [imageState]) - - useEffect(() => { - setActiveImage(-1) - }, [album]) - - let subAlbumElement = null - - if (album) { - if (album.subAlbums.length > 0) { - subAlbumElement = ( - - ) - } - } else { - subAlbumElement = - } - - return ( - <> - - {showFilter && ( - - )} - {subAlbumElement} - { -

0 ? 'block' : 'none', - }} - > - Images -

+ const setPresentingWithHistory = presenting => { + setPresenting(presenting) + if (presenting) { + history.pushState({ imageState }, '') + } else { + history.back() } - { - setActiveImage(index) - }} - onFavorite={onFavorite} - setPresenting={setPresentingWithHistory} - nextImage={nextImage} - previousImage={previousImage} - /> - - ) -} + } + + const updateHistory = imageState => { + history.replaceState({ imageState }, '') + return imageState + } + + const setActiveImage = activeImage => { + setImageState(state => updateHistory({ ...state, activeImage })) + } + + const nextImage = () => { + setActiveImage((imageState.activeImage + 1) % album.media.length) + } + + const previousImage = () => { + if (imageState.activeImage <= 0) { + setActiveImage(album.media.length - 1) + } else { + setActiveImage(imageState.activeImage - 1) + } + } + + useEffect(() => { + const updateImageState = event => { + setImageState(event.state.imageState) + } + window.addEventListener('popstate', updateImageState) + + return () => { + window.removeEventListener('popstate', updateImageState) + } + }, [imageState]) + + useEffect(() => { + setActiveImage(-1) + }, [album]) + + let subAlbumElement = null + + if (album) { + if (album.subAlbums.length > 0) { + subAlbumElement = ( + + ) + } + } else { + subAlbumElement = + } + + return ( +
+ + {showFilter && ( + + )} + {subAlbumElement} + { +

0 ? 'block' : 'none', + }} + > + Images +

+ } + { + setActiveImage(index) + }} + onFavorite={onFavorite} + setPresenting={setPresentingWithHistory} + nextImage={nextImage} + previousImage={previousImage} + /> +
+ ) + } +) AlbumGallery.propTypes = { album: PropTypes.object, diff --git a/ui/src/components/timelineGallery/TimelineGallery.js b/ui/src/components/timelineGallery/TimelineGallery.js index 870c6f55..094cfa29 100644 --- a/ui/src/components/timelineGallery/TimelineGallery.js +++ b/ui/src/components/timelineGallery/TimelineGallery.js @@ -130,21 +130,16 @@ const TimelineGallery = () => { variables: { onlyFavorites, offset: 0, - limit: 10, + limit: 50, }, } ) - const { containerElem } = useScrollPagination({ + const { containerElem, finished: finishedLoadingMore } = useScrollPagination({ loading, - fetchMore: () => { - console.log('fetching more') - fetchMore({ - variables: { - offset: data.myTimeline.length, - }, - }) - }, + fetchMore, + data, + getItems: data => data.myTimeline, }) useEffect(() => { @@ -209,6 +204,13 @@ const TimelineGallery = () => { setOnlyFavorites={setOnlyFavorites} /> {timelineGroups} + + Loading more media + {presenting && ( { +const useScrollPagination = ({ loading, fetchMore, data, getItems }) => { const observer = useRef(null) const observerElem = useRef(null) + const [finished, setFinished] = useState(false) const reconfigureIntersectionObserver = () => { var options = { @@ -14,18 +15,32 @@ const useScrollPagination = ({ loading, fetchMore }) => { // delete old observer if (observer.current) observer.current.disconnect() + if (finished) return + // configure new observer observer.current = new IntersectionObserver(entities => { console.log('Observing', entities) if (entities.find(x => x.isIntersecting == false)) { - console.log('load more') - fetchMore() + let itemCount = getItems(data).length + console.log('load more', itemCount) + fetchMore({ + variables: { + offset: itemCount, + }, + }).then(result => { + const newItemCount = getItems(result.data).length + console.log('then', result, itemCount, newItemCount) + if (newItemCount == 0) { + setFinished(true) + } + }) } }, options) // activate new observer - if (observerElem.current && !loading) + if (observerElem.current && !loading) { observer.current.observe(observerElem.current) + } } const containerElem = useCallback(node => { @@ -55,10 +70,11 @@ const useScrollPagination = ({ loading, fetchMore }) => { // reconfigure observer if fetchMore function changes useEffect(() => { reconfigureIntersectionObserver() - }, [fetchMore]) + }, [fetchMore, data, finished]) return { containerElem, + finished, } }