feat(home): add prominent "New note" button to dashboard

Add a big "New note" action to the Home screen that creates a new page
and opens it. Since the home screen has no active space, the target
space is resolved from the user's writable spaces (CASL Manage/Page
gate, mirroring the space sidebar): created directly when there is one
writable space, picked from a dropdown when there are several, hidden
when there are none. Menu items are disabled while a create is in
flight to avoid duplicate pages.

- New component features/home/components/new-note-button.tsx
- Render it at the top of pages/dashboard/home.tsx (above the carousel)
- Add i18n keys "New note" / "Create in space" to en-US and ru-RU

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-22 02:18:31 +03:00
parent c83343d3a3
commit 6d0ee6c61f
4 changed files with 120 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import { Button, Menu, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { createMongoAbility } from "@casl/ability";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { ISpace } from "@/features/space/types/space.types.ts";
import {
SpaceAbility,
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
// A space is writable when the member's CASL rules allow managing pages —
// mirrors the create-page gate used in the space sidebar.
function canCreatePage(space: ISpace): boolean {
const ability = createMongoAbility<SpaceAbility>(
(space.membership?.permissions ?? []) as any,
);
return ability.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
}
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several.
export default function NewNoteButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => {
try {
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared `IPageInput` type; cast to satisfy the mutation signature.
const createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
} as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
// useCreatePageMutation already surfaces a red notification on error.
}
};
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
const isPending = createPageMutation.isPending;
// Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) {
return (
<Button
fullWidth
size="md"
leftSection={<IconPlus size={18} />}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
>
{t("New note")}
</Button>
);
}
// Multiple writable spaces → pick the target space from a dropdown.
return (
<Menu shadow="md" width="target" position="bottom-start">
<Menu.Target>
<Button
fullWidth
size="md"
leftSection={<IconPlus size={18} />}
loading={isPending}
>
{t("New note")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Create in space")}</Menu.Label>
{writableSpaces.map((space) => (
<Menu.Item
key={space.id}
disabled={isPending}
leftSection={
<CustomAvatar
name={space.name}
avatarUrl={space.logo}
type={AvatarIconType.SPACE_ICON}
color="initials"
variant="filled"
size={20}
/>
}
onClick={() => createNote(space)}
>
<Text size="sm" lineClamp={1}>
{space.name}
</Text>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}