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).
This commit is contained in:
vvzvlad
2026-06-18 20:43:07 +03:00
parent e7b7f48d35
commit a0a7d62b59
3 changed files with 141 additions and 121 deletions

View File

@@ -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);
@@ -87,19 +80,14 @@ export function SpaceSidebar() {
marginTop: 2,
marginBottom: 3,
}}
>
<Group
gap={4}
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<SwitchSpace
spaceId={space?.id}
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
onSettings={openSettings}
/>
</Group>
</div>
<div className={classes.section}>
@@ -123,55 +111,6 @@ export function SpaceSidebar() {
<span>{t("Overview")}</span>
</div>
</UnstyledButton>
<UnstyledButton
className={classes.menu}
onClick={searchSpotlight.open}
>
<div className={classes.menuItemInner}>
<IconSearch
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>{t("Search")}</span>
</div>
</UnstyledButton>
<UnstyledButton className={classes.menu} onClick={openSettings}>
<div className={classes.menuItemInner}>
<IconSettings
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>{t("Space settings")}</span>
</div>
</UnstyledButton>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<UnstyledButton
className={classes.menu}
onClick={() => {
handleCreatePage();
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<div className={classes.menuItemInner}>
<IconPlus
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>{t("New page")}</span>
</div>
</UnstyledButton>
)}
</div>
</div>

View File

@@ -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));
}

View File

@@ -1,55 +1,65 @@
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 (
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
opened={opened}
onChange={toggle}
trapFocus
returnFocus
>
<Popover.Target>
<Button
variant="subtle"
fullWidth
justify="space-between"
rightSection={opened ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
color="gray"
onClick={toggle}
>
<div className={classes.wrapper}>
<Group gap={6} wrap="nowrap" className={classes.header}>
<CustomAvatar
name={spaceName}
avatarUrl={spaceIcon}
@@ -58,20 +68,48 @@ export function SwitchSpace({
variant="filled"
size={20}
/>
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
<Text className={classes.spaceName} size="md" fw={600} lineClamp={1}>
{spaceName}
</Text>
</Button>
</Popover.Target>
<Popover.Dropdown>
<SpaceSelect
label={spaceName}
value={spaceSlug}
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
<Tooltip label={t("Space settings")} withArrow position="top">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onSettings}
aria-label={t("Space settings")}
>
<IconSettings size={18} stroke={2} />
</ActionIcon>
</Tooltip>
</Group>
<div className={classes.grid}>
{spaces.map((space: ISpace) => (
<UnstyledButton
key={space.id}
className={clsx(
classes.card,
space.slug === spaceSlug && classes.cardActive,
)}
onClick={() => handleSelect(space.slug)}
onMouseEnter={() => prefetchSpace(space.slug, space.id)}
title={space.name}
>
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={18}
/>
</Popover.Dropdown>
</Popover>
<Text className={classes.cardName} size="xs" fw={500} lineClamp={1}>
{space.name}
</Text>
</UnstyledButton>
))}
</div>
</div>
);
}