refactor layout

* ui polishing
* frontend and backend fixes
This commit is contained in:
Philipinho
2024-05-31 21:51:44 +01:00
parent 046dd6d150
commit 06d854a7d2
95 changed files with 1548 additions and 821 deletions

View File

@ -0,0 +1,23 @@
.breadcrumbs {
flex: 1 1 auto;
display: flex;
align-items: center;
overflow: hidden;
a {
color: var(--mantine-color-default-color);
}
.mantine-Breadcrumbs-breadcrumb {
min-width: 1px;
overflow: hidden;
}
}
.truncatedText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}

View File

@ -0,0 +1,118 @@
import { useAtomValue } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import React, { useEffect, useState } from "react";
import { findBreadcrumbPath } from "@/features/page/tree/utils";
import {
Button,
Anchor,
Popover,
Breadcrumbs,
ActionIcon,
Text,
} from "@mantine/core";
import { IconDots } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
function getTitle(name: string, icon: string) {
if (icon) {
return `${icon} ${name}`;
}
return name;
}
export default function Breadcrumb() {
const treeData = useAtomValue(treeDataAtom);
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
SpaceTreeNode[] | null
>(null);
const { pageSlug, spaceSlug } = useParams();
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
useEffect(() => {
if (treeData?.length > 0 && currentPage) {
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
setBreadcrumbNodes(breadcrumb || null);
}
}, [currentPage?.id, treeData]);
const HiddenNodesTooltipContent = () =>
breadcrumbNodes?.slice(1, -2).map((node) => (
<Button.Group orientation="vertical" key={node.id}>
<Button
justify="start"
component={Link}
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
variant="default"
style={{ border: "none" }}
>
<Text fz={"sm"} className={classes.truncatedText}>
{getTitle(node.name, node.icon)}
</Text>
</Button>
</Button.Group>
));
const renderAnchor = (node: SpaceTreeNode) => (
<Anchor
component={Link}
to={buildPageUrl(spaceSlug, node.slugId, node.name)}
underline="never"
fz={"sm"}
key={node.id}
className={classes.truncatedText}
>
{getTitle(node.name, node.icon)}
</Anchor>
);
const getBreadcrumbItems = () => {
if (!breadcrumbNodes) return [];
if (breadcrumbNodes.length > 3) {
const firstNode = breadcrumbNodes[0];
const secondLastNode = breadcrumbNodes[breadcrumbNodes.length - 2];
const lastNode = breadcrumbNodes[breadcrumbNodes.length - 1];
return [
renderAnchor(firstNode),
<Popover
width={250}
position="bottom"
withArrow
shadow="xl"
key="hidden-nodes"
>
<Popover.Target>
<ActionIcon color="gray" variant="transparent">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<HiddenNodesTooltipContent />
</Popover.Dropdown>
</Popover>,
renderAnchor(secondLastNode),
renderAnchor(lastNode),
];
}
return breadcrumbNodes.map(renderAnchor);
};
return (
<div style={{ overflow: "hidden" }}>
{breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}>
{getBreadcrumbItems()}
</Breadcrumbs>
)}
</div>
);
}

View File

@ -0,0 +1,110 @@
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconDots,
IconHistory,
IconLink,
IconMessage,
IconTrash,
} from "@tabler/icons-react";
import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard } from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
export default function PageHeaderMenu() {
const toggleAside = useToggleAside();
return (
<>
<Tooltip label="Comments" openDelay={250} withArrow>
<ActionIcon
variant="default"
style={{ border: "none" }}
onClick={() => toggleAside("comments")}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
<PageActionMenu />
</>
);
}
function PageActionMenu() {
const [, setHistoryModalOpen] = useAtom(historyAtoms);
const clipboard = useClipboard({ timeout: 500 });
const { pageSlug, spaceSlug } = useParams();
const { data: page, isLoading } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
const { openDeleteModal } = useDeletePageModal();
const [tree] = useAtom(treeApiAtom);
const handleCopyLink = () => {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, page.slugId, page.title);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
};
const openHistoryModal = () => {
setHistoryModalOpen(true);
};
const handleDeletePage = () => {
openDeleteModal({ onConfirm: () => tree?.delete(page.id) });
};
return (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="default" style={{ border: "none" }}>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} stroke={2} />}
onClick={handleCopyLink}
>
Copy link
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconHistory size={16} stroke={2} />}
onClick={openHistoryModal}
>
Page history
</Menu.Item>
<Menu.Divider />
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} stroke={2} />}
onClick={handleDeletePage}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}

View File

@ -0,0 +1,11 @@
.header {
height: 45px;
background-color: var(--mantine-color-body);
padding-left: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-md);
position: fixed;
z-index: 99;
top: var(--app-shell-header-offset, 0rem);
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
inset-inline-end: var(--app-shell-aside-offset, 0rem);
}

View File

