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 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 { g.GET("/memo/all", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
memoFind := &api.MemoFind{} memoFind := &api.MemoFind{}

View File

@@ -21,7 +21,6 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
if (showConfirmDeleteBtn) { if (showConfirmDeleteBtn) {
try { try {
await memoService.deleteMemoById(memo.id); await memoService.deleteMemoById(memo.id);
await memoService.fetchMemos();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toastHelper.error(error.response.data.message); 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 { useTranslation } from "react-i18next";
import { memoService, shortcutService } from "../services"; import { memoService, shortcutService } from "../services";
import { DEFAULT_MEMO_LIMIT } from "../services/memoService";
import { useAppSelector } from "../store"; import { useAppSelector } from "../store";
import { TAG_REG, LINK_REG } from "../labs/marked/parser"; import { TAG_REG, LINK_REG } from "../labs/marked/parser";
import * as utils from "../helpers/utils"; import * as utils from "../helpers/utils";
@@ -14,6 +15,7 @@ const MemoList = () => {
const query = useAppSelector((state) => state.location.query); const query = useAppSelector((state) => state.location.query);
const memoDisplayTsOption = useAppSelector((state) => state.user.user?.setting.memoDisplayTsOption); const memoDisplayTsOption = useAppSelector((state) => state.user.user?.setting.memoDisplayTsOption);
const { memos, isFetching } = useAppSelector((state) => state.memo); const { memos, isFetching } = useAppSelector((state) => state.memo);
const [isComplete, setIsComplete] = useState<boolean>(false);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {}; const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {};
const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null; const shortcut = shortcutId ? shortcutService.getShortcutById(shortcutId) : null;
@@ -81,8 +83,12 @@ const MemoList = () => {
useEffect(() => { useEffect(() => {
memoService memoService
.fetchMemos() .fetchMemos()
.then(() => { .then((fetchedMemos) => {
// do nth if (fetchedMemos.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
} else {
setIsComplete(false);
}
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
@@ -97,6 +103,20 @@ const MemoList = () => {
} }
}, [query]); }, [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 ( return (
<div className="memo-list-container"> <div className="memo-list-container">
{sortedMemos.map((memo) => ( {sortedMemos.map((memo) => (
@@ -108,7 +128,21 @@ const MemoList = () => {
</div> </div>
) : ( ) : (
<div className="status-text-container"> <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>
)} )}
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,11 @@
.ol-block, .ol-block,
.ul-block, .ul-block,
.todo-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 { .todo-block {

View File

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

View File

@@ -88,7 +88,8 @@
} }
}, },
"memo-list": { "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-list": {
"shortcut-title": "Tên lối tắt", "shortcut-title": "Tên lối tắt",

View File

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

View File

@@ -3,8 +3,10 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { memoService } from "../services"; import { memoService } from "../services";
import { DEFAULT_MEMO_LIMIT } from "../services/memoService";
import { useAppSelector } from "../store"; import { useAppSelector } from "../store";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import toastHelper from "../components/Toast";
import MemoContent from "../components/MemoContent"; import MemoContent from "../components/MemoContent";
import MemoResources from "../components/MemoResources"; import MemoResources from "../components/MemoResources";
import "../less/explore.less"; import "../less/explore.less";
@@ -20,10 +22,14 @@ const Explore = () => {
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
memos: [], memos: [],
}); });
const [isComplete, setIsComplete] = useState<boolean>(false);
const loadingState = useLoading(); const loadingState = useLoading();
useEffect(() => { useEffect(() => {
memoService.fetchAllMemos().then((memos) => { memoService.fetchAllMemos(DEFAULT_MEMO_LIMIT, state.memos.length).then((memos) => {
if (memos.length < DEFAULT_MEMO_LIMIT) {
setIsComplete(true);
}
setState({ setState({
memos, memos,
}); });
@@ -31,6 +37,23 @@ const Explore = () => {
}); });
}, [location]); }, [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 ( return (
<section className="page-wrapper explore"> <section className="page-wrapper explore">
<div className="page-container"> <div className="page-container">
@@ -53,25 +76,33 @@ const Explore = () => {
</div> </div>
{!loadingState.isLoading && ( {!loadingState.isLoading && (
<main className="memos-wrapper"> <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");
const createdAtStr = dayjs(memo.displayTs).locale(i18n.language).format("YYYY/MM/DD HH:mm:ss"); return (
return ( <div className="memo-container" key={memo.id}>
<div className="memo-container" key={memo.id}> <div className="memo-header">
<div className="memo-header"> <span className="time-text">{createdAtStr}</span>
<span className="time-text">{createdAtStr}</span> <span className="split-text">by</span>
<span className="split-text">by</span> <a className="name-text" href={`/u/${memo.creator.id}`}>
<a className="name-text" href={`/u/${memo.creator.id}`}> {memo.creator.name}
{memo.creator.name} </a>
</a>
</div>
<MemoContent className="memo-content" content={memo.content} onMemoContentClick={() => undefined} />
<MemoResources memo={memo} />
</div> </div>
); <MemoContent className="memo-content" content={memo.content} onMemoContentClick={() => undefined} />
}) <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="w-full text-center mt-12 text-gray-600">{t("message.no-memos")}</p> <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> </main>
)} )}

View File

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

View File

@@ -44,6 +44,14 @@ const memoSlice = createSlice({
.filter((memo) => memo.rowStatus === "NORMAL"), .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[]>) => { setTags: (state, action: PayloadAction<string[]>) => {
return { return {
...state, ...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; export default memoSlice.reducer;

View File

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