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:
@@ -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,
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<SwitchSpace
|
||||
spaceName={space?.name}
|
||||
spaceSlug={space?.slug}
|
||||
spaceIcon={space?.logo}
|
||||
/>
|
||||
</Group>
|
||||
<SwitchSpace
|
||||
spaceId={space?.id}
|
||||
spaceName={space?.name}
|
||||
spaceSlug={space?.slug}
|
||||
spaceIcon={space?.logo}
|
||||
onSettings={openSettings}
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
>
|
||||
<CustomAvatar
|
||||
name={spaceName}
|
||||
avatarUrl={spaceIcon}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
|
||||
{spaceName}
|
||||
</Text>
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<SpaceSelect
|
||||
label={spaceName}
|
||||
value={spaceSlug}
|
||||
onChange={(space) => handleSelect(space.slug)}
|
||||
width={300}
|
||||
opened={true}
|
||||
<div className={classes.wrapper}>
|
||||
<Group gap={6} wrap="nowrap" className={classes.header}>
|
||||
<CustomAvatar
|
||||
name={spaceName}
|
||||
avatarUrl={spaceIcon}
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
color="initials"
|
||||
variant="filled"
|
||||
size={20}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Text className={classes.spaceName} size="md" fw={600} lineClamp={1}>
|
||||
{spaceName}
|
||||
</Text>
|
||||
<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}
|
||||
/>
|
||||
<Text className={classes.cardName} size="xs" fw={500} lineClamp={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user