@ -0,0 +1,18 @@
import classes from "./page-header.module.css";
import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx";
import { Group } from "@mantine/core";
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
export default function PageHeader() {
return (
<div className={classes.header}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap">
<PageHeaderMenu />
</Group>
</Group>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
type UseDeleteModalProps = {
onConfirm: () => void;
};
export function useDeletePageModal() {
const openDeleteModal = ({ onConfirm }: UseDeleteModalProps) => {
modals.openConfirmModal({
title: "Are you sure you want to delete this page?",
children: (
<Text size="sm">
Are you sure you want to delete this page? This will delete its
children and page history. This action is irreversible.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm,
});
};
return { openDeleteModal } as const;
}

View File

@ -1,15 +1,23 @@
import slugify from "@sindresorhus/slugify";
export const buildPageSlug = (
pageShortId: string,
pageTitle?: string,
): string => {
const titleSlug = slugify(pageTitle?.substring(0, 99) || "untitled", {
const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", {
customReplacements: [
["♥", ""],
["🦄", ""],
],
});
return `/p/${pageShortId}/${titleSlug}`;
return `p/${titleSlug}-${pageSlugId}`;
};
export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
): string => {
if (spaceName === undefined) {
return `/${buildPageSlug(pageSlugId, pageTitle)}`;
}
return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`;
};

View File

@ -10,14 +10,15 @@ import {
deletePage,
getPageById,
getSidebarPages,
getRecentChanges,
updatePage,
movePage,
getPageBreadcrumbs,
getRecentChanges,
} from "@/features/page/services/page-service";
import {
IMovePage,
IPage,
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
@ -25,32 +26,19 @@ import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
const RECENT_CHANGES_KEY = ["recentChanges"];
export function usePageQuery(
pageIdOrSlugId: string,
pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> {
return useQuery({
queryKey: ["pages", pageIdOrSlugId],
queryFn: () => getPageById(pageIdOrSlugId),
enabled: !!pageIdOrSlugId,
queryKey: ["pages", pageInput.pageId],
queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000,
});
}
export function useRecentChangesQuery(): UseQueryResult<
IPagination<IPage>,
Error
> {
return useQuery({
queryKey: RECENT_CHANGES_KEY,
queryFn: () => getRecentChanges(),
refetchOnMount: true,
});
}
export function useCreatePageMutation() {
return useMutation<IPage, Error, Partial<IPage>>({
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => createPage(data),
onSuccess: (data) => {},
onError: (error) => {
@ -61,7 +49,7 @@ export function useCreatePageMutation() {
export function useUpdatePageMutation() {
const queryClient = useQueryClient();
return useMutation<IPage, Error, Partial<IPage>>({
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
// update page in cache
@ -130,3 +118,13 @@ export async function fetchAncestorChildren(params: SidebarPagesParams) {
});
return buildTree(response.items);
}
export function useRecentChangesQuery(
spaceId?: string,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
queryKey: ["recent-changes", spaceId],
queryFn: () => getRecentChanges(spaceId),
refetchOnMount: true,
});
}

View File

@ -2,6 +2,7 @@ import api from "@/lib/api-client";
import {
IMovePage,
IPage,
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
@ -11,12 +12,14 @@ export async function createPage(data: Partial<IPage>): Promise<IPage> {
return req.data;
}
export async function getPageById(pageId: string): Promise<IPage> {
const req = await api.post<IPage>("/pages/info", { pageId });
export async function getPageById(
pageInput: Partial<IPageInput>,
): Promise<IPage> {
const req = await api.post<IPage>("/pages/info", pageInput);
return req.data;
}
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
const req = await api.post<IPage>("/pages/update", data);
return req.data;
}
@ -29,11 +32,6 @@ export async function movePage(data: IMovePage): Promise<void> {
await api.post<void>("/pages/move", data);
}
export async function getRecentChanges(): Promise<IPagination<IPage>> {
const req = await api.post("/pages/recent");
return req.data;
}
export async function getSidebarPages(
params: SidebarPagesParams,
): Promise<IPagination<IPage>> {
@ -47,3 +45,10 @@ export async function getPageBreadcrumbs(
const req = await api.post("/pages/breadcrumbs", { pageId });
return req.data;
}
export async function getRecentChanges(
spaceId?: string,
): Promise<IPagination<IPage>> {
const req = await api.post("/pages/recent", { spaceId });
return req.data;
}

View File

@ -1,31 +0,0 @@
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Box } from "@mantine/core";
import { IconNotes } from "@tabler/icons-react";
import React from "react";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { TreeCollapse } from "@/features/page/tree/components/tree-collapse.tsx";
export default function SpaceContent() {
const [currentUser] = useAtom(currentUserAtom);
const { data: space } = useSpaceQuery(currentUser?.workspace.defaultSpaceId);
if (!space) {
return <div>Loading...</div>;
}
return (
<>
<Box p="sm" mx="auto">
<TreeCollapse
initiallyOpened={true}
icon={IconNotes}
label={space.name}
>
<SpaceTree spaceId={space.id} />
</TreeCollapse>
</Box>
</>
);
}

View File

@ -8,9 +8,9 @@ import {
useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts";
import React, { useEffect, useRef } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Menu, rem, Text } from "@mantine/core";
import { ActionIcon, Menu, rem } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
@ -42,10 +42,11 @@ import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { modals } from "@mantine/modals";
import APP_ROUTE from "@/lib/app-route.ts";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
interface SpaceTreeProps {
spaceId: string;
@ -54,7 +55,7 @@ interface SpaceTreeProps {
const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
const { slugId } = useParams();
const { pageSlug } = useParams();
const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
const {
@ -72,20 +73,21 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const isDataLoaded = useRef(false);
const { data: currentPage } = usePageQuery(slugId);
const location = useLocation();
const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
useEffect(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
}, [hasNextPage, fetchNextPage, isFetching]);
}, [hasNextPage, fetchNextPage, isFetching, spaceId]);
useEffect(() => {
if (pagesData?.pages && !hasNextPage) {
const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems);
if (data.length < 1) {
if (data.length < 1 || data?.[0].spaceId !== spaceId) {
//Thoughts
// don't reset if there is data in state
// we only expect to call this once on initial load
@ -94,6 +96,7 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
// which looses async loaded children too
setData(treeData);
isDataLoaded.current = true;
setOpenTreeNodes({});
}
}
}, [pagesData, hasNextPage]);
@ -166,7 +169,10 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
useEffect(() => {
if (currentPage?.id) {
treeApiRef.current?.select(currentPage.id, { align: "auto" });
setTimeout(() => {
// focus on node and open all parents
treeApiRef.current?.select(currentPage.id, { align: "auto" });
}, 200);
} else {
treeApiRef.current?.deselectAll();
}
@ -212,6 +218,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit();
const { spaceSlug } = useParams();
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
if (!node.data.hasChildren) return;
@ -228,7 +235,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const newChildren = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getSidebarPages(params),
staleTime: 30 * 60 * 1000,
staleTime: 10 * 60 * 1000,
});
const childrenTree = buildTree(newChildren.items);
@ -246,7 +253,8 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
}
const handleClick = () => {
navigate(buildPageSlug(node.data.slugId, node.data.name));
const pageUrl = buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
navigate(pageUrl);
};
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
@ -381,29 +389,16 @@ interface NodeMenuProps {
function NodeMenu({ node, treeApi }: NodeMenuProps) {
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const handleCopyLink = () => {
const pageLink =
window.location.host + buildPageSlug(node.data.id, node.data.name);
clipboard.copy(pageLink);
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.data.slugId, node.data.name);
clipboard.copy(pageUrl);
notifications.show({ message: "Link copied" });
};
const openDeleteModal = () =>
modals.openConfirmModal({
title: "Are you sure you want to delete this page?",
children: (
<Text size="sm">
Are you sure you want to delete this page? This action is
irreversible.
</Text>
),
centered: true,
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: () => treeApi?.delete(node),
});
return (
<Menu shadow="md" width={200}>
<Menu.Target>
@ -440,7 +435,9 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
onClick={openDeleteModal}
onClick={() =>
openDeleteModal({ onConfirm: () => treeApi?.delete(node) })
}
>
Delete
</Menu.Item>

View File

@ -10,7 +10,7 @@ import {
import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useNavigate } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
useCreatePageMutation,
useDeletePageMutation,
@ -19,7 +19,8 @@ import {
} from "@/features/page/queries/page-query.ts";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { buildPageSlug } from "@/features/page/page.utils.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts";
export function useTreeMutation<T>(spaceId: string) {
const [data, setData] = useAtom(treeDataAtom);
@ -29,6 +30,7 @@ export function useTreeMutation<T>(spaceId: string) {
const deletePageMutation = useDeletePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug } = useParams();
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
const payload: { spaceId: string; parentPageId?: string } = {
@ -65,7 +67,12 @@ export function useTreeMutation<T>(spaceId: string) {
tree.create({ parentId, index, data });
setData(tree.data);
navigate(buildPageSlug(createdPage.slugId, createdPage.title));
const pageUrl = buildPageUrl(
spaceSlug,
createdPage.slugId,
createdPage.title,
);
navigate(pageUrl);
return data;
};
@ -173,10 +180,12 @@ export function useTreeMutation<T>(spaceId: string) {
try {
await deletePageMutation.mutateAsync(args.ids[0]);
tree.drop({ id: args.ids[0] });
setData(tree.data);
if (tree.find(args.ids[0])) {
tree.drop({ id: args.ids[0] });
setData(tree.data);
}
navigate("/home");
navigate(getSpaceUrl(spaceSlug));
} catch (error) {
console.error("Failed to delete page:", error);
}

View File

@ -1,3 +1,5 @@
import { ISpace } from "@/features/space/types/space.types.ts";
export interface IPage {
id: string;
slugId: string;
@ -17,7 +19,7 @@ export interface IPage {
deletedAt: Date;
position: string;
hasChildren: boolean;
pageId: string;
space: Partial<ISpace>;
}
export interface IMovePage {
@ -33,3 +35,12 @@ export interface SidebarPagesParams {
pageId?: string;
page?: number; // pagination
}
export interface IPageInput {
pageId: string;
title: string;
parentPageId: string;
icon: string;
coverPhoto: string;
position: string;
}