mirror of
https://github.com/usatiuk/photos.git
synced 2025-10-28 15:27:49 +01:00
pagination
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadingWrapper {
|
.loadingWrapper {
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
.viewComponent {
|
.viewComponent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 80%;
|
width: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: -$pt-navbar-height;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
padding-top: 2 * $pt-navbar-height + 20px;
|
padding-top: $pt-navbar-height;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mainContainer {
|
#mainContainer {
|
||||||
|
|||||||
@@ -26,16 +26,33 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#overviewContainer {
|
||||||
|
padding-top: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
#overview {
|
#overview {
|
||||||
|
width: 80%;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
.photosLoader {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding-top: 5rem;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
#actionbar {
|
#actionbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
@@ -74,6 +91,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
.bp3-dark {
|
.bp3-dark {
|
||||||
#overview {}
|
#overview {}
|
||||||
}
|
}
|
||||||
@@ -7,13 +7,14 @@ import { photosLoadStart } from "~redux/photos/actions";
|
|||||||
import { IPhotoReqJSON } from "~../../src/entity/Photo";
|
import { IPhotoReqJSON } from "~../../src/entity/Photo";
|
||||||
import { LoadingStub } from "~LoadingStub";
|
import { LoadingStub } from "~LoadingStub";
|
||||||
import { PhotoCard } from "./PhotoCard";
|
import { PhotoCard } from "./PhotoCard";
|
||||||
import { Button, Classes, Overlay } from "@blueprintjs/core";
|
import { Button, Classes, Overlay, Spinner } from "@blueprintjs/core";
|
||||||
import { UploadButton } from "./UploadButton";
|
import { UploadButton } from "./UploadButton";
|
||||||
import { Photo } from "./Photo";
|
import { Photo } from "./Photo";
|
||||||
|
|
||||||
export interface IOverviewComponentProps {
|
export interface IOverviewComponentProps {
|
||||||
photos: IPhotoReqJSON[] | null;
|
photos: IPhotoReqJSON[];
|
||||||
overviewLoaded: boolean;
|
triedLoading: boolean;
|
||||||
|
allPhotosLoaded: boolean;
|
||||||
overviewFetching: boolean;
|
overviewFetching: boolean;
|
||||||
overviewFetchingError: string | null;
|
overviewFetchingError: string | null;
|
||||||
overviewFetchingSpinner: boolean;
|
overviewFetchingSpinner: boolean;
|
||||||
@@ -27,21 +28,20 @@ export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps>
|
|||||||
const [selectedPhoto, setSelectedPhoto] = React.useState<number>(0);
|
const [selectedPhoto, setSelectedPhoto] = React.useState<number>(0);
|
||||||
const [isOverlayOpened, setOverlayOpen] = React.useState<boolean>(false);
|
const [isOverlayOpened, setOverlayOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
if (!props.overviewLoaded && !props.overviewFetching) {
|
|
||||||
props.fetchPhotos();
|
|
||||||
}
|
|
||||||
if (!props.photos) {
|
|
||||||
return <LoadingStub spinner={props.overviewFetchingSpinner} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCardClick = (id: number) => {
|
const onCardClick = (id: number) => {
|
||||||
setSelectedPhoto(id);
|
setSelectedPhoto(id);
|
||||||
setOverlayOpen(true);
|
setOverlayOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const photos = props.photos
|
if (
|
||||||
.sort((a, b) => b.shotAt - a.shotAt)
|
props.photos.length === 0 &&
|
||||||
.map((photo) => (
|
!props.triedLoading &&
|
||||||
|
!props.overviewFetching
|
||||||
|
) {
|
||||||
|
props.fetchPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
const photos = props.photos.map((photo) => (
|
||||||
<PhotoCard
|
<PhotoCard
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
@@ -49,6 +49,18 @@ export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps>
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
function onLoaderScroll(e: React.UIEvent<HTMLElement>) {
|
||||||
|
if (
|
||||||
|
e.currentTarget.scrollTop + e.currentTarget.clientHeight >=
|
||||||
|
e.currentTarget.scrollHeight
|
||||||
|
) {
|
||||||
|
console.log(props.allPhotosLoaded, props.overviewFetching);
|
||||||
|
if (!props.allPhotosLoaded && !props.overviewFetching) {
|
||||||
|
props.fetchPhotos();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Overlay
|
<Overlay
|
||||||
@@ -65,11 +77,16 @@ export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps>
|
|||||||
<Photo id={selectedPhoto} />
|
<Photo id={selectedPhoto} />
|
||||||
</div>
|
</div>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
|
<div id="overviewContainer" onScroll={onLoaderScroll}>
|
||||||
<div id="overview">
|
<div id="overview">
|
||||||
<div id="actionbar">
|
<div id="actionbar">
|
||||||
<UploadButton />
|
<UploadButton />
|
||||||
</div>
|
</div>
|
||||||
<div className="list">{photos}</div>
|
<div className="list">{photos}</div>
|
||||||
|
<div className="photosLoader">
|
||||||
|
{props.overviewFetching && <Spinner />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -78,10 +95,11 @@ export const OverviewComponent: React.FunctionComponent<IOverviewComponentProps>
|
|||||||
function mapStateToProps(state: IAppState) {
|
function mapStateToProps(state: IAppState) {
|
||||||
return {
|
return {
|
||||||
photos: state.photos.photos,
|
photos: state.photos.photos,
|
||||||
overviewLoaded: state.photos.overviewLoaded,
|
allPhotosLoaded: state.photos.allPhotosLoaded,
|
||||||
overviewFetching: state.photos.overviewFetching,
|
overviewFetching: state.photos.overviewFetching,
|
||||||
overviewFetchingError: state.photos.overviewFetchingError,
|
overviewFetchingError: state.photos.overviewFetchingError,
|
||||||
overviewFetchingSpinner: state.photos.overviewFetchingSpinner,
|
overviewFetchingSpinner: state.photos.overviewFetchingSpinner,
|
||||||
|
triedLoading: state.photos.triedLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
frontend/src/Photos/PhotoLoader.tsx
Normal file
0
frontend/src/Photos/PhotoLoader.tsx
Normal file
@@ -19,8 +19,15 @@ export function getPhotoThumbPath(photo: IPhotoReqJSON, size: number): string {
|
|||||||
}?size=${size.toString()}`;
|
}?size=${size.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPhotosList(): Promise<IPhotosListRespBody> {
|
export async function fetchPhotosList(
|
||||||
return fetchJSONAuth("/photos/list", "GET");
|
skip: number,
|
||||||
|
num: number,
|
||||||
|
): Promise<IPhotosListRespBody> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
skip: skip.toString(),
|
||||||
|
num: num.toString(),
|
||||||
|
});
|
||||||
|
return fetchJSONAuth(`/photos/list?${params.toString()}`, "GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPhoto(id: number): Promise<IPhotosByIDGetRespBody> {
|
export async function fetchPhoto(id: number): Promise<IPhotosByIDGetRespBody> {
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ export interface IPhotoState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IPhotosState {
|
export interface IPhotosState {
|
||||||
photos: IPhotoReqJSON[] | null;
|
photos: IPhotoReqJSON[];
|
||||||
|
|
||||||
photoStates: Record<number, IPhotoState>;
|
photoStates: Record<number, IPhotoState>;
|
||||||
|
|
||||||
overviewFetching: boolean;
|
overviewFetching: boolean;
|
||||||
overviewLoaded: boolean;
|
allPhotosLoaded: boolean;
|
||||||
|
triedLoading: boolean;
|
||||||
overviewFetchingError: string | null;
|
overviewFetchingError: string | null;
|
||||||
overviewFetchingSpinner: boolean;
|
overviewFetchingSpinner: boolean;
|
||||||
|
|
||||||
@@ -27,9 +28,10 @@ export interface IPhotosState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultPhotosState: IPhotosState = {
|
const defaultPhotosState: IPhotosState = {
|
||||||
photos: null,
|
photos: [],
|
||||||
overviewLoaded: false,
|
allPhotosLoaded: false,
|
||||||
overviewFetching: false,
|
overviewFetching: false,
|
||||||
|
triedLoading: false,
|
||||||
overviewFetchingError: null,
|
overviewFetchingError: null,
|
||||||
overviewFetchingSpinner: false,
|
overviewFetchingSpinner: false,
|
||||||
|
|
||||||
@@ -52,21 +54,31 @@ export const photosReducer: Reducer<IPhotosState, PhotoAction> = (
|
|||||||
return defaultPhotosState;
|
return defaultPhotosState;
|
||||||
case PhotoTypes.PHOTOS_LOAD_START:
|
case PhotoTypes.PHOTOS_LOAD_START:
|
||||||
return {
|
return {
|
||||||
...defaultPhotosState,
|
...state,
|
||||||
overviewFetching: true,
|
overviewFetching: true,
|
||||||
|
triedLoading: true,
|
||||||
overviewFetchingSpinner: false,
|
overviewFetchingSpinner: false,
|
||||||
};
|
};
|
||||||
case PhotoTypes.PHOTOS_START_FETCHING_SPINNER:
|
case PhotoTypes.PHOTOS_START_FETCHING_SPINNER:
|
||||||
return { ...state, overviewFetchingSpinner: true };
|
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 {
|
return {
|
||||||
...defaultPhotosState,
|
...state,
|
||||||
photos: action.photos,
|
photos: [...oldPhotos, ...action.photos],
|
||||||
overviewLoaded: true,
|
triedLoading: true,
|
||||||
|
allPhotosLoaded,
|
||||||
|
overviewFetching: false,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case PhotoTypes.PHOTOS_LOAD_FAIL:
|
case PhotoTypes.PHOTOS_LOAD_FAIL:
|
||||||
return {
|
return {
|
||||||
...defaultPhotosState,
|
...defaultPhotosState,
|
||||||
|
triedLoading: true,
|
||||||
overviewFetchingError: action.error,
|
overviewFetchingError: action.error,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
photoUploadSuccess,
|
photoUploadSuccess,
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
import { IPhotosNewRespBody } from "~../../src/routes/photos";
|
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
|
// Thanks, https://dev.to/qortex/compute-md5-checksum-for-a-file-in-typescript-59a4
|
||||||
function computeChecksumMd5(file: File): Promise<string> {
|
function computeChecksumMd5(file: File): Promise<string> {
|
||||||
@@ -108,21 +109,24 @@ function computeSize(f: File) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shouldn't be used anymore
|
||||||
function* startSpinner() {
|
function* startSpinner() {
|
||||||
yield delay(300);
|
yield delay(300);
|
||||||
yield put(photosStartFetchingSpinner());
|
yield put(photosStartFetchingSpinner());
|
||||||
}
|
}
|
||||||
|
|
||||||
function* photosLoad() {
|
function* photosLoad() {
|
||||||
|
const state = yield select();
|
||||||
try {
|
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({
|
const { response, timeout } = yield race({
|
||||||
response: call(fetchPhotosList),
|
response: call(fetchPhotosList, skip, IPhotosListPagination),
|
||||||
timeout: delay(10000),
|
timeout: delay(10000),
|
||||||
});
|
});
|
||||||
|
|
||||||
yield cancel(spinner);
|
//yield cancel(spinner);
|
||||||
|
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
yield put(photosLoadFail("Timeout"));
|
yield put(photosLoadFail("Timeout"));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as Router from "@koa/router";
|
import * as Router from "@koa/router";
|
||||||
import { IPhotoReqJSON, Photo } from "~entity/Photo";
|
import { IPhotoReqJSON, Photo } from "~entity/Photo";
|
||||||
import { User } from "~entity/User";
|
import { User } from "~entity/User";
|
||||||
import { IAPIResponse } from "~types";
|
import { IAPIResponse, IPhotosListPagination } from "~types";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import send = require("koa-send");
|
import send = require("koa-send");
|
||||||
import { getHash, getSize } from "~util";
|
import { getHash, getSize } from "~util";
|
||||||
@@ -171,7 +171,29 @@ photosRouter.get("/photos/list", async (ctx) => {
|
|||||||
|
|
||||||
const { user } = ctx.state;
|
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(
|
const photosList: IPhotoReqJSON[] = await Promise.all(
|
||||||
photos.map(async (photo) => await photo.toReqJSON()),
|
photos.map(async (photo) => await photo.toReqJSON()),
|
||||||
|
|||||||
@@ -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)
|
const response = await request(callback)
|
||||||
.get("/photos/list")
|
.get("/photos/list")
|
||||||
.set({
|
.set({
|
||||||
@@ -582,13 +582,18 @@ describe("photos", function () {
|
|||||||
expect(response.body.error).to.be.false;
|
expect(response.body.error).to.be.false;
|
||||||
|
|
||||||
const photos = response.body.data as IPhotoReqJSON[];
|
const photos = response.body.data as IPhotoReqJSON[];
|
||||||
|
|
||||||
const userPhotos = [
|
const userPhotos = [
|
||||||
await seed.dogPhoto.toReqJSON(),
|
await seed.dogPhoto.toReqJSON(),
|
||||||
await seed.catPhoto.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(photos).to.deep.equal(userPhotos);
|
||||||
|
expect(photoIds).to.have.ordered.members(userPhotoIds);
|
||||||
|
|
||||||
|
//TODO: Test pagination
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ interface IAPISuccessResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IAPIResponse<T> = IAPIErrorResponse<T> | IAPISuccessResponse<T>;
|
export type IAPIResponse<T> = IAPIErrorResponse<T> | IAPISuccessResponse<T>;
|
||||||
|
|
||||||
|
export const IPhotosListPagination = 30;
|
||||||
|
|||||||
Reference in New Issue
Block a user