feat: pagination for memo list (#330)

This commit is contained in:
boojack
2022-10-21 22:51:41 +08:00
committed by GitHub
parent fc5d5cf231
commit 1c2998c4d8
14 changed files with 234 additions and 58 deletions

View File

@@ -203,6 +203,56 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return nil
})
g.GET("/memo/stats", func(c echo.Context) error {
ctx := c.Request().Context()
normalStatus := api.Normal
memoFind := &api.MemoFind{
RowStatus: &normalStatus,
}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
if memoFind.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
}
list, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
}
displayTsList := []int64{}
for _, memo := range list {
displayTsList = append(displayTsList, memo.DisplayTs)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(displayTsList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode memo stats response").SetInternal(err)
}
return nil
})
g.GET("/memo/all", func(c echo.Context) error {
ctx := c.Request().Context()
memoFind := &api.MemoFind{}

View File

@@ -21,7 +21,6 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
if (showConfirmDeleteBtn) {
try {
await memoService.deleteMemoById(memo.id);
await memoService.fetchMemos();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);

View File

@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { memoService, shortcutService } from "../services";
import { DEFAULT_MEMO_LIMIT } from "../services/memoService";
import { useAppSelector } from "../store";
import { TAG_REG, LINK_REG } from "../labs/marked/parser";
import * as utils from "../helpers/utils";
@@ -14,6 +15,7 @@ const MemoList = () => {
const query = useAppSelector((state) => state.location.query);
const memoDisplayTsOption = useAppSelector((state) => state.user.user?.setting.memoDisplayTsOption);
const { memos, isFetching } = useAppSelector((state) => state.memo);
const [isComplete, setIsComplete] = useState<boolean>(false);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {};
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
@@ -81,8 +83,12 @@ const MemoList = () => {
useEffect(() => {
memoService
.fetchMemos()
.then(() => {
// do nth
.then((fetchedMemos) => {
if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
} else {
setIsComplete(false);
}
})
.catch((error) => {
console.error(error);
@@ -97,6 +103,20 @@ const MemoList = () => {
}
}, [query]);
const handleFetchMoreClick = async () => {
try {
const fetchedMemos = await memoService.fetchMemos(DEFAULT_MEMO_LIMIT, memos.length);
if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
} else {
setIsComplete(false);
}
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};
return (
<div className="memo-list-container">
{sortedMemos.map((memo) => (
@@ -108,7 +128,21 @@ const MemoList = () => {
</div>
) : (
<div className="status-text-container">
<p className="status-text">{sortedMemos.length === 0 ? t("message.no-memos") : showMemoFilter ? "" : t("message.memos-ready")}</p>
<p className="status-text">
{isComplete ? (
sortedMemos.length === 0 ? (
t("message.no-memos")
) : (
t("message.memos-ready")
)
) : (
<>
<span className="cursor-pointer hover:text-green-600" onClick={handleFetchMoreClick}>
{t("memo-list.fetch-more")}
</span>
</>
)}
</p>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useAppSelector } from "../store";
import { locationService } from "../services";
import { getMemoStats } from "../helpers/api";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
import "../less/usage-heat-map.less";
@@ -39,15 +40,21 @@ const UsageHeatMap = () => {
const containerElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
getMemoStats()
.then(({ data: { data } }) => {
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp);
for (const m of memos) {
const index = (utils.getDateStampByDate(m.displayTs) - beginDayTimestemp) / (1000 * 3600 * 24) - 1;
for (const record of data) {
const index = (utils.getDateStampByDate(record * 1000) - beginDayTimestemp) / (1000 * 3600 * 24) - 1;
if (index >= 0) {
newStat[index].count += 1;
}
}
setAllStat([...newStat]);
}, [memos]);
})
.catch((error) => {
console.error(error);
});
}, [memos.length]);
const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => {
const tempDiv = document.createElement("div");

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { getMemoStats } from "../helpers/api";
import * as utils from "../helpers/utils";
import userService from "../services/userService";
import { locationService } from "../services";
@@ -18,6 +19,7 @@ const UserBanner = () => {
const { user, owner } = useAppSelector((state) => state.user);
const { memos, tags } = useAppSelector((state) => state.memo);
const [username, setUsername] = useState("Memos");
const [memoAmount, setMemoAmount] = useState(0);
const [createdDays, setCreatedDays] = useState(0);
const isVisitorMode = userService.isVisitorMode();
@@ -34,6 +36,16 @@ const UserBanner = () => {
}
}, [isVisitorMode, user, owner]);
useEffect(() => {
getMemoStats()
.then(({ data: { data } }) => {
setMemoAmount(data.length);
})
.catch((error) => {
console.error(error);
});
}, [memos]);
const handleUsernameClick = useCallback(() => {
locationService.clearQuery();
}, []);
@@ -109,7 +121,7 @@ const UserBanner = () => {
</div>
<div className="amount-text-container">
<div className="status-text memos-text">
<span className="amount-text">{memos.length}</span>
<span className="amount-text">{memoAmount}</span>
<span className="type-text">{t("amount-text.memo")}</span>
</div>
<div className="status-text tags-text">

View File

@@ -58,8 +58,16 @@ export function deleteUser(userDelete: UserDelete) {
return axios.delete(`/api/user/${userDelete.id}`);
}
export function getAllMemos() {
return axios.get<ResponseObject<Memo[]>>("/api/memo/all");
export function getAllMemos(memoFind?: MemoFind) {
const queryList = [];
if (memoFind?.offset) {
queryList.push(`offset=${memoFind.offset}`);
}
if (memoFind?.limit) {
queryList.push(`limit=${memoFind.limit}`);
}
return axios.get<ResponseObject<Memo[]>>(`/api/memo/all?${queryList.join("&")}`);
}
export function getMemoList(memoFind?: MemoFind) {
@@ -70,9 +78,19 @@ export function getMemoList(memoFind?: MemoFind) {
if (memoFind?.rowStatus) {
queryList.push(`rowStatus=${memoFind.rowStatus}`);
}
if (memoFind?.offset) {
queryList.push(`offset=${memoFind.offset}`);
}
if (memoFind?.limit) {
queryList.push(`limit=${memoFind.limit}`);
}
return axios.get<ResponseObject<Memo[]>>(`/api/memo?${queryList.join("&")}`);
}
export function getMemoStats() {
return axios.get<ResponseObject<number[]>>(`/api/memo/stats`);
}
export function getMemoById(id: MemoId) {
return axios.get<ResponseObject<Memo>>(`/api/memo/${id}`);
}

View File

@@ -40,7 +40,11 @@
.ol-block,
.ul-block,
.todo-block {
@apply inline-block box-border text-center w-7 font-mono select-none;
@apply inline-block box-border text-right w-8 mr-px font-mono select-none whitespace-nowrap;
}
.ul-block {
@apply text-center;
}
.todo-block {

View File

@@ -88,7 +88,8 @@
}
},
"memo-list": {
"fetching-data": "fetching data..."
"fetching-data": "fetching data...",
"fetch-more": "Click here to fetch more"
},
"shortcut-list": {
"shortcut-title": "shortcut title",

View File

@@ -88,7 +88,8 @@
}
},
"memo-list": {
"fetching-data": "đang tải dữ liệu..."
"fetching-data": "đang tải dữ liệu...",
"fetch-more": "Click here to fetch more"
},
"shortcut-list": {
"shortcut-title": "Tên lối tắt",

View File

@@ -88,7 +88,8 @@
}
},
"memo-list": {
"fetching-data": "请求数据中..."
"fetching-data": "请求数据中...",
"fetch-more": "Click here to fetch more"
},
"shortcut-list": {
"shortcut-title": "捷径标题",

View File

@@ -3,8 +3,10 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { memoService } from "../services";
import { DEFAULT_MEMO_LIMIT } from "../services/memoService";
import { useAppSelector } from "../store";
import useLoading from "../hooks/useLoading";
import toastHelper from "../components/Toast";
import MemoContent from "../components/MemoContent";
import MemoResources from "../components/MemoResources";
import "../less/explore.less";
@@ -20,10 +22,14 @@ const Explore = () => {
const [state, setState] = useState<State>({
memos: [],
});
const [isComplete, setIsComplete] = useState<boolean>(false);
const loadingState = useLoading();
useEffect(() => {
memoService.fetchAllMemos().then((memos) => {
memoService.fetchAllMemos(DEFAULT_MEMO_LIMIT, state.memos.length).then((memos) => {
if (memos.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
}
setState({
memos,
});
@@ -31,6 +37,23 @@ const Explore = () => {
});
}, [location]);
const handleFetchMoreClick = async () => {
try {
const fetchedMemos = await memoService.fetchAllMemos(DEFAULT_MEMO_LIMIT, state.memos.length);
if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
} else {
setIsComplete(false);
}
setState({
memos: state.memos.concat(fetchedMemos),
});
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};
return (
<section className="page-wrapper explore">
<div className="page-container">
@@ -53,8 +76,7 @@ const Explore = () => {
</div>
{!loadingState.isLoading && (
<main className="memos-wrapper">
{state.memos.length > 0 ? (
state.memos.map((memo) => {
{state.memos.map((memo) => {
const createdAtStr = dayjs(memo.displayTs).locale(i18n.language).format("YYYY/MM/DD HH:mm:ss");
return (
<div className="memo-container" key={memo.id}>
@@ -69,9 +91,18 @@ const Explore = () => {
<MemoResources memo={memo} />
</div>
);
})
) : (
})}
{isComplete ? (
state.memos.length === 0 ? (
<p className="w-full text-center mt-12 text-gray-600">{t("message.no-memos")}</p>
) : null
) : (
<p
className="m-auto text-center mt-4 italic cursor-pointer text-gray-500 hover:text-green-600"
onClick={handleFetchMoreClick}
>
{t("memo-list.fetch-more")}
</p>
)}
</main>
)}

View File

@@ -1,8 +1,10 @@
import * as api from "../helpers/api";
import { createMemo, patchMemo, setIsFetching, setMemos, setTags } from "../store/modules/memo";
import { createMemo, deleteMemo, patchMemo, setIsFetching, setMemos, setTags } from "../store/modules/memo";
import store from "../store";
import userService from "./userService";
export const DEFAULT_MEMO_LIMIT = 20;
const convertResponseModelMemo = (memo: Memo): Memo => {
return {
...memo,
@@ -17,32 +19,37 @@ const memoService = {
return store.getState().memo;
},
fetchAllMemos: async () => {
const memoFind: MemoFind = {};
if (userService.isVisitorMode()) {
memoFind.creatorId = userService.getUserIdFromPath();
}
const { data } = (await api.getAllMemos()).data;
const memos = data.map((m) => convertResponseModelMemo(m));
return memos;
},
fetchMemos: async () => {
const timeoutIndex = setTimeout(() => {
fetchMemos: async (limit = DEFAULT_MEMO_LIMIT, offset = 0) => {
store.dispatch(setIsFetching(true));
}, 1000);
const memoFind: MemoFind = {
rowStatus: "NORMAL",
limit,
offset,
};
if (userService.isVisitorMode()) {
memoFind.creatorId = userService.getUserIdFromPath();
}
const { data } = (await api.getMemoList(memoFind)).data;
const memos = data.map((m) => convertResponseModelMemo(m));
store.dispatch(setMemos(memos));
clearTimeout(timeoutIndex);
const fetchedMemos = data.map((m) => convertResponseModelMemo(m));
if (offset === 0) {
store.dispatch(setMemos([]));
}
const memos = memoService.getState().memos;
store.dispatch(setMemos(memos.concat(fetchedMemos)));
store.dispatch(setIsFetching(false));
return fetchedMemos;
},
fetchAllMemos: async (limit = DEFAULT_MEMO_LIMIT, offset?: number) => {
const memoFind: MemoFind = {
rowStatus: "NORMAL",
limit,
offset,
};
const { data } = (await api.getAllMemos(memoFind)).data;
const memos = data.map((m) => convertResponseModelMemo(m));
return memos;
},
@@ -129,6 +136,7 @@ const memoService = {
deleteMemoById: async (memoId: MemoId) => {
await api.deleteMemo(memoId);
store.dispatch(deleteMemo(memoId));
},
};

View File

@@ -44,6 +44,14 @@ const memoSlice = createSlice({
.filter((memo) => memo.rowStatus === "NORMAL"),
};
},
deleteMemo: (state, action: PayloadAction<MemoId>) => {
return {
...state,
memos: state.memos.filter((memo) => {
return memo.id !== action.payload;
}),
};
},
setTags: (state, action: PayloadAction<string[]>) => {
return {
...state,
@@ -59,6 +67,6 @@ const memoSlice = createSlice({
},
});
export const { setMemos, createMemo, patchMemo, setTags, setIsFetching } = memoSlice.actions;
export const { setMemos, createMemo, patchMemo, deleteMemo, setTags, setIsFetching } = memoSlice.actions;
export default memoSlice.reducer;

View File

@@ -38,4 +38,6 @@ interface MemoFind {
creatorId?: UserId;
rowStatus?: RowStatus;
visibility?: Visibility;
offset?: number;
limit?: number;
}