memos/web/src/contexts/SpaceContext.tsx
johnnyjoygh af008b2153 feat(web): scope Spaces by URL path instead of a remembered selection
The selected Space used to live in sessionStorage and ride along invisibly
with every collection route. It now lives in the URL: collection pages exist
both globally and beneath /spaces/:spaceUid, and the Space context derives
its scope from the current location.

- Add a Space route branch (Home, Explore, Calendar, Attachments) guarded by
  a SpaceRoute that shows loading, unavailable, and retry states.
- Resolve collection routes through one matchPath-based helper built from the
  route constants; build Space-scoped paths with buildCollectionPath.
- Fetch the route Space directly, seeded from the switcher list so pages are
  not held behind a second round-trip.
- Turn the Space switcher into plain links that preserve the current
  collection view and query.
- Drop the remembered-Space machinery: clearSelectedSpace/selectMemos,
  the compose-placement policy, routeSupportsCollectionScope, and the
  quick-find switchToAll flag are all implied by the URL now.
- Return from memo details to the full origin URL, including its Space.
- Add space.unavailable and space.back-to-memos strings; align the
  Azerbaijani catalog with English.
2026-09-07 09:29:50 +08:00

94 lines
3.4 KiB
TypeScript

import type { ConnectError } from "@connectrpc/connect";
import { createContext, type ReactNode, useCallback, useContext, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useSpace, useSpaces } from "@/hooks/useSpaceQueries";
import { buildCollectionScopeFilter, type CollectionScope } from "@/lib/cel-filter";
import { getDuplicateSpaceTitles } from "@/lib/space-display";
import { buildCollectionPath, ROUTES, resolveCollectionRoute } from "@/router/routes";
import type { Space } from "@/types/proto/api/v1/space_service_pb";
interface SpaceContextValue {
spaces: Space[];
spaceByName: ReadonlyMap<string, Space>;
duplicateSpaceTitles: ReadonlySet<string>;
selectedSpace?: Space;
selectedSpaceName?: string;
collectionScope: CollectionScope;
memoFilter?: string;
isLoadingSpaces: boolean;
isSpacesError: boolean;
isSpaceReady: boolean;
spaceError: ConnectError | null;
retrySpace: () => void;
/** Opens the Space Home after creation or from an invitation. */
selectSpace: (space: Space) => void;
}
const SpaceContext = createContext<SpaceContextValue | null>(null);
const NO_SPACES: Space[] = [];
export function SpaceProvider({ children }: { children: ReactNode }) {
const userName = useCurrentUser()?.name;
const location = useLocation();
const navigate = useNavigate();
const { spaceName: selectedSpaceName } = resolveCollectionRoute(location.pathname);
const spacesQuery = useSpaces(userName);
const spaceQuery = useSpace(userName, selectedSpaceName);
const spaces = spacesQuery.data ?? NO_SPACES;
const selectedSpace = spaceQuery.data;
const spaceByName = useMemo(() => {
const result = new Map(spaces.map((space) => [space.name, space]));
if (selectedSpace) result.set(selectedSpace.name, selectedSpace);
return result;
}, [spaces, selectedSpace]);
const duplicateSpaceTitles = useMemo(() => getDuplicateSpaceTitles([...spaceByName.values()]), [spaceByName]);
const collectionScope = useMemo<CollectionScope>(
() => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : { kind: "all" }),
[selectedSpaceName],
);
const selectSpace = useCallback((space: Space) => navigate(buildCollectionPath(ROUTES.HOME, space.name)), [navigate]);
const retrySpace = useCallback(() => {
void spaceQuery.refetch();
}, [spaceQuery.refetch]);
const value = useMemo<SpaceContextValue>(
() => ({
spaces,
spaceByName,
duplicateSpaceTitles,
selectedSpace,
selectedSpaceName,
collectionScope,
memoFilter: buildCollectionScopeFilter(collectionScope),
isLoadingSpaces: spacesQuery.isPending,
isSpacesError: spacesQuery.isError,
isSpaceReady: !selectedSpaceName || (Boolean(userName) && spaceQuery.isSuccess),
spaceError: spaceQuery.error,
retrySpace,
selectSpace,
}),
[
spaces,
spaceByName,
duplicateSpaceTitles,
selectedSpace,
selectedSpaceName,
collectionScope,
spacesQuery.isPending,
spacesQuery.isError,
userName,
spaceQuery.isSuccess,
spaceQuery.error,
retrySpace,
selectSpace,
],
);
return <SpaceContext.Provider value={value}>{children}</SpaceContext.Provider>;
}
export function useSpaceContext() {
const context = useContext(SpaceContext);
if (!context) throw new Error("useSpaceContext must be used within SpaceProvider");
return context;
}