diff --git a/api/resource.go b/api/resource.go index 91b0183c..3ee90d9d 100644 --- a/api/resource.go +++ b/api/resource.go @@ -45,6 +45,10 @@ type ResourceFind struct { Filename *string `json:"filename"` MemoID *int GetBlob bool + + // Pagination + Limit *int + Offset *int } type ResourcePatch struct { diff --git a/server/resource.go b/server/resource.go index ec4022bb..bd2c46b7 100644 --- a/server/resource.go +++ b/server/resource.go @@ -256,6 +256,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) { resourceFind := &api.ResourceFind{ CreatorID: &userID, } + if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil { + resourceFind.Limit = &limit + } + if offset, err := strconv.Atoi(c.QueryParam("offset")); err == nil { + resourceFind.Offset = &offset + } + list, err := s.Store.FindResourceList(ctx, resourceFind) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err) diff --git a/store/resource.go b/store/resource.go index 0d54a99c..f5f3c3c7 100644 --- a/store/resource.go +++ b/store/resource.go @@ -312,6 +312,13 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api. GROUP BY resource.id ORDER BY resource.id DESC `, strings.Join(fields, ", "), strings.Join(where, " AND ")) + if find.Limit != nil { + query = fmt.Sprintf("%s LIMIT %d", query, *find.Limit) + if find.Offset != nil { + query = fmt.Sprintf("%s OFFSET %d", query, *find.Offset) + } + } + rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return nil, FormatError(err) diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 38877d01..930fd0f3 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -164,6 +164,17 @@ export function getResourceList() { return axios.get>("/api/resource"); } +export function getResourceListWithLimit(resourceFind?: ResourceFind) { + const queryList = []; + if (resourceFind?.offset) { + queryList.push(`offset=${resourceFind.offset}`); + } + if (resourceFind?.limit) { + queryList.push(`limit=${resourceFind.limit}`); + } + return axios.get>(`/api/resource?${queryList.join("&")}`); +} + export function createResource(resourceCreate: ResourceCreate) { return axios.post>("/api/resource", resourceCreate); } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 7e09cc21..144075d6 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -245,6 +245,8 @@ "message": { "no-memos": "no memos πŸŒƒ", "memos-ready": "all memos are ready πŸŽ‰", + "no-resource": "no resource πŸŒƒ", + "resource-ready": "all resource are ready πŸŽ‰", "restored-successfully": "Restored successfully", "memo-updated-datetime": "Memo created datetime changed.", "invalid-created-datetime": "Invalid created datetime.", diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index 7c902224..8d66564c 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -218,6 +218,8 @@ "message": { "no-memos": "ζ²’ζœ‰ Memo δΊ† πŸŒƒ", "memos-ready": "ζ‰€ζœ‰ Memo ε·²ε°±η·’ πŸŽ‰", + "no-resource": "ζ²’ζœ‰ Resource δΊ† πŸŒƒ", + "memos-resource": "ζ‰€ζœ‰ Resource ε·²ε°±η·’ πŸŽ‰", "restored-successfully": "ι‚„εŽŸζˆεŠŸ", "memo-updated-datetime": "Memo ε»Ίη«‹ζ—₯ζœŸζ™‚ι–“ε·²ζ›΄ζ”Ήγ€‚", "invalid-created-datetime": "ε»Ίη«‹ηš„ζ—₯ζœŸζ™‚ι–“η„‘ζ•ˆγ€‚", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index addb45b5..f6ead994 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -221,6 +221,8 @@ "message": { "no-memos": "ζ²‘ζœ‰ Memo δΊ† πŸŒƒ", "memos-ready": "ζ‰€ζœ‰ Memo ε·²ε°±η»ͺ πŸŽ‰", + "no-resource": "ζ²‘ζœ‰ Resource δΊ† πŸŒƒ", + "resource-ready": "ζ‰€ζœ‰ Resource ε·²ε°±η»ͺ πŸŽ‰", "restored-successfully": "恒倍成功", "memo-updated-datetime": "Memo εˆ›ε»Ίζ—₯ζœŸζ—Άι—΄ε·²ζ›΄ζ”Ήγ€‚", "invalid-created-datetime": "εˆ›ε»Ίηš„ζ—₯ζœŸζ—Άι—΄ζ— ζ•ˆγ€‚", diff --git a/web/src/pages/ResourcesDashboard.tsx b/web/src/pages/ResourcesDashboard.tsx index 8e00db2e..739053f2 100644 --- a/web/src/pages/ResourcesDashboard.tsx +++ b/web/src/pages/ResourcesDashboard.tsx @@ -16,6 +16,7 @@ import { showCommonDialog } from "@/components/Dialog/CommonDialog"; import showChangeResourceFilenameDialog from "@/components/ChangeResourceFilenameDialog"; import showPreviewImageDialog from "@/components/PreviewImageDialog"; import showCreateResourceDialog from "@/components/CreateResourceDialog"; +import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts"; const ResourcesDashboard = () => { const { t } = useTranslation(); @@ -26,16 +27,20 @@ const ResourcesDashboard = () => { const [listStyle, setListStyle] = useState<"GRID" | "TABLE">("GRID"); const [queryText, setQueryText] = useState(""); const [dragActive, setDragActive] = useState(false); + const [isComplete, setIsComplete] = useState(false); useEffect(() => { resourceStore - .fetchResourceList() + .fetchResourceListWithLimit(DEFAULT_MEMO_LIMIT) + .then((fetchedResource) => { + if (fetchedResource.length < DEFAULT_MEMO_LIMIT) { + setIsComplete(true); + } + loadingState.setFinish(); + }) .catch((error) => { console.error(error); toast.error(error.response.data.message); - }) - .finally(() => { - loadingState.setFinish(); }); }, []); @@ -47,29 +52,31 @@ const ResourcesDashboard = () => { setSelectedList(selectedList.filter((resId) => resId !== resourceId)); }; - const handleDeleteUnusedResourcesBtnClick = () => { + const handleDeleteUnusedResourcesBtnClick = async () => { let warningText = t("resources.warning-text-unused"); - const unusedResources = resources.filter((resource) => { - if (resource.linkedMemoAmount === 0) { - warningText = warningText + `\n- ${resource.filename}`; - return true; - } - return false; - }); - if (unusedResources.length === 0) { - toast.success(t("resources.no-unused-resources")); - return; - } - showCommonDialog({ - title: t("resources.delete-resource"), - content: warningText, - style: "warning", - dialogName: "delete-unused-resources", - onConfirm: async () => { - for (const resource of unusedResources) { - await resourceStore.deleteResourceById(resource.id); + await loadAllResources((allResources: Resource[]) => { + const unusedResources = allResources.filter((resource) => { + if (resource.linkedMemoAmount === 0) { + warningText = warningText + `\n- ${resource.filename}`; + return true; } - }, + return false; + }); + if (unusedResources.length === 0) { + toast.success(t("resources.no-unused-resources")); + return; + } + showCommonDialog({ + title: t("resources.delete-resource"), + content: warningText, + style: "warning", + dialogName: "delete-unused-resources", + onConfirm: async () => { + for (const resource of unusedResources) { + await resourceStore.deleteResourceById(resource.id); + } + }, + }); }); }; @@ -136,9 +143,44 @@ const ResourcesDashboard = () => { toast.success(t("message.succeed-copy-resource-link")); }; - const handleSearchResourceInputChange = (query: string) => { - setQueryText(query); - setSelectedList([]); + const handleFetchMoreResourceBtnClick = async () => { + try { + const fetchedResource = await resourceStore.fetchResourceListWithLimit(DEFAULT_MEMO_LIMIT, resources.length); + if (fetchedResource.length < DEFAULT_MEMO_LIMIT) { + setIsComplete(true); + } else { + setIsComplete(false); + } + } catch (error: any) { + console.error(error); + toast.error(error.response.data.message); + } + }; + + const loadAllResources = async (resolve: (allResources: Resource[]) => void) => { + if (!isComplete) { + loadingState.setLoading(); + try { + const allResources = await resourceStore.fetchResourceList(); + loadingState.setFinish(); + setIsComplete(true); + resolve(allResources); + } catch (error: any) { + console.error(error); + toast.error(error.response.data.message); + } + } else { + resolve(resources); + } + }; + + const handleSearchResourceInputChange = async (query: string) => { + // to prevent first tiger when page is loaded + if (query === queryText) return; + await loadAllResources(() => { + setQueryText(query); + setSelectedList([]); + }); }; const resourceList = useMemo( @@ -304,6 +346,23 @@ const ResourcesDashboard = () => { )} +
+

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

+
diff --git a/web/src/store/module/resource.ts b/web/src/store/module/resource.ts index 8b6c712d..052af42a 100644 --- a/web/src/store/module/resource.ts +++ b/web/src/store/module/resource.ts @@ -1,6 +1,7 @@ import store, { useAppSelector } from "../"; -import { patchResource, setResources, deleteResource } from "../reducer/resource"; +import { patchResource, setResources, deleteResource, upsertResources } from "../reducer/resource"; import * as api from "../../helpers/api"; +import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts"; const MAX_FILE_SIZE = 32 << 20; @@ -26,6 +27,16 @@ export const useResourceStore = () => { store.dispatch(setResources(resourceList)); return resourceList; }, + async fetchResourceListWithLimit(limit = DEFAULT_MEMO_LIMIT, offset?: number): Promise { + const resourceFind: ResourceFind = { + limit, + offset, + }; + const { data } = (await api.getResourceListWithLimit(resourceFind)).data; + const resourceList = data.map((m) => convertResponseModelResource(m)); + store.dispatch(upsertResources(resourceList)); + return resourceList; + }, async createResource(resourceCreate: ResourceCreate): Promise { const { data } = (await api.createResource(resourceCreate)).data; const resource = convertResponseModelResource(data); diff --git a/web/src/store/reducer/resource.ts b/web/src/store/reducer/resource.ts index a6b5183d..5b09b92f 100644 --- a/web/src/store/reducer/resource.ts +++ b/web/src/store/reducer/resource.ts @@ -1,4 +1,5 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { uniqBy } from "lodash-es"; interface State { resources: Resource[]; @@ -16,6 +17,12 @@ const resourceSlice = createSlice({ resources: action.payload, }; }, + upsertResources: (state, action: PayloadAction) => { + return { + ...state, + resources: uniqBy([...state.resources, ...action.payload], "id"), + }; + }, patchResource: (state, action: PayloadAction>) => { return { ...state, @@ -42,6 +49,6 @@ const resourceSlice = createSlice({ }, }); -export const { setResources, patchResource, deleteResource } = resourceSlice.actions; +export const { setResources, upsertResources, patchResource, deleteResource } = resourceSlice.actions; export default resourceSlice.reducer; diff --git a/web/src/types/modules/resource.d.ts b/web/src/types/modules/resource.d.ts index 0ce5f9d0..0fd7618c 100644 --- a/web/src/types/modules/resource.d.ts +++ b/web/src/types/modules/resource.d.ts @@ -24,3 +24,8 @@ interface ResourcePatch { id: ResourceId; filename?: string; } + +interface ResourceFind { + offset?: number; + limit?: number; +}