From a0a7d62b598ca1a7c0e4d16389c817397deefdc7 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Thu, 18 Jun 2026 20:43:07 +0300 Subject: [PATCH] feat(client): replace space switcher popover with always-visible space grid Rework the space sidebar: - remove the "New page" and "Search" menu items (search stays in the app header; page creation stays via the "+" button in the Pages section) - move "Space settings" into a gear icon next to the current space name - drop the searchable space popover and render all spaces as an always-visible grid of fixed-width cards (icon + name), several per row, sorted alphabetically, with the active space highlighted - always inject the active space into the grid so it stays highlightable even when the user has more than the 100-space API page limit The shared SpaceSelect component is left untouched (still used by the move/copy page modals). --- .../components/sidebar/space-sidebar.tsx | 75 +--------- .../sidebar/switch-space.module.css | 47 +++++- .../space/components/sidebar/switch-space.tsx | 140 +++++++++++------- 3 files changed, 141 insertions(+), 121 deletions(-) diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 51e529ba..fef03583 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -14,7 +14,6 @@ import { IconFileExport, IconHome, IconPlus, - IconSearch, IconSettings, IconStar, IconStarFilled, @@ -27,7 +26,6 @@ import { } from "@/features/space/queries/space-watcher-query.ts"; import classes from "./space-sidebar.module.css"; import React from "react"; -import { useAtom } from "jotai"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { Link, useLocation, useParams } from "react-router-dom"; import clsx from "clsx"; @@ -50,17 +48,12 @@ import { useAddFavoriteMutation, useRemoveFavoriteMutation, } from "@/features/favorite/queries/favorite-query"; -import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; -import { searchSpotlight } from "@/features/search/constants"; export function SpaceSidebar() { const { t } = useTranslation(); const location = useLocation(); const [opened, { open: openSettings, close: closeSettings }] = useDisclosure(false); - const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); - const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const { spaceSlug } = useParams(); const { data: space } = useGetSpaceBySlugQuery(spaceSlug); @@ -88,18 +81,13 @@ export function SpaceSidebar() { marginBottom: 3, }} > - - - +
@@ -123,55 +111,6 @@ export function SpaceSidebar() { {t("Overview")}
- - -
- - {t("Search")} -
-
- - -
- - {t("Space settings")} -
-
- - {spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page, - ) && ( - { - handleCreatePage(); - if (mobileSidebarOpened) { - toggleMobileSidebar(); - } - }} - > -
- - {t("New page")} -
-
- )} diff --git a/apps/client/src/features/space/components/sidebar/switch-space.module.css b/apps/client/src/features/space/components/sidebar/switch-space.module.css index 451aa9c7..480c40bb 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.module.css +++ b/apps/client/src/features/space/components/sidebar/switch-space.module.css @@ -1,5 +1,48 @@ -.spaceName { +.wrapper { width: 100%; - padding: var(--mantine-spacing-sm); +} + +.header { + padding: rem(4px) var(--mantine-spacing-sm); +} + +.spaceName { + flex: 1; + min-width: 0; color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-0)); } + +.grid { + display: flex; + flex-wrap: wrap; + gap: rem(6px); + padding: rem(4px) var(--mantine-spacing-sm) var(--mantine-spacing-xs); + max-height: rem(180px); + overflow-y: auto; +} + +.card { + display: flex; + align-items: center; + gap: rem(6px); + width: rem(120px); + padding: rem(6px) rem(8px); + border-radius: var(--mantine-radius-sm); + border: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); +} + +.card:hover { + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6)); +} + +.cardActive { + border-color: var(--mantine-primary-color-filled); + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6)); +} + +.cardName { + flex: 1; + min-width: 0; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); +} diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index 7531a592..23b19679 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -1,77 +1,115 @@ import classes from "./switch-space.module.css"; import { useNavigate } from "react-router-dom"; -import { SpaceSelect } from "./space-select"; import { getSpaceUrl } from "@/lib/config"; -import { Button, Popover, Text } from "@mantine/core"; -import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; -import { useDisclosure } from "@mantine/hooks"; +import { ActionIcon, Group, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { IconSettings } from "@tabler/icons-react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; -import React from "react"; +import { + prefetchSpace, + useGetSpacesQuery, +} from "@/features/space/queries/space-query.ts"; +import { ISpace } from "../../types/space.types"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import React, { useMemo } from "react"; interface SwitchSpaceProps { + spaceId: string; spaceName: string; spaceSlug: string; spaceIcon?: string; + onSettings: () => void; } export function SwitchSpace({ + spaceId, spaceName, spaceSlug, spaceIcon, + onSettings, }: SwitchSpaceProps) { + const { t } = useTranslation(); const navigate = useNavigate(); - const [opened, { close, toggle }] = useDisclosure(false); + // Load every space the user belongs to (API caps limit at 100) and render + // them as an always-visible grid instead of the previous searchable popover. + const { data } = useGetSpacesQuery({ limit: 100 }); - const handleSelect = (value: string) => { - if (value) { - navigate(getSpaceUrl(value)); - close(); + // Sort spaces alphabetically by name for a stable, predictable grid. + const spaces = useMemo(() => { + const list = [...(data?.items ?? [])]; + // Ensure the active space is always present (and highlightable) in the grid, + // even when it falls outside the first 100 spaces returned by the API. + if (spaceSlug && !list.some((s: ISpace) => s.slug === spaceSlug)) { + list.push({ + id: spaceId, + name: spaceName, + slug: spaceSlug, + logo: spaceIcon, + } as ISpace); + } + return list.sort((a: ISpace, b: ISpace) => a.name.localeCompare(b.name)); + }, [data, spaceId, spaceName, spaceSlug, spaceIcon]); + + const handleSelect = (slug: string) => { + if (slug && slug !== spaceSlug) { + navigate(getSpaceUrl(slug)); } }; return ( - - - - - - handleSelect(space.slug)} - width={300} - opened={true} +
+ + - - + + {spaceName} + + + + + + + + +
+ {spaces.map((space: ISpace) => ( + handleSelect(space.slug)} + onMouseEnter={() => prefetchSpace(space.slug, space.id)} + title={space.name} + > + + + {space.name} + + + ))} +
+
); }