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,
|
IconFileExport,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSearch,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
@@ -27,7 +26,6 @@ import {
|
|||||||
} from "@/features/space/queries/space-watcher-query.ts";
|
} from "@/features/space/queries/space-watcher-query.ts";
|
||||||
import classes from "./space-sidebar.module.css";
|
import classes from "./space-sidebar.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
import { Link, useLocation, useParams } from "react-router-dom";
|
import { Link, useLocation, useParams } from "react-router-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -50,17 +48,12 @@ import {
|
|||||||
useAddFavoriteMutation,
|
useAddFavoriteMutation,
|
||||||
useRemoveFavoriteMutation,
|
useRemoveFavoriteMutation,
|
||||||
} from "@/features/favorite/queries/favorite-query";
|
} 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() {
|
export function SpaceSidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [opened, { open: openSettings, close: closeSettings }] =
|
const [opened, { open: openSettings, close: closeSettings }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
|
||||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
|
||||||
|
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
@@ -88,18 +81,13 @@ export function SpaceSidebar() {
|
|||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group
|
<SwitchSpace
|
||||||
gap={4}
|
spaceId={space?.id}
|
||||||
wrap="nowrap"
|
spaceName={space?.name}
|
||||||
justify="space-between"
|
spaceSlug={space?.slug}
|
||||||
style={{ width: "100%" }}
|
spaceIcon={space?.logo}
|
||||||
>
|
onSettings={openSettings}
|
||||||
<SwitchSpace
|
/>
|
||||||
spaceName={space?.name}
|
|
||||||
spaceSlug={space?.slug}
|
|
||||||
spaceIcon={space?.logo}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.section}>
|
<div className={classes.section}>
|
||||||
@@ -123,55 +111,6 @@ export function SpaceSidebar() {
|
|||||||
<span>{t("Overview")}</span>
|
<span>{t("Overview")}</span>
|
||||||
</div>
|
</div>
|
||||||
</UnstyledButton>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,48 @@
|
|||||||
.spaceName {
|
.wrapper {
|
||||||
width: 100%;
|
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));
|
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 classes from "./switch-space.module.css";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { SpaceSelect } from "./space-select";
|
|
||||||
import { getSpaceUrl } from "@/lib/config";
|
import { getSpaceUrl } from "@/lib/config";
|
||||||
import { Button, Popover, Text } from "@mantine/core";
|
import { ActionIcon, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
import { IconSettings } from "@tabler/icons-react";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
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 {
|
interface SwitchSpaceProps {
|
||||||
|
spaceId: string;
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
spaceIcon?: string;
|
spaceIcon?: string;
|
||||||
|
onSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchSpace({
|
export function SwitchSpace({
|
||||||
|
spaceId,
|
||||||
spaceName,
|
spaceName,
|
||||||
spaceSlug,
|
spaceSlug,
|
||||||
spaceIcon,
|
spaceIcon,
|
||||||
|
onSettings,
|
||||||
}: SwitchSpaceProps) {
|
}: SwitchSpaceProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
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) => {
|
// Sort spaces alphabetically by name for a stable, predictable grid.
|
||||||
if (value) {
|
const spaces = useMemo(() => {
|
||||||
navigate(getSpaceUrl(value));
|
const list = [...(data?.items ?? [])];
|
||||||
close();
|
// 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 (
|
return (
|
||||||
<Popover
|
<div className={classes.wrapper}>
|
||||||
width={300}
|
<Group gap={6} wrap="nowrap" className={classes.header}>
|
||||||
position="bottom"
|
<CustomAvatar
|
||||||
withArrow
|
name={spaceName}
|
||||||
shadow="md"
|
avatarUrl={spaceIcon}
|
||||||
opened={opened}
|
type={AvatarIconType.SPACE_ICON}
|
||||||
onChange={toggle}
|
color="initials"
|
||||||
trapFocus
|
variant="filled"
|
||||||
returnFocus
|
size={20}
|
||||||
>
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</Popover.Dropdown>
|
<Text className={classes.spaceName} size="md" fw={600} lineClamp={1}>
|
||||||
</Popover>
|
{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