mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-24 05:31:19 +10:00
page tree - wip
This commit is contained in:
@ -27,14 +27,17 @@
|
|||||||
"@tiptap/react": "^2.1.8",
|
"@tiptap/react": "^2.1.8",
|
||||||
"@tiptap/starter-kit": "^2.1.8",
|
"@tiptap/starter-kit": "^2.1.8",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
"jotai": "^2.3.1",
|
"jotai": "^2.3.1",
|
||||||
"jotai-optics": "^0.3.1",
|
"jotai-optics": "^0.3.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "13.5.3",
|
"next": "13.5.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-arborist": "^3.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"yjs": "^13.6.7",
|
"yjs": "^13.6.7",
|
||||||
"zod": "^3.22.2"
|
"zod": "^3.22.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
.navbar {
|
.navbar {
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: rem(300px);
|
width: 100%;
|
||||||
padding: var(--mantine-spacing-md);
|
padding: var(--mantine-spacing-md);
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
/*border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));*/
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@ -44,7 +43,7 @@
|
|||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,7 +82,7 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import { useAtom } from 'jotai';
|
|||||||
import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom';
|
import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom';
|
||||||
import SettingsModal from '@/features/settings/modal/settings-modal';
|
import SettingsModal from '@/features/settings/modal/settings-modal';
|
||||||
import { SearchSpotlight } from '@/features/search/search-spotlight';
|
import { SearchSpotlight } from '@/features/search/search-spotlight';
|
||||||
|
import PageTree from '@/features/page/tree/page-tree';
|
||||||
|
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom';
|
||||||
|
|
||||||
interface PrimaryMenuItem {
|
interface PrimaryMenuItem {
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
@ -53,9 +55,9 @@ const pages: PageItem[] = [
|
|||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [, setSettingsModalOpen] = useAtom(settingsModalAtom);
|
const [, setSettingsModalOpen] = useAtom(settingsModalAtom);
|
||||||
|
const [tree] = useAtom(treeApiAtom);
|
||||||
|
|
||||||
const handleMenuItemClick = (label: string) => {
|
const handleMenuItemClick = (label: string) => {
|
||||||
|
|
||||||
if (label === 'Search') {
|
if (label === 'Search') {
|
||||||
spotlight.open();
|
spotlight.open();
|
||||||
}
|
}
|
||||||
@ -65,12 +67,22 @@ export function Navbar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function handleCreatePage() {
|
||||||
|
tree?.create({ type: 'internal', index: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
const primaryMenuItems = primaryMenu.map((menuItem) => (
|
const primaryMenuItems = primaryMenu.map((menuItem) => (
|
||||||
<UnstyledButton key={menuItem.label} className={classes.menu}
|
<UnstyledButton
|
||||||
onClick={() => handleMenuItemClick(menuItem.label)}
|
key={menuItem.label}
|
||||||
|
className={classes.menu}
|
||||||
|
onClick={() => handleMenuItemClick(menuItem.label)}
|
||||||
>
|
>
|
||||||
<div className={classes.menuItemInner}>
|
<div className={classes.menuItemInner}>
|
||||||
<menuItem.icon size={20} className={classes.menuItemIcon} stroke={1.5} />
|
<menuItem.icon
|
||||||
|
size={20}
|
||||||
|
className={classes.menuItemIcon}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
<span>{menuItem.label}</span>
|
<span>{menuItem.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@ -106,8 +118,13 @@ export function Navbar() {
|
|||||||
<Text size="xs" fw={500} c="dimmed">
|
<Text size="xs" fw={500} c="dimmed">
|
||||||
Pages
|
Pages
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Tooltip label="Create page" withArrow position="right">
|
<Tooltip label="Create page" withArrow position="right">
|
||||||
<ActionIcon variant="default" size={18}>
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size={18}
|
||||||
|
onClick={handleCreatePage}
|
||||||
|
>
|
||||||
<IconPlus
|
<IconPlus
|
||||||
style={{ width: rem(12), height: rem(12) }}
|
style={{ width: rem(12), height: rem(12) }}
|
||||||
stroke={1.5}
|
stroke={1.5}
|
||||||
@ -115,6 +132,11 @@ export function Navbar() {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<div className={classes.pages}>
|
||||||
|
<PageTree />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={classes.pages}>{pageLinks}</div>
|
<div className={classes.pages}>{pageLinks}</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
5
frontend/src/features/page/tree/atoms/tree-api-atom.ts
Normal file
5
frontend/src/features/page/tree/atoms/tree-api-atom.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import { TreeApi } from 'react-arborist';
|
||||||
|
import { Data } from "../types";
|
||||||
|
|
||||||
|
export const treeApiAtom = atom<TreeApi<Data> | null>(null);
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import React, { ReactElement } from 'react';
|
||||||
|
import mergeRefs from './merge-refs';
|
||||||
|
import { useElementSize } from '@mantine/hooks';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: (dimens: { width: number; height: number }) => ReactElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FillFlexParent = React.forwardRef(function FillFlexParent(
|
||||||
|
props: Props,
|
||||||
|
forwardRef
|
||||||
|
) {
|
||||||
|
const { ref, width, height } = useElementSize();
|
||||||
|
return (
|
||||||
|
<div style={style} ref={mergeRefs(ref, forwardRef)}>
|
||||||
|
{width && height ? props.children({ width, height }) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
15
frontend/src/features/page/tree/components/merge-refs.ts
Normal file
15
frontend/src/features/page/tree/components/merge-refs.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type AnyRef = React.MutableRefObject<any> | React.RefCallback<any> | null;
|
||||||
|
|
||||||
|
export default function mergeRefs(...refs: AnyRef[]) {
|
||||||
|
return (instance: any) => {
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(instance);
|
||||||
|
} else if (ref != null) {
|
||||||
|
ref.current = instance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
117
frontend/src/features/page/tree/data.ts
Normal file
117
frontend/src/features/page/tree/data.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { Data } from "./types";
|
||||||
|
|
||||||
|
export const pageData: Data[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Homehvhjjjgjggjgjjghfjgjghhyryrtttttttygchcghcghghvcctgccrtrtcrtrr',
|
||||||
|
icon: 'home',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'About Us',
|
||||||
|
icon: 'info',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '2-1',
|
||||||
|
name: 'History',
|
||||||
|
icon: 'history',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2-2',
|
||||||
|
name: 'Team',
|
||||||
|
icon: 'group',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '2-2-1',
|
||||||
|
name: 'Members',
|
||||||
|
icon: 'person',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2-2-2',
|
||||||
|
name: 'Join Us',
|
||||||
|
icon: 'person_add',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Services',
|
||||||
|
icon: 'services',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Contact',
|
||||||
|
icon: 'contact_mail',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: 'Blog',
|
||||||
|
icon: 'blog',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '5-1',
|
||||||
|
name: 'Latest Posts',
|
||||||
|
icon: 'post',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5-2',
|
||||||
|
name: 'Categories',
|
||||||
|
icon: 'category',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '5-2-1',
|
||||||
|
name: 'Tech',
|
||||||
|
icon: 'laptop',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '5-2-1-1',
|
||||||
|
name: 'Programming',
|
||||||
|
icon: 'code',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
name: 'Support',
|
||||||
|
icon: 'support',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
name: 'FAQ',
|
||||||
|
icon: 'faq',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
name: 'Shop',
|
||||||
|
icon: 'shop',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
name: 'Testimonials',
|
||||||
|
icon: 'testimonials',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
name: 'Careers',
|
||||||
|
icon: 'career',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
67
frontend/src/features/page/tree/hooks/use-dynamic-tree.ts
Normal file
67
frontend/src/features/page/tree/hooks/use-dynamic-tree.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CreateHandler,
|
||||||
|
DeleteHandler,
|
||||||
|
MoveHandler,
|
||||||
|
RenameHandler,
|
||||||
|
SimpleTree,
|
||||||
|
} from 'react-arborist';
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function useDynamicTree<T>() {
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const tree = useMemo(
|
||||||
|
() =>
|
||||||
|
new SimpleTree<// @ts-ignore
|
||||||
|
T>(data),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMove: MoveHandler<T> = (args: {
|
||||||
|
dragIds: string[];
|
||||||
|
parentId: null | string;
|
||||||
|
index: number;
|
||||||
|
}) => {
|
||||||
|
for (const id of args.dragIds) {
|
||||||
|
tree.move({ id, parentId: args.parentId, index: args.index });
|
||||||
|
}
|
||||||
|
setData(tree.data);
|
||||||
|
|
||||||
|
// reparent pages in db on move
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRename: RenameHandler<T> = ({ name, id }) => {
|
||||||
|
tree.update({ id, changes: { name } as any });
|
||||||
|
setData(tree.data);
|
||||||
|
|
||||||
|
console.log('new title: ' + name + ' for ' + id )
|
||||||
|
// use jotai to store the title in an atom
|
||||||
|
// on rename, persist to db
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreate: CreateHandler<T> = ({ parentId, index, type }) => {
|
||||||
|
const data = { id: `id-${nextId++}`, name: '' } as any;
|
||||||
|
//if (type === 'internal')
|
||||||
|
data.children = []; // all nodes are internal
|
||||||
|
tree.create({ parentId, index, data });
|
||||||
|
setData(tree.data);
|
||||||
|
|
||||||
|
// oncreate, create new page on db
|
||||||
|
// figure out the id for new pages
|
||||||
|
// perhaps persist the uuid to the create page endpoint
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete: DeleteHandler<T> = (args: { ids: string[] }) => {
|
||||||
|
args.ids.forEach((id) => tree.drop({ id }));
|
||||||
|
setData(tree.data);
|
||||||
|
// delete page by id from db
|
||||||
|
};
|
||||||
|
|
||||||
|
const controllers = { onMove, onRename, onCreate, onDelete };
|
||||||
|
|
||||||
|
return { data, setData, controllers } as const;
|
||||||
|
}
|
||||||
214
frontend/src/features/page/tree/page-tree.tsx
Normal file
214
frontend/src/features/page/tree/page-tree.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { NodeApi, NodeRendererProps, Tree, TreeApi } from 'react-arborist';
|
||||||
|
import { pageData } from '@/features/page/tree/data';
|
||||||
|
import {
|
||||||
|
IconArrowsLeftRight,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronRight,
|
||||||
|
IconCornerRightUp,
|
||||||
|
IconDots,
|
||||||
|
IconDotsVertical,
|
||||||
|
IconEdit,
|
||||||
|
IconFileDescription,
|
||||||
|
IconLink,
|
||||||
|
IconPlus,
|
||||||
|
IconStar,
|
||||||
|
IconTrash,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import styles from './tree.module.css';
|
||||||
|
import { ActionIcon, Menu, rem } from '@mantine/core';
|
||||||
|
import { atom, useAtom } from 'jotai';
|
||||||
|
import { useDynamicTree } from './hooks/use-dynamic-tree';
|
||||||
|
import { FillFlexParent } from './components/fill-flex-parent';
|
||||||
|
import { Data } from './types';
|
||||||
|
import { treeApiAtom } from './atoms/tree-api-atom';
|
||||||
|
|
||||||
|
export default function PageTree() {
|
||||||
|
const { data, setData, controllers } = useDynamicTree();
|
||||||
|
|
||||||
|
const [, setTree] = useAtom<TreeApi<Data>>(treeApiAtom);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setData(pageData);
|
||||||
|
}, [setData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.treeContainer}>
|
||||||
|
<FillFlexParent>
|
||||||
|
{(dimens) => (
|
||||||
|
<Tree
|
||||||
|
data={data}
|
||||||
|
{...controllers}
|
||||||
|
{...dimens}
|
||||||
|
// @ts-ignore
|
||||||
|
ref={(t) => setTree(t)}
|
||||||
|
openByDefault={false}
|
||||||
|
disableMultiSelection={true}
|
||||||
|
className={styles.tree}
|
||||||
|
rowClassName={styles.row}
|
||||||
|
padding={15}
|
||||||
|
rowHeight={30}
|
||||||
|
overscanCount={5}
|
||||||
|
>
|
||||||
|
{Node}
|
||||||
|
</Tree>
|
||||||
|
)}
|
||||||
|
</FillFlexParent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={clsx(styles.node, node.state)}
|
||||||
|
ref={dragHandle}
|
||||||
|
>
|
||||||
|
<PageArrow node={node} />
|
||||||
|
|
||||||
|
<IconFileDescription size="18px" style={{ marginRight: '4px' }} />
|
||||||
|
|
||||||
|
<span className={styles.text}>
|
||||||
|
{node.isEditing ? (
|
||||||
|
<Input node={node} />
|
||||||
|
) : (
|
||||||
|
node.data.name || 'untitled'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<NodeMenu node={node} />
|
||||||
|
<CreateNode node={node} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateNode({ node }: { node: NodeApi<Data> }) {
|
||||||
|
const [tree] = useAtom(treeApiAtom);
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
tree?.create({ type: 'internal', parentId: node.id, index: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon variant="transparent" color="gray" onClick={handleCreate}>
|
||||||
|
<IconPlus style={{ width: rem(20), height: rem(20) }} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeMenu({ node }: { node: NodeApi<Data> }) {
|
||||||
|
const [tree] = useAtom(treeApiAtom);
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
const sib = node.nextSibling;
|
||||||
|
const parent = node.parent;
|
||||||
|
tree?.focus(sib || parent, { scroll: false });
|
||||||
|
tree?.delete(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu shadow="md" width={200}>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconDotsVertical
|
||||||
|
style={{ width: rem(20), height: rem(20) }}
|
||||||
|
stroke={2}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconEdit style={{ width: rem(14), height: rem(14) }} />}
|
||||||
|
onClick={() => node.edit()}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
||||||
|
>
|
||||||
|
Favorite
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={
|
||||||
|
<IconCornerRightUp style={{ width: rem(14), height: rem(14) }} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Move
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={
|
||||||
|
<IconArrowsLeftRight style={{ width: rem(14), height: rem(14) }} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
color="red"
|
||||||
|
leftSection={
|
||||||
|
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||||
|
}
|
||||||
|
onClick={() => handleDelete()}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageArrow({ node }: { node: NodeApi<Data> }) {
|
||||||
|
return (
|
||||||
|
<span onClick={() => node.toggle()}>
|
||||||
|
{node.isInternal ? (
|
||||||
|
node.children && node.children.length > 0 ? (
|
||||||
|
node.isOpen ? (
|
||||||
|
<IconChevronDown size={18} />
|
||||||
|
) : (
|
||||||
|
<IconChevronRight size={18} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<IconChevronRight size={18} style={{ visibility: 'hidden' }} />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Input({ node }: { node: NodeApi<Data> }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="untitled"
|
||||||
|
defaultValue={node.data.name}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onBlur={() => node.reset()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') node.reset();
|
||||||
|
if (e.key === 'Enter') node.submit(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/src/features/page/tree/tree.json
Normal file
115
frontend/src/features/page/tree/tree.json
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"title": "Home",
|
||||||
|
"icon": "home",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"title": "About Us",
|
||||||
|
"icon": "info",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "2-1",
|
||||||
|
"title": "History",
|
||||||
|
"icon": "history",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2-2",
|
||||||
|
"title": "Team",
|
||||||
|
"icon": "group",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "2-2-1",
|
||||||
|
"title": "Members",
|
||||||
|
"icon": "person",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2-2-2",
|
||||||
|
"title": "Join Us",
|
||||||
|
"icon": "person_add",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"title": "Services",
|
||||||
|
"icon": "services",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"title": "Contact",
|
||||||
|
"icon": "contact_mail",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"title": "Blog",
|
||||||
|
"icon": "blog",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "5-1",
|
||||||
|
"title": "Latest Posts",
|
||||||
|
"icon": "post",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5-2",
|
||||||
|
"title": "Categories",
|
||||||
|
"icon": "category",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "5-2-1",
|
||||||
|
"title": "Tech",
|
||||||
|
"icon": "laptop",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "5-2-1-1",
|
||||||
|
"title": "Programming",
|
||||||
|
"icon": "code",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6",
|
||||||
|
"title": "Support",
|
||||||
|
"icon": "support",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7",
|
||||||
|
"title": "FAQ",
|
||||||
|
"icon": "faq",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8",
|
||||||
|
"title": "Shop",
|
||||||
|
"icon": "shop",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9",
|
||||||
|
"title": "Testimonials",
|
||||||
|
"icon": "testimonials",
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10",
|
||||||
|
"title": "Careers",
|
||||||
|
"icon": "career",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
99
frontend/src/features/page/tree/tree.module.css
Normal file
99
frontend/src/features/page/tree/tree.module.css
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
.tree {
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeContainer {
|
||||||
|
display: flex;
|
||||||
|
height: 50vh;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .actions {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.node:global(.willReceiveDrop) {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-gray-7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.node:global(.isSelected) {
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||||
|
/*
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
// background-color: light-dark(
|
||||||
|
// var(--mantine-color-gray-0),
|
||||||
|
// var(--mantine-color-dark-6)
|
||||||
|
//);
|
||||||
|
//background: rgb(20, 127, 250, 0.5);*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.node:global(.isSelectedStart.isSelectedEnd) {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus .node:global(.isSelected) {
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:focus .node {
|
||||||
|
/** come back to this **/
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin: 0 rem(10px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: rem(14px);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
9
frontend/src/features/page/tree/types.ts
Normal file
9
frontend/src/features/page/tree/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export type Data = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
slug?: string
|
||||||
|
selected?: boolean
|
||||||
|
children: Data[]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user