page tree - wip

This commit is contained in:
Philipinho 2023-10-01 01:25:04 +01:00
parent cfb553d63a
commit e4099ad4c5
12 changed files with 703 additions and 11 deletions

View File

@ -27,14 +27,17 @@
"@tiptap/react": "^2.1.8",
"@tiptap/starter-kit": "^2.1.8",
"axios": "^1.4.0",
"clsx": "^2.0.0",
"jotai": "^2.3.1",
"jotai-optics": "^0.3.1",
"js-cookie": "^3.0.5",
"next": "13.5.3",
"react": "18.2.0",
"react-arborist": "^3.2.0",
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.1",
"typescript": "5.2.2",
"uuid": "^9.0.1",
"yjs": "^13.6.7",
"zod": "^3.22.2"
},

View File

@ -1,13 +1,12 @@
.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%;
width: rem(300px);
width: 100%;
padding: var(--mantine-spacing-md);
padding-top: 0;
display: flex;
flex-direction: column;
border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
user-select: none;
/*border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));*/
}
.section {
@ -44,7 +43,7 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
&: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));
}
}
@ -83,7 +82,7 @@
font-weight: 500;
&: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));
}
}

View File

@ -21,6 +21,8 @@ import { useAtom } from 'jotai';
import { settingsModalAtom } from '@/features/settings/modal/atoms/settings-modal-atom';
import SettingsModal from '@/features/settings/modal/settings-modal';
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 {
icon: React.ElementType;
@ -53,9 +55,9 @@ const pages: PageItem[] = [
export function Navbar() {
const [, setSettingsModalOpen] = useAtom(settingsModalAtom);
const [tree] = useAtom(treeApiAtom);
const handleMenuItemClick = (label: string) => {
if (label === 'Search') {
spotlight.open();
}
@ -65,12 +67,22 @@ export function Navbar() {
}
};
function handleCreatePage() {
tree?.create({ type: 'internal', index: 0 });
}
const primaryMenuItems = primaryMenu.map((menuItem) => (
<UnstyledButton key={menuItem.label} className={classes.menu}
onClick={() => handleMenuItemClick(menuItem.label)}
<UnstyledButton
key={menuItem.label}
className={classes.menu}
onClick={() => handleMenuItemClick(menuItem.label)}
>
<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>
</div>
</UnstyledButton>
@ -106,8 +118,13 @@ export function Navbar() {
<Text size="xs" fw={500} c="dimmed">
Pages
</Text>
<Tooltip label="Create page" withArrow position="right">
<ActionIcon variant="default" size={18}>
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
>
<IconPlus
style={{ width: rem(12), height: rem(12) }}
stroke={1.5}
@ -115,6 +132,11 @@ export function Navbar() {
</ActionIcon>
</Tooltip>
</Group>
<div className={classes.pages}>
<PageTree />
</div>
<div className={classes.pages}>{pageLinks}</div>
</div>
</nav>

View 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);

View File

@ -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>
);
});

View 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;
}
});
};
}

View 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: [],
},
];

View 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;
}

View 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);
}}
/>
);
}

View 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": []
}
]

View 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;
}

View File

@ -0,0 +1,9 @@
export type Data = {
id: string
name: string
icon?: string
slug?: string
selected?: boolean
children: Data[]
}