diff --git a/frontend/package.json b/frontend/package.json index fa3ee5e..7e0a5a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/components/navbar/navbar.module.css b/frontend/src/components/navbar/navbar.module.css index ea1f85a..c113220 100644 --- a/frontend/src/components/navbar/navbar.module.css +++ b/frontend/src/components/navbar/navbar.module.css @@ -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)); } } diff --git a/frontend/src/components/navbar/navbar.tsx b/frontend/src/components/navbar/navbar.tsx index e30749a..134a719 100644 --- a/frontend/src/components/navbar/navbar.tsx +++ b/frontend/src/components/navbar/navbar.tsx @@ -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) => ( - handleMenuItemClick(menuItem.label)} + handleMenuItemClick(menuItem.label)} >
- + {menuItem.label}
@@ -106,8 +118,13 @@ export function Navbar() { Pages + - + + +
+ +
+
{pageLinks}
diff --git a/frontend/src/features/page/tree/atoms/tree-api-atom.ts b/frontend/src/features/page/tree/atoms/tree-api-atom.ts new file mode 100644 index 0000000..8ac2a67 --- /dev/null +++ b/frontend/src/features/page/tree/atoms/tree-api-atom.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; +import { TreeApi } from 'react-arborist'; +import { Data } from "../types"; + +export const treeApiAtom = atom | null>(null); \ No newline at end of file diff --git a/frontend/src/features/page/tree/components/fill-flex-parent.tsx b/frontend/src/features/page/tree/components/fill-flex-parent.tsx new file mode 100644 index 0000000..7d88a6b --- /dev/null +++ b/frontend/src/features/page/tree/components/fill-flex-parent.tsx @@ -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 ( +
+ {width && height ? props.children({ width, height }) : null} +
+ ); +}); diff --git a/frontend/src/features/page/tree/components/merge-refs.ts b/frontend/src/features/page/tree/components/merge-refs.ts new file mode 100644 index 0000000..1f974d2 --- /dev/null +++ b/frontend/src/features/page/tree/components/merge-refs.ts @@ -0,0 +1,15 @@ +import React from "react"; + +type AnyRef = React.MutableRefObject | React.RefCallback | 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; + } + }); + }; +} \ No newline at end of file diff --git a/frontend/src/features/page/tree/data.ts b/frontend/src/features/page/tree/data.ts new file mode 100644 index 0000000..fde4d5e --- /dev/null +++ b/frontend/src/features/page/tree/data.ts @@ -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: [], + }, +]; diff --git a/frontend/src/features/page/tree/hooks/use-dynamic-tree.ts b/frontend/src/features/page/tree/hooks/use-dynamic-tree.ts new file mode 100644 index 0000000..4ccd336 --- /dev/null +++ b/frontend/src/features/page/tree/hooks/use-dynamic-tree.ts @@ -0,0 +1,67 @@ +import { useMemo, useState } from 'react'; +import { + CreateHandler, + DeleteHandler, + MoveHandler, + RenameHandler, + SimpleTree, +} from 'react-arborist'; + +let nextId = 0; + +export function useDynamicTree() { + const [data, setData] = useState([]); + const tree = useMemo( + () => + new SimpleTree(data), + [data] + ); + + const onMove: MoveHandler = (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 = ({ 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 = ({ 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 = (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; +} diff --git a/frontend/src/features/page/tree/page-tree.tsx b/frontend/src/features/page/tree/page-tree.tsx new file mode 100644 index 0000000..695c965 --- /dev/null +++ b/frontend/src/features/page/tree/page-tree.tsx @@ -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>(treeApiAtom); + + + useEffect(() => { + setData(pageData); + }, [setData]); + + return ( +
+ + {(dimens) => ( + setTree(t)} + openByDefault={false} + disableMultiSelection={true} + className={styles.tree} + rowClassName={styles.row} + padding={15} + rowHeight={30} + overscanCount={5} + > + {Node} + + )} + +
+ ); +} + +function Node({ node, style, dragHandle }: NodeRendererProps) { + return ( + <> +
+ + + + + + {node.isEditing ? ( + + ) : ( + node.data.name || 'untitled' + )} + + +
+ + +
+
+ + ); +} + +function CreateNode({ node }: { node: NodeApi }) { + const [tree] = useAtom(treeApiAtom); + + function handleCreate() { + tree?.create({ type: 'internal', parentId: node.id, index: 0 }); + } + + return ( + + + + ); +} + +function NodeMenu({ node }: { node: NodeApi }) { + const [tree] = useAtom(treeApiAtom); + + function handleDelete() { + const sib = node.nextSibling; + const parent = node.parent; + tree?.focus(sib || parent, { scroll: false }); + tree?.delete(node); + } + + return ( + + + + + + + + + } + onClick={() => node.edit()} + > + Rename + + } + > + Favorite + + + + + } + > + Copy link + + + + } + > + Move + + + + + } + > + Archive + + + } + onClick={() => handleDelete()} + > + Delete + + + + ); +} + +function PageArrow({ node }: { node: NodeApi }) { + return ( + node.toggle()}> + {node.isInternal ? ( + node.children && node.children.length > 0 ? ( + node.isOpen ? ( + + ) : ( + + ) + ) : ( + + ) + ) : null} + + ); +} + +function Input({ node }: { node: NodeApi }) { + return ( + e.currentTarget.select()} + onBlur={() => node.reset()} + onKeyDown={(e) => { + if (e.key === 'Escape') node.reset(); + if (e.key === 'Enter') node.submit(e.currentTarget.value); + }} + /> + ); +} diff --git a/frontend/src/features/page/tree/tree.json b/frontend/src/features/page/tree/tree.json new file mode 100644 index 0000000..511270b --- /dev/null +++ b/frontend/src/features/page/tree/tree.json @@ -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": [] + } +] diff --git a/frontend/src/features/page/tree/tree.module.css b/frontend/src/features/page/tree/tree.module.css new file mode 100644 index 0000000..a9d2dca --- /dev/null +++ b/frontend/src/features/page/tree/tree.module.css @@ -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; +} diff --git a/frontend/src/features/page/tree/types.ts b/frontend/src/features/page/tree/types.ts new file mode 100644 index 0000000..2b23751 --- /dev/null +++ b/frontend/src/features/page/tree/types.ts @@ -0,0 +1,9 @@ +export type Data = { + id: string + name: string + icon?: string + slug?: string + selected?: boolean + children: Data[] + } + \ No newline at end of file