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..e513ea1d 100644
--- a/ui/src/Pages/AlbumPage/AlbumPage.js
+++ b/ui/src/Pages/AlbumPage/AlbumPage.js
@@ -4,7 +4,9 @@ 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'
+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 cbd423ca..db9eca24 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,25 @@ 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 f6197c87..094cfa29 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,10 +124,22 @@ 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: 50,
+ },
+ }
+ )
+
+ const { containerElem, finished: finishedLoadingMore } = useScrollPagination({
+ loading,
+ fetchMore,
+ data,
+ getItems: data => data.myTimeline,
})
useEffect(() => {
@@ -190,7 +203,14 @@ const TimelineGallery = () => {
onlyFavorites={onlyFavorites}
setOnlyFavorites={setOnlyFavorites}
/>
- {timelineGroups}
+ {timelineGroups}
+
+ Loading more media
+
{presenting && (
{
+ const observer = useRef(null)
+ const observerElem = useRef(null)
+ const [finished, setFinished] = useState(false)
+
+ const reconfigureIntersectionObserver = () => {
+ var options = {
+ root: null,
+ rootMargin: '-100% 0px 0px 0px',
+ threshold: 0,
+ }
+
+ // 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)) {
+ 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) {
+ 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, data, finished])
+
+ return {
+ containerElem,
+ finished,
+ }
+}
+
+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