Files
gitmost/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
claude_code 4f035b8e19 feat(client): declutter space sidebar and global header
- Remove the large active-space name header in the space sidebar;
  the active space stays highlighted in the spaces grid below.
- Move "Space settings" into the user avatar (top) menu next to
  "Workspace settings"; it shows only while viewing a space and is
  detected via useMatch("/s/:spaceSlug/*").
- Make the brand logo non-selectable/non-draggable (user-select:none
  on .brand, draggable=false on the img).
- Remove the redundant "Home" button next to the logo (the logo
  already links to /home).
- Remove the version label under the Settings sidebar menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:50:30 +03:00

321 lines
8.7 KiB
TypeScript

import {
ActionIcon,
Group,
Menu,
Text,
Tooltip,
} from "@mantine/core";
import {
IconArrowDown,
IconChevronsDown,
IconChevronsUp,
IconDots,
IconEye,
IconEyeOff,
IconFileExport,
IconPlus,
IconSettings,
IconStar,
IconStarFilled,
IconTrash,
} from "@tabler/icons-react";
import {
useSpaceWatchStatusQuery,
useWatchSpaceMutation,
useUnwatchSpaceMutation,
} from "@/features/space/queries/space-watcher-query.ts";
import classes from "./space-sidebar.module.css";
import React, { useRef } from "react";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { Link, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import SpaceTree, {
SpaceTreeApi,
} from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { useTranslation } from "react-i18next";
import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
import {
useFavoriteIds,
useAddFavoriteMutation,
useRemoveFavoriteMutation,
} from "@/features/favorite/queries/favorite-query";
export function SpaceSidebar() {
const { t } = useTranslation();
const [opened, { open: openSettings, close: closeSettings }] =
useDisclosure(false);
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const { handleCreate } = useTreeMutation(space?.id ?? "");
const treeRef = useRef<SpaceTreeApi | null>(null);
if (!space) {
return <></>;
}
function handleCreatePage() {
handleCreate(null);
}
return (
<>
<div className={classes.navbar}>
<div
className={classes.section}
style={{
border: "none",
marginTop: 2,
marginBottom: 3,
}}
>
<SwitchSpace
spaceId={space?.id}
spaceName={space?.name}
spaceSlug={space?.slug}
spaceIcon={space?.logo}
/>
</div>
<div className={clsx(classes.section, classes.sectionPages)}>
<Group className={classes.pagesHeader} justify="space-between">
<Text size="xs" fw={500} c="dimmed">
{t("Pages")}
</Text>
<Group gap="xs">
<SpaceMenu
spaceId={space.id}
canManagePages={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
onSpaceSettings={openSettings}
treeRef={treeRef}
/>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<Tooltip label={t("Create page")} withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label={t("Create page")}
>
<IconPlus />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<div className={classes.pages}>
<SpaceTree
ref={treeRef}
spaceId={space.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
/>
</div>
</div>
</div>
<SpaceSettingsModal
opened={opened}
onClose={closeSettings}
spaceId={space?.slug}
/>
</>
);
}
interface SpaceMenuProps {
spaceId: string;
canManagePages: boolean;
onSpaceSettings: () => void;
treeRef: React.RefObject<SpaceTreeApi | null>;
}
function SpaceMenu({
spaceId,
canManagePages,
onSpaceSettings,
treeRef,
}: SpaceMenuProps) {
const { t } = useTranslation();
const handleExpandAll = () => {
// Fire-and-forget: expandAll already surfaces its own error notification.
// The menu closes on click (consistent with Collapse all), so there is no
// in-menu loading state to track here.
treeRef.current?.expandAll();
};
const handleCollapseAll = () => {
treeRef.current?.collapseAll();
};
const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId);
const watchMutation = useWatchSpaceMutation();
const unwatchMutation = useUnwatchSpaceMutation();
const isWatching = watchStatus?.watching ?? false;
const favoriteIds = useFavoriteIds("space");
const addFavoriteMutation = useAddFavoriteMutation();
const removeFavoriteMutation = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(spaceId);
const handleToggleFavorite = () => {
const params = { type: "space" as const, spaceId };
if (isFavorited) {
removeFavoriteMutation.mutate(params);
} else {
addFavoriteMutation.mutate(params);
}
};
const handleToggleWatch = () => {
if (isWatching) {
unwatchMutation.mutate(spaceId);
} else {
watchMutation.mutate(spaceId);
}
};
return (
<>
<Menu width={200} shadow="md" withArrow>
<Menu.Target>
<Tooltip label={t("Space menu")} withArrow position="top">
<ActionIcon
variant="default"
size={18}
aria-label={t("Space menu")}
>
<IconDots />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleExpandAll}
leftSection={<IconChevronsDown size={16} />}
>
{t("Expand all")}
</Menu.Item>
<Menu.Item
onClick={handleCollapseAll}
leftSection={<IconChevronsUp size={16} />}
>
{t("Collapse all")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleToggleFavorite}
leftSection={
isFavorited ? (
<IconStarFilled
size={16}
color="var(--mantine-color-yellow-filled)"
/>
) : (
<IconStar size={16} />
)
}
>
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
</Menu.Item>
<Menu.Item
onClick={handleToggleWatch}
leftSection={
isWatching ? <IconEyeOff size={16} /> : <IconEye size={16} />
}
>
{isWatching ? t("Stop watching space") : t("Watch space")}
</Menu.Item>
{canManagePages && (
<>
<Menu.Divider />
<Menu.Item
onClick={openImportModal}
leftSection={<IconArrowDown size={16} />}
>
{t("Import pages")}
</Menu.Item>
<Menu.Item
onClick={openExportModal}
leftSection={<IconFileExport size={16} />}
>
{t("Export space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={onSpaceSettings}
leftSection={<IconSettings size={16} />}
>
{t("Space settings")}
</Menu.Item>
<Menu.Item
component={Link}
to={`/s/${spaceSlug}/trash`}
leftSection={<IconTrash size={16} />}
>
{t("Trash")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
{canManagePages && (
<>
<PageImportModal
spaceId={spaceId}
open={importOpened}
onClose={closeImportModal}
/>
<ExportModal
type="space"
id={spaceId}
open={exportOpened}
onClose={closeExportModal}
/>
</>
)}
</>
);
}