Merge branch 'feature/home-new-note' into develop

feat(home): prominent "New note" button on the dashboard
This commit is contained in:
claude_code
2026-06-22 02:19:07 +03:00
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>
);
}

View File

@@ -1,5 +1,6 @@
import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import NewNoteButton from "@/features/home/components/new-note-button";
import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@@ -16,6 +17,10 @@ export default function Home() {
</title>
</Helmet>
<Container size={"900"} pt="xl">
<NewNoteButton />
<Space h="xl" />
<SpaceCarousel />
<Space h="xl" />