diff --git a/frontend/src/App.scss b/frontend/src/App.scss index 928fb9c..fdf9a84 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -7,6 +7,7 @@ left: 0; right: 0; overflow-x: hidden; + overflow: hidden; } .loadingWrapper { diff --git a/frontend/src/Home/Home.scss b/frontend/src/Home/Home.scss index dffc8b1..e0a8174 100644 --- a/frontend/src/Home/Home.scss +++ b/frontend/src/Home/Home.scss @@ -3,15 +3,15 @@ .viewComponent { position: absolute; max-width: 100%; - width: 80%; + width: 100%; left: 0; right: 0; - top: -$pt-navbar-height; bottom: 0; margin-left: auto; margin-right: auto; - padding-top: 2 * $pt-navbar-height + 20px; + padding-top: $pt-navbar-height; max-height: 100%; + height: 100%; } #mainContainer { diff --git a/frontend/src/Photos/Overview.scss b/frontend/src/Photos/Overview.scss index 090077d..95e1522 100644 --- a/frontend/src/Photos/Overview.scss +++ b/frontend/src/Photos/Overview.scss @@ -26,52 +26,71 @@ opacity: 0; } +#overviewContainer { + padding-top: 2rem; + width: 100%; + height: 100%; + overflow: auto; -#overview { - display: flex; - flex-direction: column; + #overview { + width: 80%; + height: 100%; + margin-left: auto; + margin-right: auto; - #actionbar { - display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: center; - } - - .list { - display: flex; - flex-shrink: 0; - flex-grow: 0; - flex-wrap: wrap; - - // 400px is the minimal width for 2 cards to fit - @media (max-width: 400px) { + .photosLoader { + display: flex; + align-items: center; justify-content: center; + flex-direction: column; + padding-top: 5rem; + padding-bottom: 5rem; } - .photoCard { + #actionbar { display: flex; + flex-direction: column; + align-items: flex-end; justify-content: center; - align-items: center; - transition: 0.3s; - user-select: none; - height: 15rem; - width: 20rem; - margin: 1rem; - padding: 0rem; - overflow: hidden; + width: 100%; + } - img { - min-height: 100%; - min-width: 100%; - max-width: 100%; - max-height: 100%; - width: auto; - height: auto; - object-fit: cover; + .list { + display: flex; + flex-shrink: 0; + flex-grow: 0; + flex-wrap: wrap; + + // 400px is the minimal width for 2 cards to fit + @media (max-width: 400px) { + justify-content: center; + } + + .photoCard { + display: flex; + justify-content: center; + align-items: center; + transition: 0.3s; + user-select: none; + height: 15rem; + width: 20rem; + margin: 1rem; + padding: 0rem; + overflow: hidden; + + img { + min-height: 100%; + min-width: 100%; + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: cover; + } } } } + } .bp3-dark { diff --git a/frontend/src/Photos/Overview.tsx b/frontend/src/Photos/Overview.tsx index 6d9a743..71af9aa 100644 --- a/frontend/src/Photos/Overview.tsx +++ b/frontend/src/Photos/Overview.tsx @@ -7,13 +7,14 @@ import { photosLoadStart } from "~redux/photos/actions"; import { IPhotoReqJSON } from "~../../src/entity/Photo"; import { LoadingStub } from "~LoadingStub"; import { PhotoCard } from "./PhotoCard"; -import { Button, Classes, Overlay } from "@blueprintjs/core"; +import { Button, Classes, Overlay, Spinner } from "@blueprintjs/core"; import { UploadButton } from "./UploadButton"; import { Photo } from "./Photo"; export interface IOverviewComponentProps { - photos: IPhotoReqJSON[] | null; - overviewLoaded: boolean; + photos: IPhotoReqJSON[]; + triedLoading: boolean; + allPhotosLoaded: boolean; overviewFetching: boolean; overviewFetchingError: string | null; overviewFetchingSpinner: boolean; @@ -27,27 +28,38 @@ export const OverviewComponent: React.FunctionComponent const [selectedPhoto, setSelectedPhoto] = React.useState(0); const [isOverlayOpened, setOverlayOpen] = React.useState(false); - if (!props.overviewLoaded && !props.overviewFetching) { - props.fetchPhotos(); - } - if (!props.photos) { - return ; - } - const onCardClick = (id: number) => { setSelectedPhoto(id); setOverlayOpen(true); }; - const photos = props.photos - .sort((a, b) => b.shotAt - a.shotAt) - .map((photo) => ( - onCardClick(photo.id)} - /> - )); + if ( + props.photos.length === 0 && + !props.triedLoading && + !props.overviewFetching + ) { + props.fetchPhotos(); + } + + const photos = props.photos.map((photo) => ( + onCardClick(photo.id)} + /> + )); + + function onLoaderScroll(e: React.UIEvent) { + if ( + e.currentTarget.scrollTop + e.currentTarget.clientHeight >= + e.currentTarget.scrollHeight + ) { + console.log(props.allPhotosLoaded, props.overviewFetching); + if (!props.allPhotosLoaded && !props.overviewFetching) { + props.fetchPhotos(); + } + } + } return ( <> @@ -65,11 +77,16 @@ export const OverviewComponent: React.FunctionComponent -
-
- +
+
+
+ +
+
{photos}
+
+ {props.overviewFetching && } +
-
{photos}
); @@ -78,10 +95,11 @@ export const OverviewComponent: React.FunctionComponent function mapStateToProps(state: IAppState) { return { photos: state.photos.photos, - overviewLoaded: state.photos.overviewLoaded, + allPhotosLoaded: state.photos.allPhotosLoaded, overviewFetching: state.photos.overviewFetching, overviewFetchingError: state.photos.overviewFetchingError, overviewFetchingSpinner: state.photos.overviewFetchingSpinner, + triedLoading: state.photos.triedLoading, }; } diff --git a/frontend/src/Photos/PhotoLoader.tsx b/frontend/src/Photos/PhotoLoader.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/redux/api/photos.ts b/frontend/src/redux/api/photos.ts index 5a52775..64551e0 100644 --- a/frontend/src/redux/api/photos.ts +++ b/frontend/src/redux/api/photos.ts @@ -19,8 +19,15 @@ export function getPhotoThumbPath(photo: IPhotoReqJSON, size: number): string { }?size=${size.toString()}`; } -export async function fetchPhotosList(): Promise { - return fetchJSONAuth("/photos/list", "GET"); +export async function fetchPhotosList( + skip: number, + num: number, +): Promise { + const params = new URLSearchParams({ + skip: skip.toString(), + num: num.toString(), + }); + return fetchJSONAuth(`/photos/list?${params.toString()}`, "GET"); } export async function fetchPhoto(id: number): Promise { diff --git a/frontend/src/redux/photos/reducer.ts b/frontend/src/redux/photos/reducer.ts index 4bad575..8bb96ad 100644 --- a/frontend/src/redux/photos/reducer.ts +++ b/frontend/src/redux/photos/reducer.ts @@ -9,12 +9,13 @@ export interface IPhotoState { } export interface IPhotosState { - photos: IPhotoReqJSON[] | null; + photos: IPhotoReqJSON[]; photoStates: Record; overviewFetching: boolean; - overviewLoaded: boolean; + allPhotosLoaded: boolean; + triedLoading: boolean; overviewFetchingError: string | null; overviewFetchingSpinner: boolean; @@ -27,9 +28,10 @@ export interface IPhotosState { } const defaultPhotosState: IPhotosState = { - photos: null, - overviewLoaded: false, + photos: [], + allPhotosLoaded: false, overviewFetching: false, + triedLoading: false, overviewFetchingError: null, overviewFetchingSpinner: false, @@ -52,21 +54,31 @@ export const photosReducer: Reducer = ( return defaultPhotosState; case PhotoTypes.PHOTOS_LOAD_START: return { - ...defaultPhotosState, + ...state, overviewFetching: true, + triedLoading: true, overviewFetchingSpinner: false, }; case PhotoTypes.PHOTOS_START_FETCHING_SPINNER: return { ...state, overviewFetchingSpinner: true }; - case PhotoTypes.PHOTOS_LOAD_SUCCESS: + case PhotoTypes.PHOTOS_LOAD_SUCCESS: { + let { allPhotosLoaded } = state; + if (action.photos.length === 0) { + allPhotosLoaded = true; + } + const oldPhotos = state.photos ? state.photos : []; return { - ...defaultPhotosState, - photos: action.photos, - overviewLoaded: true, + ...state, + photos: [...oldPhotos, ...action.photos], + triedLoading: true, + allPhotosLoaded, + overviewFetching: false, }; + } case PhotoTypes.PHOTOS_LOAD_FAIL: return { ...defaultPhotosState, + triedLoading: true, overviewFetchingError: action.error, }; diff --git a/frontend/src/redux/photos/sagas.ts b/frontend/src/redux/photos/sagas.ts index 4e45350..ca5f21a 100644 --- a/frontend/src/redux/photos/sagas.ts +++ b/frontend/src/redux/photos/sagas.ts @@ -42,6 +42,7 @@ import { photoUploadSuccess, } from "./actions"; import { IPhotosNewRespBody } from "~../../src/routes/photos"; +import { IPhotosListPagination } from "~../../src/types"; // Thanks, https://dev.to/qortex/compute-md5-checksum-for-a-file-in-typescript-59a4 function computeChecksumMd5(file: File): Promise { @@ -108,21 +109,24 @@ function computeSize(f: File) { }); } +// Shouldn't be used anymore function* startSpinner() { yield delay(300); yield put(photosStartFetchingSpinner()); } function* photosLoad() { + const state = yield select(); try { - const spinner = yield fork(startSpinner); + //const spinner = yield fork(startSpinner); + const skip = state.photos.photos ? state.photos.photos.length : 0; const { response, timeout } = yield race({ - response: call(fetchPhotosList), + response: call(fetchPhotosList, skip, IPhotosListPagination), timeout: delay(10000), }); - yield cancel(spinner); + //yield cancel(spinner); if (timeout) { yield put(photosLoadFail("Timeout")); diff --git a/package.json b/package.json index c38ca60..504940c 100644 --- a/package.json +++ b/package.json @@ -89,4 +89,4 @@ "pre-commit": "npm run lint-all && npm run prettier-check" } } -} +} \ No newline at end of file diff --git a/src/routes/photos.ts b/src/routes/photos.ts index 0fcdab0..4e41ad3 100644 --- a/src/routes/photos.ts +++ b/src/routes/photos.ts @@ -1,7 +1,7 @@ import * as Router from "@koa/router"; import { IPhotoReqJSON, Photo } from "~entity/Photo"; import { User } from "~entity/User"; -import { IAPIResponse } from "~types"; +import { IAPIResponse, IPhotosListPagination } from "~types"; import * as fs from "fs/promises"; import send = require("koa-send"); import { getHash, getSize } from "~util"; @@ -171,7 +171,29 @@ photosRouter.get("/photos/list", async (ctx) => { const { user } = ctx.state; - const photos = await Photo.find({ user }); + let { skip, num } = ctx.request.query as { + skip: string | number | undefined; + num: string | number | undefined; + }; + + if (typeof num === "string") { + num = parseInt(num); + } + + if (typeof skip === "string") { + skip = parseInt(skip); + } + + if (!num || num > IPhotosListPagination) { + num = IPhotosListPagination; + } + + const photos = await Photo.find({ + where: { user }, + take: num, + skip: skip, + order: { shotAt: "DESC" }, + }); const photosList: IPhotoReqJSON[] = await Promise.all( photos.map(async (photo) => await photo.toReqJSON()), diff --git a/src/tests/integration/photos.test.ts b/src/tests/integration/photos.test.ts index 8028f56..b5f0407 100644 --- a/src/tests/integration/photos.test.ts +++ b/src/tests/integration/photos.test.ts @@ -571,7 +571,7 @@ describe("photos", function () { }); */ - it("should list photos", async function () { + it("should list photos, sorted", async function () { const response = await request(callback) .get("/photos/list") .set({ @@ -582,13 +582,18 @@ describe("photos", function () { expect(response.body.error).to.be.false; const photos = response.body.data as IPhotoReqJSON[]; - const userPhotos = [ await seed.dogPhoto.toReqJSON(), await seed.catPhoto.toReqJSON(), - ]; + ].sort((a, b) => b.shotAt - a.shotAt); + + const photoIds = photos.map((p) => p.id); + const userPhotoIds = userPhotos.map((p) => p.id); expect(photos).to.deep.equal(userPhotos); + expect(photoIds).to.have.ordered.members(userPhotoIds); + + //TODO: Test pagination }); /* diff --git a/src/types.ts b/src/types.ts index d33e7c8..4ca2adf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,3 +10,5 @@ interface IAPISuccessResponse { } export type IAPIResponse = IAPIErrorResponse | IAPISuccessResponse; + +export const IPhotosListPagination = 30;