diff --git a/web/src/components/MemoFilters.tsx b/web/src/components/MemoFilters.tsx index 9fc7ed25168c0..af9a76738dc16 100644 --- a/web/src/components/MemoFilters.tsx +++ b/web/src/components/MemoFilters.tsx @@ -1,10 +1,47 @@ import { isEqual } from "lodash-es"; import { CalendarIcon, CheckCircleIcon, CodeIcon, EyeIcon, FilterIcon, LinkIcon, SearchIcon, TagIcon, XIcon } from "lucide-react"; -import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterStore } from "@/store/v1"; +import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { usePrevious } from "react-use"; +import { FilterFactor, getMemoFilterKey, MemoFilter, parseFilterQuery, stringifyFilters, useMemoFilterStore } from "@/store/v1"; const MemoFilters = () => { + const [searchParams, setSearchParams] = useSearchParams(); const memoFilterStore = useMemoFilterStore(); const filters = memoFilterStore.filters; + const prevFilters = usePrevious(filters); + const orderByTimeAsc = memoFilterStore.orderByTimeAsc; + const prevOrderByTimeAsc = usePrevious(orderByTimeAsc); + + // Sync the filters and orderByTimeAsc to the search params. + useEffect(() => { + const newSearchParams = new URLSearchParams(searchParams); + + if (prevOrderByTimeAsc !== orderByTimeAsc) { + if (orderByTimeAsc) { + newSearchParams.set("orderBy", "asc"); + } else { + newSearchParams.delete("orderBy"); + } + } + + if (prevFilters && stringifyFilters(prevFilters) !== stringifyFilters(filters)) { + if (filters.length > 0) { + newSearchParams.set("filter", stringifyFilters(filters)); + } else { + newSearchParams.delete("filter"); + } + } + + setSearchParams(newSearchParams); + }, [prevOrderByTimeAsc, orderByTimeAsc, prevFilters, filters, searchParams]); + + // Sync the search params to the filters and orderByTimeAsc when the component is mounted. + useEffect(() => { + const newFilters = parseFilterQuery(searchParams.get("filter")); + const newOrderByTimeAsc = searchParams.get("orderBy") === "asc"; + memoFilterStore.setState({ filters: newFilters, orderByTimeAsc: newOrderByTimeAsc }); + }, []); const getFilterDisplayText = (filter: MemoFilter) => { if (filter.value) { diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 43e4e7a470cb2..c2495ce50468c 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -41,7 +41,7 @@ const PagedMemoList = (props: Props) => { }); setState(() => ({ isRequesting: false, - nextPageToken: response.nextPageToken, + nextPageToken: response?.nextPageToken || "", })); }; diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 68c48c42ce859..2d16cc85fdb03 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -2,8 +2,9 @@ import { Tooltip } from "@mui/joy"; import { Button } from "@usememos/mui"; import clsx from "clsx"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { Suspense, useEffect, useState } from "react"; -import { Outlet, useLocation } from "react-router-dom"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import { Outlet, useLocation, useSearchParams } from "react-router-dom"; +import { usePrevious } from "react-use"; import useLocalStorage from "react-use/lib/useLocalStorage"; import Navigation from "@/components/Navigation"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -16,11 +17,14 @@ import { useTranslate } from "@/utils/i18n"; const RootLayout = () => { const t = useTranslate(); const location = useLocation(); + const [searchParams] = useSearchParams(); const { sm } = useResponsiveWidth(); const currentUser = useCurrentUser(); const memoFilterStore = useMemoFilterStore(); const [collapsed, setCollapsed] = useLocalStorage("navigation-collapsed", false); const [initialized, setInitialized] = useState(false); + const pathname = useMemo(() => location.pathname, [location.pathname]); + const prevPathname = usePrevious(pathname); useEffect(() => { if (!currentUser) { @@ -33,9 +37,11 @@ const RootLayout = () => { }, []); useEffect(() => { - // When the route changes, remove all filters. - memoFilterStore.removeFilter(() => true); - }, [location.pathname]); + // When the route changes and there is no filter in the search params, remove all filters. + if (prevPathname !== pathname && !searchParams.has("filter")) { + memoFilterStore.removeFilter(() => true); + } + }, [prevPathname, pathname, searchParams]); return !initialized ? ( diff --git a/web/src/store/v1/memo.ts b/web/src/store/v1/memo.ts index d9714232a1de0..5987b2ea1512d 100644 --- a/web/src/store/v1/memo.ts +++ b/web/src/store/v1/memo.ts @@ -9,28 +9,56 @@ interface State { // It should be update when any state change. stateId: string; memoMapByName: Record; + currentRequest: AbortController | null; } const getDefaultState = (): State => ({ stateId: uniqueId(), memoMapByName: {}, + currentRequest: null, }); export const useMemoStore = create( combine(getDefaultState(), (set, get) => ({ setState: (state: State) => set(state), getState: () => get(), + updateStateId: () => set({ stateId: uniqueId() }), fetchMemos: async (request: Partial) => { - const { memos, nextPageToken } = await memoServiceClient.listMemos({ - ...request, - view: MemoView.MEMO_VIEW_FULL, - }); - const memoMap = { ...get().memoMapByName }; - for (const memo of memos) { - memoMap[memo.name] = memo; + const currentRequest = get().currentRequest; + if (currentRequest) { + currentRequest.abort(); + } + + const controller = new AbortController(); + set({ currentRequest: controller }); + + try { + const { memos, nextPageToken } = await memoServiceClient.listMemos( + { + ...request, + view: MemoView.MEMO_VIEW_FULL, + }, + { signal: controller.signal }, + ); + + if (!controller.signal.aborted) { + const memoMap = request.pageToken ? { ...get().memoMapByName } : {}; + for (const memo of memos) { + memoMap[memo.name] = memo; + } + set({ stateId: uniqueId(), memoMapByName: memoMap }); + return { memos, nextPageToken }; + } + } catch (error: any) { + if (error.name === "AbortError") { + return; + } + throw error; + } finally { + if (get().currentRequest === controller) { + set({ currentRequest: null }); + } } - set({ stateId: uniqueId(), memoMapByName: memoMap }); - return { memos, nextPageToken }; }, getOrFetchMemoByName: async (name: string, options?: { skipCache?: boolean; skipStore?: boolean }) => { const memoMap = get().memoMapByName; @@ -99,7 +127,7 @@ export const useMemoList = () => { const memos = Object.values(memoStore.getState().memoMapByName); const reset = () => { - memoStore.setState({ stateId: uniqueId(), memoMapByName: {} }); + memoStore.updateStateId(); }; const size = () => { diff --git a/web/src/store/v1/memoFilter.ts b/web/src/store/v1/memoFilter.ts index b3183969441bb..947064c9b92a8 100644 --- a/web/src/store/v1/memoFilter.ts +++ b/web/src/store/v1/memoFilter.ts @@ -18,13 +18,41 @@ export interface MemoFilter { export const getMemoFilterKey = (filter: MemoFilter) => `${filter.factor}:${filter.value}`; +export const parseFilterQuery = (query: string | null): MemoFilter[] => { + if (!query) return []; + try { + return query.split(",").map((filterStr) => { + const [factor, value] = filterStr.split(":"); + return { + factor: factor as FilterFactor, + value: decodeURIComponent(value), + }; + }); + } catch (error) { + console.error("Failed to parse filter query:", error); + return []; + } +}; + +export const stringifyFilters = (filters: MemoFilter[]): string => { + return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(","); +}; + interface State { filters: MemoFilter[]; orderByTimeAsc: boolean; } +const getInitialState = (): State => { + const searchParams = new URLSearchParams(window.location.search); + return { + filters: parseFilterQuery(searchParams.get("filter")), + orderByTimeAsc: searchParams.get("orderBy") === "asc", + }; +}; + export const useMemoFilterStore = create( - combine(((): State => ({ filters: [], orderByTimeAsc: false }))(), (set, get) => ({ + combine(getInitialState(), (set, get) => ({ setState: (state: State) => set(state), getState: () => get(), getFiltersByFactor: (factor: FilterFactor) => get().filters.filter((f) => f.factor === factor),