From 1c2998c4d8c538627af7cb1ca7e398a707669767 Mon Sep 17 00:00:00 2001 From: boojack Date: Fri, 21 Oct 2022 22:51:41 +0800 Subject: [PATCH] feat: pagination for memo list (#330) --- server/memo.go | 50 +++++++++++++++++++++ web/src/components/ArchivedMemo.tsx | 1 - web/src/components/MemoList.tsx | 42 ++++++++++++++++-- web/src/components/UsageHeatMap.tsx | 25 +++++++---- web/src/components/UserBanner.tsx | 14 +++++- web/src/helpers/api.ts | 22 +++++++++- web/src/less/memo-content.less | 6 ++- web/src/locales/en.json | 3 +- web/src/locales/vi.json | 3 +- web/src/locales/zh.json | 3 +- web/src/pages/Explore.tsx | 67 +++++++++++++++++++++-------- web/src/services/memoService.ts | 44 +++++++++++-------- web/src/store/modules/memo.ts | 10 ++++- web/src/types/modules/memo.d.ts | 2 + 14 files changed, 234 insertions(+), 58 deletions(-) diff --git a/server/memo.go b/server/memo.go index dc24a07..9391e98 100644 --- a/server/memo.go +++ b/server/memo.go @@ -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 = ¤tUserID + } 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{} diff --git a/web/src/components/ArchivedMemo.tsx b/web/src/components/ArchivedMemo.tsx index 3862994..0914a2b 100644 --- a/web/src/components/ArchivedMemo.tsx +++ b/web/src/components/ArchivedMemo.tsx @@ -21,7 +21,6 @@ const ArchivedMemo: React.FC = (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); diff --git a/web/src/components/MemoList.tsx b/web/src/components/MemoList.tsx index 8b77277..019cf5e 100644 --- a/web/src/components/MemoList.tsx +++ b/web/src/components/MemoList.tsx @@ -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(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 (
{sortedMemos.map((memo) => ( @@ -108,7 +128,21 @@ const MemoList = () => {
) : (
-

{sortedMemos.length === 0 ? t("message.no-memos") : showMemoFilter ? "" : t("message.memos-ready")}

+

+ {isComplete ? ( + sortedMemos.length === 0 ? ( + t("message.no-memos") + ) : ( + t("message.memos-ready") + ) + ) : ( + <> + + {t("memo-list.fetch-more")} + + + )} +

)} diff --git a/web/src/components/UsageHeatMap.tsx b/web/src/components/UsageHeatMap.tsx index bfb2a52..5bf998c 100644 --- a/web/src/components/UsageHeatMap.tsx +++ b/web/src/components/UsageHeatMap.tsx @@ -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(null); useEffect(() => { - const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp); - for (const m of memos) { - const index = (utils.getDateStampByDate(m.displayTs) - beginDayTimestemp) / (1000 * 3600 * 24) - 1; - if (index >= 0) { - newStat[index].count += 1; - } - } - setAllStat([...newStat]); - }, [memos]); + getMemoStats() + .then(({ data: { data } }) => { + const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp); + 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]); + }) + .catch((error) => { + console.error(error); + }); + }, [memos.length]); const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => { const tempDiv = document.createElement("div"); diff --git a/web/src/components/UserBanner.tsx b/web/src/components/UserBanner.tsx index 074ceb7..e465e3f 100644 --- a/web/src/components/UserBanner.tsx +++ b/web/src/components/UserBanner.tsx @@ -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 = () => {
- {memos.length} + {memoAmount} {t("amount-text.memo")}
diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index d0c4e98..85f8da3 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -58,8 +58,16 @@ export function deleteUser(userDelete: UserDelete) { return axios.delete(`/api/user/${userDelete.id}`); } -export function getAllMemos() { - return axios.get>("/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>(`/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>(`/api/memo?${queryList.join("&")}`); } +export function getMemoStats() { + return axios.get>(`/api/memo/stats`); +} + export function getMemoById(id: MemoId) { return axios.get>(`/api/memo/${id}`); } diff --git a/web/src/less/memo-content.less b/web/src/less/memo-content.less index 3d85f06..0ef2690 100644 --- a/web/src/less/memo-content.less +++ b/web/src/less/memo-content.less @@ -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 { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 0c4e34a..ae6bc5e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/locales/vi.json b/web/src/locales/vi.json index 98a7ca2..387f311 100644 --- a/web/src/locales/vi.json +++ b/web/src/locales/vi.json @@ -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", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 8e4d0a3..0c5a7fc 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -88,7 +88,8 @@ } }, "memo-list": { - "fetching-data": "请求数据中..." + "fetching-data": "请求数据中...", + "fetch-more": "Click here to fetch more" }, "shortcut-list": { "shortcut-title": "捷径标题", diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 6183081..eb4f89a 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -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({ memos: [], }); + const [isComplete, setIsComplete] = useState(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 (
@@ -53,25 +76,33 @@ const Explore = () => {
{!loadingState.isLoading && (
- {state.memos.length > 0 ? ( - state.memos.map((memo) => { - const createdAtStr = dayjs(memo.displayTs).locale(i18n.language).format("YYYY/MM/DD HH:mm:ss"); - return ( -
-
- {createdAtStr} - by - - {memo.creator.name} - -
- undefined} /> - + {state.memos.map((memo) => { + const createdAtStr = dayjs(memo.displayTs).locale(i18n.language).format("YYYY/MM/DD HH:mm:ss"); + return ( +
+
+ {createdAtStr} + by + + {memo.creator.name} +
- ); - }) + undefined} /> + +
+ ); + })} + {isComplete ? ( + state.memos.length === 0 ? ( +

{t("message.no-memos")}

+ ) : null ) : ( -

{t("message.no-memos")}

+

+ {t("memo-list.fetch-more")} +

)}
)} diff --git a/web/src/services/memoService.ts b/web/src/services/memoService.ts index 95ad641..8cf12a1 100644 --- a/web/src/services/memoService.ts +++ b/web/src/services/memoService.ts @@ -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(() => { - store.dispatch(setIsFetching(true)); - }, 1000); + fetchMemos: async (limit = DEFAULT_MEMO_LIMIT, offset = 0) => { + store.dispatch(setIsFetching(true)); 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)); }, }; diff --git a/web/src/store/modules/memo.ts b/web/src/store/modules/memo.ts index add469a..9eae529 100644 --- a/web/src/store/modules/memo.ts +++ b/web/src/store/modules/memo.ts @@ -44,6 +44,14 @@ const memoSlice = createSlice({ .filter((memo) => memo.rowStatus === "NORMAL"), }; }, + deleteMemo: (state, action: PayloadAction) => { + return { + ...state, + memos: state.memos.filter((memo) => { + return memo.id !== action.payload; + }), + }; + }, setTags: (state, action: PayloadAction) => { 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; diff --git a/web/src/types/modules/memo.d.ts b/web/src/types/modules/memo.d.ts index ac7c4dc..6fd035e 100644 --- a/web/src/types/modules/memo.d.ts +++ b/web/src/types/modules/memo.d.ts @@ -38,4 +38,6 @@ interface MemoFind { creatorId?: UserId; rowStatus?: RowStatus; visibility?: Visibility; + offset?: number; + limit?: number; }