mirror of
https://github.com/usememos/memos.git
synced 2024-11-24 14:42:58 +03:00
feat: request pagination for resource(#1425)
* feat: add support for resource page on frontend * [WIP]feat: add backend support for limit and offset search * feat: add reducer to add resource * support fetch all resource when first search * beautify the fetch ui * restore file * feat: add all resource before clear resource * eslint * i18n * chore:change the nane * chore: change the name of param * eslint * feat: setIsComplete to true when first loading resource fully * fix the bug of fetch * feat change finally to then * feat: add await and async to clear and search * feat: return all resource when fetch * chore: change variable name * Update web/src/pages/ResourcesDashboard.tsx Co-authored-by: boojack <stevenlgtm@gmail.com> * fix missing const value --------- Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
parent
fab3dac70a
commit
424f10e180
@ -45,6 +45,10 @@ type ResourceFind struct {
|
|||||||
Filename *string `json:"filename"`
|
Filename *string `json:"filename"`
|
||||||
MemoID *int
|
MemoID *int
|
||||||
GetBlob bool
|
GetBlob bool
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
Limit *int
|
||||||
|
Offset *int
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResourcePatch struct {
|
type ResourcePatch struct {
|
||||||
|
@ -256,6 +256,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
|
|||||||
resourceFind := &api.ResourceFind{
|
resourceFind := &api.ResourceFind{
|
||||||
CreatorID: &userID,
|
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)
|
list, err := s.Store.FindResourceList(ctx, resourceFind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
|
||||||
|
@ -312,6 +312,13 @@ func (s *Store) findResourceListImpl(ctx context.Context, tx *sql.Tx, find *api.
|
|||||||
GROUP BY resource.id
|
GROUP BY resource.id
|
||||||
ORDER BY resource.id DESC
|
ORDER BY resource.id DESC
|
||||||
`, strings.Join(fields, ", "), strings.Join(where, " AND "))
|
`, 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...)
|
rows, err := tx.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, FormatError(err)
|
return nil, FormatError(err)
|
||||||
|
@ -164,6 +164,17 @@ export function getResourceList() {
|
|||||||
return axios.get<ResponseObject<Resource[]>>("/api/resource");
|
return axios.get<ResponseObject<Resource[]>>("/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<ResponseObject<Resource[]>>(`/api/resource?${queryList.join("&")}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createResource(resourceCreate: ResourceCreate) {
|
export function createResource(resourceCreate: ResourceCreate) {
|
||||||
return axios.post<ResponseObject<Resource>>("/api/resource", resourceCreate);
|
return axios.post<ResponseObject<Resource>>("/api/resource", resourceCreate);
|
||||||
}
|
}
|
||||||
|
@ -245,6 +245,8 @@
|
|||||||
"message": {
|
"message": {
|
||||||
"no-memos": "no memos 🌃",
|
"no-memos": "no memos 🌃",
|
||||||
"memos-ready": "all memos are ready 🎉",
|
"memos-ready": "all memos are ready 🎉",
|
||||||
|
"no-resource": "no resource 🌃",
|
||||||
|
"resource-ready": "all resource are ready 🎉",
|
||||||
"restored-successfully": "Restored successfully",
|
"restored-successfully": "Restored successfully",
|
||||||
"memo-updated-datetime": "Memo created datetime changed.",
|
"memo-updated-datetime": "Memo created datetime changed.",
|
||||||
"invalid-created-datetime": "Invalid created datetime.",
|
"invalid-created-datetime": "Invalid created datetime.",
|
||||||
|
@ -218,6 +218,8 @@
|
|||||||
"message": {
|
"message": {
|
||||||
"no-memos": "沒有 Memo 了 🌃",
|
"no-memos": "沒有 Memo 了 🌃",
|
||||||
"memos-ready": "所有 Memo 已就緒 🎉",
|
"memos-ready": "所有 Memo 已就緒 🎉",
|
||||||
|
"no-resource": "沒有 Resource 了 🌃",
|
||||||
|
"memos-resource": "所有 Resource 已就緒 🎉",
|
||||||
"restored-successfully": "還原成功",
|
"restored-successfully": "還原成功",
|
||||||
"memo-updated-datetime": "Memo 建立日期時間已更改。",
|
"memo-updated-datetime": "Memo 建立日期時間已更改。",
|
||||||
"invalid-created-datetime": "建立的日期時間無效。",
|
"invalid-created-datetime": "建立的日期時間無效。",
|
||||||
|
@ -221,6 +221,8 @@
|
|||||||
"message": {
|
"message": {
|
||||||
"no-memos": "没有 Memo 了 🌃",
|
"no-memos": "没有 Memo 了 🌃",
|
||||||
"memos-ready": "所有 Memo 已就绪 🎉",
|
"memos-ready": "所有 Memo 已就绪 🎉",
|
||||||
|
"no-resource": "没有 Resource 了 🌃",
|
||||||
|
"resource-ready": "所有 Resource 已就绪 🎉",
|
||||||
"restored-successfully": "恢复成功",
|
"restored-successfully": "恢复成功",
|
||||||
"memo-updated-datetime": "Memo 创建日期时间已更改。",
|
"memo-updated-datetime": "Memo 创建日期时间已更改。",
|
||||||
"invalid-created-datetime": "创建的日期时间无效。",
|
"invalid-created-datetime": "创建的日期时间无效。",
|
||||||
|
@ -16,6 +16,7 @@ import { showCommonDialog } from "@/components/Dialog/CommonDialog";
|
|||||||
import showChangeResourceFilenameDialog from "@/components/ChangeResourceFilenameDialog";
|
import showChangeResourceFilenameDialog from "@/components/ChangeResourceFilenameDialog";
|
||||||
import showPreviewImageDialog from "@/components/PreviewImageDialog";
|
import showPreviewImageDialog from "@/components/PreviewImageDialog";
|
||||||
import showCreateResourceDialog from "@/components/CreateResourceDialog";
|
import showCreateResourceDialog from "@/components/CreateResourceDialog";
|
||||||
|
import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts";
|
||||||
|
|
||||||
const ResourcesDashboard = () => {
|
const ResourcesDashboard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -26,16 +27,20 @@ const ResourcesDashboard = () => {
|
|||||||
const [listStyle, setListStyle] = useState<"GRID" | "TABLE">("GRID");
|
const [listStyle, setListStyle] = useState<"GRID" | "TABLE">("GRID");
|
||||||
const [queryText, setQueryText] = useState<string>("");
|
const [queryText, setQueryText] = useState<string>("");
|
||||||
const [dragActive, setDragActive] = useState(false);
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [isComplete, setIsComplete] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resourceStore
|
resourceStore
|
||||||
.fetchResourceList()
|
.fetchResourceListWithLimit(DEFAULT_MEMO_LIMIT)
|
||||||
|
.then((fetchedResource) => {
|
||||||
|
if (fetchedResource.length < DEFAULT_MEMO_LIMIT) {
|
||||||
|
setIsComplete(true);
|
||||||
|
}
|
||||||
|
loadingState.setFinish();
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.response.data.message);
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loadingState.setFinish();
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -47,29 +52,31 @@ const ResourcesDashboard = () => {
|
|||||||
setSelectedList(selectedList.filter((resId) => resId !== resourceId));
|
setSelectedList(selectedList.filter((resId) => resId !== resourceId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUnusedResourcesBtnClick = () => {
|
const handleDeleteUnusedResourcesBtnClick = async () => {
|
||||||
let warningText = t("resources.warning-text-unused");
|
let warningText = t("resources.warning-text-unused");
|
||||||
const unusedResources = resources.filter((resource) => {
|
await loadAllResources((allResources: Resource[]) => {
|
||||||
if (resource.linkedMemoAmount === 0) {
|
const unusedResources = allResources.filter((resource) => {
|
||||||
warningText = warningText + `\n- ${resource.filename}`;
|
if (resource.linkedMemoAmount === 0) {
|
||||||
return true;
|
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);
|
|
||||||
}
|
}
|
||||||
},
|
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"));
|
toast.success(t("message.succeed-copy-resource-link"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchResourceInputChange = (query: string) => {
|
const handleFetchMoreResourceBtnClick = async () => {
|
||||||
setQueryText(query);
|
try {
|
||||||
setSelectedList([]);
|
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(
|
const resourceList = useMemo(
|
||||||
@ -304,6 +346,23 @@ const ResourcesDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col justify-start items-center w-full my-6">
|
||||||
|
<p className="text-sm text-gray-400 italic">
|
||||||
|
{isComplete ? (
|
||||||
|
resources.length === 0 ? (
|
||||||
|
t("message.no-resource")
|
||||||
|
) : (
|
||||||
|
t("message.resource-ready")
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="cursor-pointer hover:text-green-600" onClick={handleFetchMoreResourceBtnClick}>
|
||||||
|
{t("memo-list.fetch-more")}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import store, { useAppSelector } from "../";
|
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 * as api from "../../helpers/api";
|
||||||
|
import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts";
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 32 << 20;
|
const MAX_FILE_SIZE = 32 << 20;
|
||||||
|
|
||||||
@ -26,6 +27,16 @@ export const useResourceStore = () => {
|
|||||||
store.dispatch(setResources(resourceList));
|
store.dispatch(setResources(resourceList));
|
||||||
return resourceList;
|
return resourceList;
|
||||||
},
|
},
|
||||||
|
async fetchResourceListWithLimit(limit = DEFAULT_MEMO_LIMIT, offset?: number): Promise<Resource[]> {
|
||||||
|
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<Resource> {
|
async createResource(resourceCreate: ResourceCreate): Promise<Resource> {
|
||||||
const { data } = (await api.createResource(resourceCreate)).data;
|
const { data } = (await api.createResource(resourceCreate)).data;
|
||||||
const resource = convertResponseModelResource(data);
|
const resource = convertResponseModelResource(data);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
import { uniqBy } from "lodash-es";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
@ -16,6 +17,12 @@ const resourceSlice = createSlice({
|
|||||||
resources: action.payload,
|
resources: action.payload,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
upsertResources: (state, action: PayloadAction<Resource[]>) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
resources: uniqBy([...state.resources, ...action.payload], "id"),
|
||||||
|
};
|
||||||
|
},
|
||||||
patchResource: (state, action: PayloadAction<Partial<Resource>>) => {
|
patchResource: (state, action: PayloadAction<Partial<Resource>>) => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...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;
|
export default resourceSlice.reducer;
|
||||||
|
5
web/src/types/modules/resource.d.ts
vendored
5
web/src/types/modules/resource.d.ts
vendored
@ -24,3 +24,8 @@ interface ResourcePatch {
|
|||||||
id: ResourceId;
|
id: ResourceId;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResourceFind {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user