From 1896eeddc853e9eb4cb34968d4ec2649772d2feb Mon Sep 17 00:00:00 2001 From: steeeee0223 Date: Thu, 28 Nov 2024 03:54:35 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20`WOR-4`=20Refactor=20global=20st?= =?UTF-8?q?ates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace-provider/_components/index.ts | 1 - .../liveblocks-layout.tsx | 33 ++- .../src/stories/playground/use-pages.ts | 30 +++ .../stories/playground/worxpace.stories.tsx | 46 +++-- apps/worxpace/src/lib/data-transfer.ts | 13 +- packages/notion/README.md | 4 +- packages/notion/src/__mock__/workspaces.ts | 40 ++-- packages/notion/src/index.ts | 9 + .../notion/src/navbar/_components/menu.tsx | 3 +- .../sidebar-2/_components/action-group.tsx | 75 +++++++ .../src/sidebar-2/_components/doc-list.tsx | 79 ++++++++ .../src/sidebar-2/_components/hint-item.tsx | 53 +++++ .../notion/src/sidebar-2/_components/index.ts | 3 + .../src/sidebar-2/_components/trash-box.tsx | 188 ++++++++++++++++++ packages/notion/src/sidebar-2/index.ts | 1 + packages/notion/src/sidebar-2/modals/index.ts | 2 + .../src/sidebar-2/modals/search-command.tsx | 164 +++++++++++++++ .../src/sidebar-2/modals/settings-modal.tsx | 34 ++++ packages/notion/src/sidebar-2/sidebar.tsx | 173 ++++++++++++++++ packages/notion/src/slices/account.ts | 31 +++ packages/notion/src/slices/index.ts | 1 + packages/notion/src/slices/page.ts | 63 ++++++ packages/notion/src/slices/use-bound-store.ts | 17 ++ packages/notion/src/slices/workspace.ts | 61 ++++++ packages/notion/src/types/index.ts | 6 +- .../_components/header-dropdown.tsx | 40 ++++ .../workspace-switcher-2/_components/index.ts | 2 + .../_components/workspace-list.tsx | 125 ++++++++++++ .../src/workspace-switcher-2/constant.ts | 9 + .../notion/src/workspace-switcher-2/index.ts | 1 + .../workspace-switcher-2/switcher.test.tsx | 17 ++ .../src/workspace-switcher-2/switcher.tsx | 105 ++++++++++ 32 files changed, 1369 insertions(+), 60 deletions(-) rename apps/storybook/src/stories/{notion/workspace-provider/_components => playground}/liveblocks-layout.tsx (77%) create mode 100644 apps/storybook/src/stories/playground/use-pages.ts create mode 100644 packages/notion/src/sidebar-2/_components/action-group.tsx create mode 100644 packages/notion/src/sidebar-2/_components/doc-list.tsx create mode 100644 packages/notion/src/sidebar-2/_components/hint-item.tsx create mode 100644 packages/notion/src/sidebar-2/_components/index.ts create mode 100644 packages/notion/src/sidebar-2/_components/trash-box.tsx create mode 100644 packages/notion/src/sidebar-2/index.ts create mode 100644 packages/notion/src/sidebar-2/modals/index.ts create mode 100644 packages/notion/src/sidebar-2/modals/search-command.tsx create mode 100644 packages/notion/src/sidebar-2/modals/settings-modal.tsx create mode 100644 packages/notion/src/sidebar-2/sidebar.tsx create mode 100644 packages/notion/src/slices/account.ts create mode 100644 packages/notion/src/slices/index.ts create mode 100644 packages/notion/src/slices/page.ts create mode 100644 packages/notion/src/slices/use-bound-store.ts create mode 100644 packages/notion/src/slices/workspace.ts create mode 100644 packages/notion/src/workspace-switcher-2/_components/header-dropdown.tsx create mode 100644 packages/notion/src/workspace-switcher-2/_components/index.ts create mode 100644 packages/notion/src/workspace-switcher-2/_components/workspace-list.tsx create mode 100644 packages/notion/src/workspace-switcher-2/constant.ts create mode 100644 packages/notion/src/workspace-switcher-2/index.ts create mode 100644 packages/notion/src/workspace-switcher-2/switcher.test.tsx create mode 100644 packages/notion/src/workspace-switcher-2/switcher.tsx diff --git a/apps/storybook/src/stories/notion/workspace-provider/_components/index.ts b/apps/storybook/src/stories/notion/workspace-provider/_components/index.ts index 1c1c0363..61fad717 100644 --- a/apps/storybook/src/stories/notion/workspace-provider/_components/index.ts +++ b/apps/storybook/src/stories/notion/workspace-provider/_components/index.ts @@ -1,2 +1 @@ export * from "./base-layout"; -export * from "./liveblocks-layout"; diff --git a/apps/storybook/src/stories/notion/workspace-provider/_components/liveblocks-layout.tsx b/apps/storybook/src/stories/playground/liveblocks-layout.tsx similarity index 77% rename from apps/storybook/src/stories/notion/workspace-provider/_components/liveblocks-layout.tsx rename to apps/storybook/src/stories/playground/liveblocks-layout.tsx index b641a673..fce2f64f 100644 --- a/apps/storybook/src/stories/notion/workspace-provider/_components/liveblocks-layout.tsx +++ b/apps/storybook/src/stories/playground/liveblocks-layout.tsx @@ -1,7 +1,14 @@ import React from "react"; import { createClient, createRoomContext, RoomProvider } from "@swy/liveblocks"; -import { Navbar, PageHeader, PageProvider, Sidebar } from "@swy/notion"; +import { + Navbar, + PageHeader, + PageProvider, + Sidebar2, + useBoundStore, + WorkspaceSwitcher2, +} from "@swy/notion"; import { mockConnections, mockLogs, @@ -29,7 +36,13 @@ type LayoutProps = React.PropsWithChildren; export const LayoutWithLiveblocks = ({ children }: LayoutProps) => { const { minSize, ref, collapse, expand, isResizing, isMobile, isCollapsed } = useSidebarLayout("group", "sidebar", 240); - const { pageId, isLoading, fetchPages, selectPage } = usePages("workspace-0"); + /** Bound stores */ + const activeWorkspace = useBoundStore((state) => state.activeWorkspace); + const workspaces = useBoundStore((state) => state.workspaces); + const user = useBoundStore((state) => state.user); + const setActiveWorkspace = useBoundStore((state) => state.setActiveWorkspace); + const { pageId, isLoading, fetchPages, selectPage } = + usePages(activeWorkspace); return ( { collapsible order={1} > - { onFetchConnections: () => Promise.resolve(mockConnections), onFetchMemberships: () => Promise.resolve(mockMemberships), }} - pageHandlers={{ - isLoading, - fetchPages, - }} - workspaceHandlers={{}} + pageHandlers={{ isLoading, fetchPages }} + WorkspaceSwitcher={ + + } /> diff --git a/apps/storybook/src/stories/playground/use-pages.ts b/apps/storybook/src/stories/playground/use-pages.ts new file mode 100644 index 00000000..ab285558 --- /dev/null +++ b/apps/storybook/src/stories/playground/use-pages.ts @@ -0,0 +1,30 @@ +import useSWR from "swr"; + +import { useBoundStore, type Page } from "@swy/notion"; +import { mockPages } from "@swy/notion/mock"; + +import { delay } from "@/lib/utils"; + +const fetcher = async () => { + await delay(2000); + return await Promise.resolve(Object.values(mockPages)); +}; + +export const usePages = (workspaceId: string | null) => { + const activePage = useBoundStore((state) => state.activePage); + const setActivePage = useBoundStore((state) => state.setActivePage); + const setPages = useBoundStore((state) => state.setPages); + + const { isLoading } = useSWR(workspaceId, fetcher, { + onSuccess: (data) => setPages(data), + onError: (e) => console.log(`[swr:workspace]: ${e.message}`), + }); + const fetchPages = () => Promise.resolve(Object.values(mockPages)); + const selectPage = (path: string) => { + const [, , id] = path.split("/"); + if (!id) return; + setActivePage(id); + }; + + return { pageId: activePage ?? "#", isLoading, fetchPages, selectPage }; +}; diff --git a/apps/storybook/src/stories/playground/worxpace.stories.tsx b/apps/storybook/src/stories/playground/worxpace.stories.tsx index 40bbb669..ed09e626 100644 --- a/apps/storybook/src/stories/playground/worxpace.stories.tsx +++ b/apps/storybook/src/stories/playground/worxpace.stories.tsx @@ -1,12 +1,13 @@ +import { useEffect } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { CollaborativeEditor } from "@swy/liveblocks"; -import { WorkspaceProvider } from "@swy/notion"; +import { useBoundStore } from "@swy/notion"; import { user, workspaces } from "@swy/notion/mock"; import { ModalProvider } from "@swy/ui/shared"; import { liveblocksAuth } from "@/stories/notion/__mock__"; -import { LayoutWithLiveblocks } from "@/stories/notion/workspace-provider/_components"; +import { LayoutWithLiveblocks } from "./liveblocks-layout"; const meta = { title: "Playground", @@ -19,21 +20,30 @@ export default meta; type Story = StoryObj; +const Template: Story["render"] = () => { + const setWorkspaces = useBoundStore((state) => state.setWorkspaces); + const setUser = useBoundStore((state) => state.setUser); + const setActiveWorkspace = useBoundStore((state) => state.setActiveWorkspace); + const setActivePage = useBoundStore((state) => state.setActivePage); + useEffect(() => { + setWorkspaces(workspaces); + setUser(user); + setActiveWorkspace(workspaces[0]!.id); + setActivePage("#"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + +
+ + + +
+
+ ); +}; + export const Playground: Story = { - render: () => ( - - -
- - - -
-
-
- ), + render: Template, }; diff --git a/apps/worxpace/src/lib/data-transfer.ts b/apps/worxpace/src/lib/data-transfer.ts index 1d3f2985..ec9e851e 100644 --- a/apps/worxpace/src/lib/data-transfer.ts +++ b/apps/worxpace/src/lib/data-transfer.ts @@ -1,5 +1,3 @@ -import { format } from "date-fns"; - import type { DocItemData, Page, @@ -46,10 +44,6 @@ export function toIcon(info: IconInfo): Icon | null { } } -export function toDateString(date: Date | string | number): string { - return format(new Date(date), "MMM d, yyyy 'at' h:mm a"); -} - function accountIdToName( accountId: string, memberships: MembershipsMap, @@ -68,7 +62,7 @@ export function toDocItem( icon: toIconInfo(doc.icon), group: doc.isArchived ? `trash:${doc.type}` : doc.type, lastEditedBy: accountIdToName(doc.updatedId, memberships), - lastEditedAt: doc.updatedAt.getMilliseconds(), + lastEditedAt: doc.updatedAt.getUTCMilliseconds(), }; } @@ -79,6 +73,7 @@ export function toPage( return doc ? { id: doc.id, + parentId: doc.parentId, title: doc.title, type: doc.type, isArchived: doc.isArchived, @@ -87,8 +82,8 @@ export function toPage( coverImage: doc.coverImage, createdBy: accountIdToName(doc.createdId, memberships), lastEditedBy: accountIdToName(doc.updatedId, memberships), - createdAt: toDateString(doc.createdAt), - lastEditedAt: toDateString(doc.updatedAt), + createdAt: doc.createdAt.getUTCMilliseconds(), + lastEditedAt: doc.updatedAt.getUTCMilliseconds(), } : null; } diff --git a/packages/notion/README.md b/packages/notion/README.md index 49fda852..37c631ab 100644 --- a/packages/notion/README.md +++ b/packages/notion/README.md @@ -5,6 +5,6 @@ Note: There are several components that require context dependencies. 1. Components requiring `ModalProvider`: `Sidebar` -2. Components requiring `WorpsaceProvider`: `Sidebar`, `WorkspaceSwitcher` -3. Components requiring `TreeProvider`: `Sidebar` +2. `Deprecated` Components requiring `WorpsaceProvider`: `Sidebar`, `WorkspaceSwitcher` +3. `Deprecated` Components requiring `TreeProvider`: `Sidebar` 4. Components requiring `PageProvider`: `Navbar`, `PageHeader` diff --git a/packages/notion/src/__mock__/workspaces.ts b/packages/notion/src/__mock__/workspaces.ts index a353d0cc..74ece3f3 100644 --- a/packages/notion/src/__mock__/workspaces.ts +++ b/packages/notion/src/__mock__/workspaces.ts @@ -5,6 +5,9 @@ import { Plan, Role } from "@swy/validators"; import { mockUsers } from "./users"; +const getRandomTs = () => + randomInt(Date.UTC(2024, 1, 1), Date.UTC(2024, 10, 31)); + export const documents: DocItemData[] = [ { group: "document", @@ -16,7 +19,7 @@ export const documents: DocItemData[] = [ url: "https://img.freepik.com/premium-vector/line-art-flag-language-korean-illustration-vector_490632-422.jpg", }, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "document", @@ -24,7 +27,7 @@ export const documents: DocItemData[] = [ title: "Pronunciation", parentId: "page1", lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "document", @@ -33,7 +36,7 @@ export const documents: DocItemData[] = [ parentId: null, icon: { type: "lucide", name: "book", color: "#CB912F" }, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "document", @@ -42,7 +45,7 @@ export const documents: DocItemData[] = [ icon: { type: "lucide", name: "book-check", color: "#CB912F" }, parentId: null, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "document", @@ -50,7 +53,7 @@ export const documents: DocItemData[] = [ title: "System flowchart", parentId: null, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "trash:document", @@ -59,7 +62,7 @@ export const documents: DocItemData[] = [ parentId: null, icon: { type: "lucide", name: "book", color: "#337EA9" }, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "kanban", @@ -67,7 +70,7 @@ export const documents: DocItemData[] = [ title: "TODO List", parentId: null, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "whiteboard", @@ -75,7 +78,7 @@ export const documents: DocItemData[] = [ title: "System flowchart", parentId: null, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "document", @@ -87,7 +90,7 @@ export const documents: DocItemData[] = [ url: "https://cdn.iconscout.com/icon/premium/png-256-thumb/bar-table-1447763-1224177.png", }, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, { group: "document", @@ -96,7 +99,7 @@ export const documents: DocItemData[] = [ title: "The Continental", icon: { type: "emoji", emoji: "๐Ÿ " }, lastEditedBy: "", - lastEditedAt: randomInt(1704067200000, 1730332800000), + lastEditedAt: getRandomTs(), }, ]; @@ -138,18 +141,19 @@ export const workspaces: Workspace[] = [ export const otherUsers = mockUsers.slice(2, 7); -const createPageData = (treeItem: DocItemData): Page => { - const res = treeItem.group!.split(":"); +const createPageData = (item: DocItemData): Page => { + const res = item.group!.split(":"); return { - id: treeItem.id, - type: res.length > 1 ? res[1]! : treeItem.group!, - title: treeItem.title, - icon: treeItem.icon ?? null, + id: item.id, + parentId: item.parentId ?? null, + type: res.length > 1 ? res[1]! : item.group!, + title: item.title, + icon: item.icon ?? null, isArchived: res.length > 1, coverImage: null, isPublished: false, - createdAt: Date.UTC(2023, 3, 5).toString(), - lastEditedAt: treeItem.lastEditedAt.toString(), + createdAt: Date.UTC(2023, 3, 5), + lastEditedAt: item.lastEditedAt, createdBy: user.name, lastEditedBy: user.name, }; diff --git a/packages/notion/src/index.ts b/packages/notion/src/index.ts index 5f4232e2..b9498d5a 100644 --- a/packages/notion/src/index.ts +++ b/packages/notion/src/index.ts @@ -1,14 +1,23 @@ export * from "./navbar"; export * from "./page-header"; export * from "./settings-panel"; +/** + * @deprecated use `Sidebar2` instead + */ export * from "./sidebar"; +export * from "./sidebar-2"; export * from "./tables"; +/** + * @deprecated use `WorkspaceSwitcher2` instead + */ export * from "./workspace-switcher"; +export * from "./workspace-switcher-2"; /** Providers */ export * from "./page-provider"; export * from "./workspace-provider"; /** Utils */ export * from "./common"; export * from "./scopes"; +export * from "./slices"; /** Types */ export * from "./types"; diff --git a/packages/notion/src/navbar/_components/menu.tsx b/packages/notion/src/navbar/_components/menu.tsx index 68eaf333..92c93919 100644 --- a/packages/notion/src/navbar/_components/menu.tsx +++ b/packages/notion/src/navbar/_components/menu.tsx @@ -13,6 +13,7 @@ import { } from "@swy/ui/shadcn"; import { Hint } from "@swy/ui/shared"; +import { toDateString } from "../../common"; import type { PageContextInterface } from "../../page-provider"; import type { Page } from "../../types"; @@ -44,7 +45,7 @@ export const Menu = ({ page, onChangeState }: MenuProps) => {
Last edited by: {page.lastEditedBy}
-
{page.lastEditedAt}
+
{toDateString(page.lastEditedAt)}
diff --git a/packages/notion/src/sidebar-2/_components/action-group.tsx b/packages/notion/src/sidebar-2/_components/action-group.tsx new file mode 100644 index 00000000..839e2415 --- /dev/null +++ b/packages/notion/src/sidebar-2/_components/action-group.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { MoreHorizontal, Plus, Trash } from "lucide-react"; + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@swy/ui/shadcn"; +import { Hint } from "@swy/ui/shared"; + +import { toDateString } from "../../common"; + +interface ActionGroupProps { + lastEditedBy: string; + lastEditedAt: number; + onCreate: () => void; + onDelete: () => void; +} + +export const ActionGroup: React.FC = ({ + lastEditedBy, + lastEditedAt, + onCreate, + onDelete, +}) => { + return ( +
+ + + e.stopPropagation()} asChild> + + + + e.stopPropagation()} + > + + + Delete + + +
+
Last edited by: {lastEditedBy}
+
{toDateString(lastEditedAt)}
+
+
+
+ + + +
+ ); +}; diff --git a/packages/notion/src/sidebar-2/_components/doc-list.tsx b/packages/notion/src/sidebar-2/_components/doc-list.tsx new file mode 100644 index 00000000..9c89741b --- /dev/null +++ b/packages/notion/src/sidebar-2/_components/doc-list.tsx @@ -0,0 +1,79 @@ +import React from "react"; + +import { + buildTree, + TreeGroup, + TreeItem, + TreeList, + type IconInfo, +} from "@swy/ui/shared"; + +import { useBoundStore } from "../../slices"; +import { ActionGroup } from "./action-group"; + +interface DocListProps { + group: string; + title: string; + defaultIcon?: IconInfo; + isLoading?: boolean; + onSelect: (group: string, id: string) => void; + onCreate: (parentId?: string) => void; + onArchive: (id: string) => void; +} + +export const DocList: React.FC = ({ + group, + title, + defaultIcon, + isLoading, + onSelect, + onCreate, + onArchive, +}) => { + const pages = useBoundStore((state) => + Object.values(state.pages).filter( + (page) => page.type === group && !page.isArchived, + ), + ); + const activePage = useBoundStore((state) => state.activePage); + const setActivePage = useBoundStore((state) => state.setActivePage); + + const nodes = buildTree(pages); + + const select = (id: string) => { + setActivePage(id); + onSelect(group, id); + }; + + return ( + onCreate()} + > + ( + + onCreate(node.id)} + onDelete={() => onArchive(node.id)} + /> + + )} + /> + + ); +}; diff --git a/packages/notion/src/sidebar-2/_components/hint-item.tsx b/packages/notion/src/sidebar-2/_components/hint-item.tsx new file mode 100644 index 00000000..592ce3b2 --- /dev/null +++ b/packages/notion/src/sidebar-2/_components/hint-item.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from "react"; +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@swy/ui/lib"; +import { + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@swy/ui/shadcn"; + +interface HintItemProps { + className?: string; + icon: LucideIcon; + label: string; + hint: string; + shortcut?: string; + onClick?: () => void; +} + +export const HintItem = forwardRef( + ({ className, icon: Icon, label, hint, shortcut, onClick }, ref) => { + return ( + + + + + + +
{hint}
+ + {shortcut} + +
+
+
+ ); + }, +); + +HintItem.displayName = "HintItem"; diff --git a/packages/notion/src/sidebar-2/_components/index.ts b/packages/notion/src/sidebar-2/_components/index.ts new file mode 100644 index 00000000..422c8332 --- /dev/null +++ b/packages/notion/src/sidebar-2/_components/index.ts @@ -0,0 +1,3 @@ +export * from "./doc-list"; +export * from "./hint-item"; +export * from "./trash-box"; diff --git a/packages/notion/src/sidebar-2/_components/trash-box.tsx b/packages/notion/src/sidebar-2/_components/trash-box.tsx new file mode 100644 index 00000000..a6f4c76b --- /dev/null +++ b/packages/notion/src/sidebar-2/_components/trash-box.tsx @@ -0,0 +1,188 @@ +/* eslint-disable jsx-a11y/interactive-supports-focus */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +"use client"; + +import React, { useEffect, useState } from "react"; +import { HelpCircle, Trash, Undo } from "lucide-react"; +import { toast } from "sonner"; + +import { cn } from "@swy/ui/lib"; +import { + Button, + buttonVariants, + Input, + Popover, + PopoverContent, + PopoverTrigger, +} from "@swy/ui/shadcn"; +import { Hint, IconBlock, useModal } from "@swy/ui/shared"; + +import { BaseModal } from "../../common"; +import type { Page } from "../../types"; +import { HintItem } from "./hint-item"; + +interface TrashBoxProps { + isOpen: boolean; + isMobile?: boolean; + onOpenChange: (open: boolean) => void; + fetchPages?: () => Promise; + onRestore?: (id: string) => void; + onDelete?: (id: string) => void; + onSelect?: (id: string, type: string) => void; +} + +export const TrashBox = ({ + isOpen, + onOpenChange, + fetchPages, + onRestore, + onDelete, + onSelect, +}: TrashBoxProps) => { + const { setOpen } = useModal(); + /** Select */ + const [pages, setPages] = useState([]); + const handleSelect = (id: string, type: string) => { + onSelect?.(id, type); + onOpenChange(false); + }; + /** Filter */ + const [input, setInput] = useState(""); + const [filteredItems, setFilteredItems] = useState(null); + const updateFilteredItems = (input: string) => { + const v = input.trim().toLowerCase(); + const result = pages.filter((page) => page.title.toLowerCase().includes(v)); + setFilteredItems( + v.length > 0 ? (result.length > 0 ? result : null) : pages, + ); + }; + const onInputChange = (input: string) => { + setInput(input); + updateFilteredItems(input); + }; + /** Docs Actions */ + const handleRestore = ( + e: React.MouseEvent, + id: string, + ) => { + e.stopPropagation(); + onRestore?.(id); + }; + const handleDelete = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + setOpen( + onDelete?.(id)} + />, + ); + }; + + useEffect(() => { + void fetchPages?.() + .then((data) => { + const pages = data.filter((page) => page.isArchived); + setPages(pages); + setFilteredItems(pages); + }) + .catch(() => toast.message("Error while fetching pages")); + }, [fetchPages]); + + return ( + + + + + +
+ onInputChange(e.target.value)} + placeholder="Search pages in Trash" + /> +
+
+ {!filteredItems || filteredItems.length === 0 ? ( +
+ +
+ No results +
+
+ ) : ( +
+ {filteredItems.map(({ id, title, type, icon }) => ( +
handleSelect(id, type)} + className={cn( + buttonVariants({ variant: "item" }), + "w-full justify-between px-2", + )} + > +
+ + {title} +
+
+ + + + + + +
+
+ ))} +
+ )} +
+
+
+ + Pages in Trash for over 30 days will be automatically deleted + + + + +
+
+
+
+ ); +}; diff --git a/packages/notion/src/sidebar-2/index.ts b/packages/notion/src/sidebar-2/index.ts new file mode 100644 index 00000000..01acaeff --- /dev/null +++ b/packages/notion/src/sidebar-2/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/packages/notion/src/sidebar-2/modals/index.ts b/packages/notion/src/sidebar-2/modals/index.ts new file mode 100644 index 00000000..b7903315 --- /dev/null +++ b/packages/notion/src/sidebar-2/modals/index.ts @@ -0,0 +1,2 @@ +export * from "./search-command"; +export * from "./settings-modal"; diff --git a/packages/notion/src/sidebar-2/modals/search-command.tsx b/packages/notion/src/sidebar-2/modals/search-command.tsx new file mode 100644 index 00000000..a43b6bee --- /dev/null +++ b/packages/notion/src/sidebar-2/modals/search-command.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { ArrowUpDown } from "lucide-react"; +import { toast } from "sonner"; + +import { cn } from "@swy/ui/lib"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, + CommandSeparator, + Input, +} from "@swy/ui/shadcn"; +import { IconBlock, useModal } from "@swy/ui/shared"; + +import { generateDefaultIcon, Icon, toDateString } from "../../common"; +import type { Page } from "../../types"; + +interface SearchCommandProps { + workspaceName: string; + fetchPages?: () => Promise; + onSelect?: (id: string, group: string) => void; + onOpenTrash?: (open: boolean) => void; +} + +export const SearchCommand: React.FC = ({ + workspaceName, + fetchPages, + onSelect, + onOpenTrash, +}) => { + /** Search */ + const { isOpen, setClose } = useModal(); + const jumpToTrash = () => { + setClose(); + onOpenTrash?.(true); + }; + /** Select */ + const [pages, setPages] = useState([]); + const handleSelect = (id: string, group: string) => { + onSelect?.(id, group); + setClose(); + }; + /** Filter */ + const [input, setInput] = useState(""); + const [filteredItems, setFilteredItems] = useState(null); + const updateFilteredItems = (input: string) => { + const v = input.trim().toLowerCase(); + const result = pages.filter((page) => page.title.toLowerCase().includes(v)); + setFilteredItems( + v.length > 0 ? (result.length > 0 ? result : null) : pages, + ); + }; + const onInputChange = (input: string) => { + setInput(input); + updateFilteredItems(input); + }; + + useEffect(() => { + void fetchPages?.() + .then((data) => { + const pages = data.filter((page) => !page.isArchived); + setPages(pages); + setFilteredItems(pages); + }) + .catch(() => toast.message("Error while fetching pages")); + }, [fetchPages]); + + return ( + +
+ onInputChange(e.target.value)} + placeholder={`Search in ${workspaceName}...`} + className="h-full w-full min-w-0 border-none bg-transparent text-lg/[27px] dark:bg-transparent" + /> +
+ + {filteredItems ? ( + + {filteredItems.map(({ id, title, icon, type, lastEditedAt }) => ( + handleSelect(id, type)} + className="group mx-1.5 flex min-h-9 items-center gap-2 py-2" + > + + {title} +
+ + {toDateString(lastEditedAt)} + + + + +
+
+ ))} +
+ ) : ( + +
+
+
+ No results +
+
+
+
+ Some results may be in your deleted pages. +
+ +
+
+
+
+ )} +
+ +
+
+
    +
  • + + Select +
  • +
  • + + Open +
  • +
+
+
+
+ ); +}; diff --git a/packages/notion/src/sidebar-2/modals/settings-modal.tsx b/packages/notion/src/sidebar-2/modals/settings-modal.tsx new file mode 100644 index 00000000..0e68245c --- /dev/null +++ b/packages/notion/src/sidebar-2/modals/settings-modal.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Dialog, DialogContent } from "@swy/ui/shadcn"; +import { useModal } from "@swy/ui/shared"; + +import { SettingsPanel, type SettingsPanelProps } from "../../settings-panel"; + +export const SettingsModal = (props: SettingsPanelProps) => { + const { isOpen, setClose } = useModal(); + const settingsProps: SettingsPanelProps = { + ...props, + onDeleteAccount: (data) => { + setClose(); + props.onDeleteAccount?.(data); + }, + onDeleteWorkspace: (data) => { + setClose(); + props.onDeleteWorkspace?.(data); + }, + }; + + return ( + + e.stopPropagation()} + forceMount + noTitle + className="flex h-[calc(100vh-100px)] max-h-[720px] w-[calc(100vw-100px)] max-w-[1150px] rounded border-none p-0 shadow" + > + + + + ); +}; diff --git a/packages/notion/src/sidebar-2/sidebar.tsx b/packages/notion/src/sidebar-2/sidebar.tsx new file mode 100644 index 00000000..65804943 --- /dev/null +++ b/packages/notion/src/sidebar-2/sidebar.tsx @@ -0,0 +1,173 @@ +"use client"; + +import React, { forwardRef, useState } from "react"; +import { + ChevronsLeft, + CirclePlus, + GitPullRequestArrow, + SearchIcon, + SettingsIcon, +} from "lucide-react"; +import { useHotkeys } from "react-hotkeys-hook"; + +import { cn } from "@swy/ui/lib"; +import { Button, useTheme } from "@swy/ui/shadcn"; +import { Hint, useModal } from "@swy/ui/shared"; + +import type { SettingsPanelProps } from "../settings-panel"; +import type { Page } from "../types"; +import { DocList, HintItem, TrashBox } from "./_components"; +import { SearchCommand, SettingsModal } from "./modals"; + +interface SidebarProps { + className?: string; + isMobile?: boolean; + collapse?: () => void; + redirect?: (path: string) => void; + settingsProps: SettingsPanelProps; + pageHandlers: { + isLoading?: boolean; + fetchPages?: () => Promise; + onCreate?: (type: string, parentId?: string) => void; + onArchive?: (id: string) => void; + onRestore?: (id: string) => void; + onDelete?: (id: string) => void; + }; + WorkspaceSwitcher: React.ReactNode; +} + +export const Sidebar2 = forwardRef(function Sidebar( + { + className, + isMobile, + collapse, + redirect, + settingsProps, + pageHandlers: pages, + WorkspaceSwitcher, + }, + ref, +) { + const { theme: activeTheme, setTheme } = useTheme(); + /** Modals */ + const { setOpen } = useModal(); + const [trashOpen, setTrashOpen] = useState(false); + const onOpenSettings = () => { + const workspaceId = settingsProps.settings.workspace.id; + setOpen(); + }; + const onOpenSearch = () => + setOpen( + redirect?.(`/${group}/${id}`)} + onOpenTrash={setTrashOpen} + />, + ); + /** Keyboard shortcut */ + const shortcutOptions = { preventDefault: true }; + useHotkeys(["meta+k", "shift+meta+k"], onOpenSearch, shortcutOptions); + useHotkeys( + ["meta+comma", "shift+meta+comma"], + onOpenSettings, + shortcutOptions, + ); + useHotkeys( + "shift+meta+l", + () => setTheme(activeTheme === "dark" ? "light" : "dark"), + shortcutOptions, + ); + + return ( + + ); +}); diff --git a/packages/notion/src/slices/account.ts b/packages/notion/src/slices/account.ts new file mode 100644 index 00000000..a2270b44 --- /dev/null +++ b/packages/notion/src/slices/account.ts @@ -0,0 +1,31 @@ +import type { StateCreator } from "zustand"; + +import type { User } from "@swy/validators"; + +interface UserState { + user: User | null; + clerkId: string | null; +} +interface UserActions { + setClerkId: (clerkId: string) => void; + setUser: (user: User) => void; + updateUser: (data: Partial) => void; + resetUser: () => void; +} + +export type UserSlice = UserState & UserActions; + +const initialState: UserState = { + user: null, + clerkId: null, +}; + +export const createUserSlice: StateCreator = ( + set, +) => ({ + ...initialState, + setClerkId: (clerkId) => set({ clerkId }), + setUser: (user) => set({ user }), + updateUser: (data) => set(({ user }) => ({ user: { ...user!, ...data } })), + resetUser: () => set(initialState), +}); diff --git a/packages/notion/src/slices/index.ts b/packages/notion/src/slices/index.ts new file mode 100644 index 00000000..a0204659 --- /dev/null +++ b/packages/notion/src/slices/index.ts @@ -0,0 +1 @@ +export * from "./use-bound-store"; diff --git a/packages/notion/src/slices/page.ts b/packages/notion/src/slices/page.ts new file mode 100644 index 00000000..e3582400 --- /dev/null +++ b/packages/notion/src/slices/page.ts @@ -0,0 +1,63 @@ +import type { StateCreator } from "zustand"; + +import type { Page } from "../types"; + +interface PageState { + pages: Record; + activePage: string | null; +} +interface PageActions { + setActivePage: (pageId: string | null) => void; + setPages: (pages: Page[]) => void; + addPage: (workspace: Page) => void; + updatePage: ( + pageId: string, + data: Partial< + Pick + >, + ) => void; + deletePage: (pageId: string) => void; + resetPages: () => void; +} + +export type PageSlice = PageState & PageActions; + +const initialState: PageState = { + pages: {}, + activePage: null, +}; + +export const createPageSlice: StateCreator = ( + set, +) => ({ + ...initialState, + setActivePage: (pageId) => + set((state) => { + if (!(pageId && pageId in state.pages)) return {}; + return { activePage: pageId }; + }), + setPages: (pages) => + set({ + pages: pages.reduce( + (acc, workspace) => ({ ...acc, [workspace.id]: workspace }), + {}, + ), + }), + addPage: (workspace) => + set(({ pages }) => ({ + pages: { ...pages, [workspace.id]: workspace }, + })), + updatePage: (pageId, data) => + set(({ pages }) => ({ + pages: { + ...pages, + [pageId]: { ...pages[pageId]!, ...data }, + }, + })), + deletePage: (pageId) => + set(({ pages }) => { + delete pages[pageId]; + return { pages }; + }), + resetPages: () => set(initialState), +}); diff --git a/packages/notion/src/slices/use-bound-store.ts b/packages/notion/src/slices/use-bound-store.ts new file mode 100644 index 00000000..de0b9509 --- /dev/null +++ b/packages/notion/src/slices/use-bound-store.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +import { createUserSlice, type UserSlice } from "./account"; +import { createPageSlice, type PageSlice } from "./page"; +import { createWorkspaceSlice, type WorkspaceSlice } from "./workspace"; + +type Store = UserSlice & WorkspaceSlice & PageSlice; + +const useStore = create((...a) => ({ + ...createUserSlice(...a), + ...createWorkspaceSlice(...a), + ...createPageSlice(...a), +})); + +export const useBoundStore = (selector: (state: Store) => T) => + useStore(useShallow(selector)); diff --git a/packages/notion/src/slices/workspace.ts b/packages/notion/src/slices/workspace.ts new file mode 100644 index 00000000..4ae44b96 --- /dev/null +++ b/packages/notion/src/slices/workspace.ts @@ -0,0 +1,61 @@ +import type { StateCreator } from "zustand"; + +import type { Workspace } from "../workspace-provider"; + +interface WorkspaceState { + workspaces: Record; + activeWorkspace: string | null; +} +interface WorkspaceActions { + setActiveWorkspace: (workspaceId: string | null) => void; + setWorkspaces: (workspaces: Workspace[]) => void; + addWorkspace: (workspace: Workspace) => void; + updateWorkspace: (workspaceId: string, data: Partial) => void; + deleteWorkspace: (workspaceId: string) => void; + resetWorkspaces: () => void; +} + +export type WorkspaceSlice = WorkspaceState & WorkspaceActions; + +const initialState: WorkspaceState = { + workspaces: {}, + activeWorkspace: null, +}; + +export const createWorkspaceSlice: StateCreator< + WorkspaceSlice, + [], + [], + WorkspaceSlice +> = (set) => ({ + ...initialState, + setActiveWorkspace: (workspaceId) => + set((state) => { + if (!(workspaceId && workspaceId in state.workspaces)) return {}; + return { activeWorkspace: workspaceId }; + }), + setWorkspaces: (workspaces) => + set({ + workspaces: workspaces.reduce( + (acc, workspace) => ({ ...acc, [workspace.id]: workspace }), + {}, + ), + }), + addWorkspace: (workspace) => + set(({ workspaces }) => ({ + workspaces: { ...workspaces, [workspace.id]: workspace }, + })), + updateWorkspace: (workspaceId, data) => + set(({ workspaces }) => ({ + workspaces: { + ...workspaces, + [workspaceId]: { ...workspaces[workspaceId]!, ...data }, + }, + })), + deleteWorkspace: (workspaceId) => + set(({ workspaces }) => { + delete workspaces[workspaceId]; + return { workspaces }; + }), + resetWorkspaces: () => set(initialState), +}); diff --git a/packages/notion/src/types/index.ts b/packages/notion/src/types/index.ts index 4779d1ed..e957e479 100644 --- a/packages/notion/src/types/index.ts +++ b/packages/notion/src/types/index.ts @@ -41,11 +41,11 @@ export interface Page { isArchived: boolean; coverImage: CoverImage | null; icon: IconInfo | null; - // parentId: string | null; + parentId: string | null; // content: string | null; isPublished: boolean; - createdAt: string; - lastEditedAt: string; + createdAt: number; // timestamp in 'ms' + lastEditedAt: number; // timestamp in 'ms' createdBy: string; lastEditedBy: string; } diff --git a/packages/notion/src/workspace-switcher-2/_components/header-dropdown.tsx b/packages/notion/src/workspace-switcher-2/_components/header-dropdown.tsx new file mode 100644 index 00000000..35655d7c --- /dev/null +++ b/packages/notion/src/workspace-switcher-2/_components/header-dropdown.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { MoreHorizontal, PlusSquare, XCircle } from "lucide-react"; + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@swy/ui/shadcn"; + +interface HeaderDropdownProps { + onLogout?: () => void; + onCreateWorkspace?: () => void; +} + +export const HeaderDropdown: React.FC = ({ + onCreateWorkspace, + onLogout, +}) => { + return ( + + + + + + + +

Join or create workspace

+
+ + +

Log out

+
+
+
+ ); +}; diff --git a/packages/notion/src/workspace-switcher-2/_components/index.ts b/packages/notion/src/workspace-switcher-2/_components/index.ts new file mode 100644 index 00000000..47292205 --- /dev/null +++ b/packages/notion/src/workspace-switcher-2/_components/index.ts @@ -0,0 +1,2 @@ +export * from "./header-dropdown"; +export * from "./workspace-list"; diff --git a/packages/notion/src/workspace-switcher-2/_components/workspace-list.tsx b/packages/notion/src/workspace-switcher-2/_components/workspace-list.tsx new file mode 100644 index 00000000..ec9e5c9d --- /dev/null +++ b/packages/notion/src/workspace-switcher-2/_components/workspace-list.tsx @@ -0,0 +1,125 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { Check } from "lucide-react"; + +import { Badge, DropdownMenuItem } from "@swy/ui/shadcn"; +import { IconBlock } from "@swy/ui/shared"; +import { Role } from "@swy/validators"; + +import { Icon } from "../../common"; +import type { Workspace } from "../../workspace-provider"; +import { planTitle } from "../constant"; + +interface TitleProps { + workspace: Workspace; + onClick: (workspaceId: string) => void; +} + +const Title: React.FC = ({ workspace, onClick }) => { + switch (workspace.role) { + case Role.GUEST: + return ( +
+
+
+ + {workspace.name} + + + + Guest + +
+
+
+ ); + default: + return ( +
+
onClick(workspace.id)} + > + {workspace.name} +
+
+ {planTitle[workspace.plan]} ยท {workspace.members} members +
+
+ ); + } +}; + +interface WorkspaceListProps { + activeWorkspace: Workspace; + workspaces: Workspace[]; + onSelect?: (id: string) => void; +} + +export const WorkspaceList: React.FC = ({ + activeWorkspace, + workspaces: initialWorkspaces, + onSelect, +}) => { + const dragItem = useRef(null); + const dragOverItem = useRef(null); + + const [workspaces, setWorkspaces] = useState(initialWorkspaces); + + const handleSort = () => { + const _workspaces = [...workspaces]; + const [dragItemContent] = _workspaces.splice(dragItem.current!, 1); + + _workspaces.splice(dragOverItem.current!, 0, dragItemContent!); + + dragItem.current = null; + dragOverItem.current = null; + + setWorkspaces(_workspaces); + }; + + useEffect(() => { + if (workspaces.length !== initialWorkspaces.length) + setWorkspaces(initialWorkspaces); + }, [workspaces, initialWorkspaces]); + + return ( +
+ {workspaces.map((workspace, i) => ( + (dragItem.current = i)} + onDragEnter={() => (dragOverItem.current = i)} + onDragEnd={handleSort} + onDragOver={(e) => e.preventDefault()} + onClick={() => onSelect?.(workspace.id)} + > +
+ +
+
+ +
+ onSelect?.(workspace.id)} + /> + {activeWorkspace.id === workspace.id && ( + <div className="ml-auto mr-1 size-4 flex-shrink-0"> + <Check className="mr-2 h-4 w-4 select-none self-center" /> + </div> + )} + </DropdownMenuItem> + ))} + </div> + ); +}; diff --git a/packages/notion/src/workspace-switcher-2/constant.ts b/packages/notion/src/workspace-switcher-2/constant.ts new file mode 100644 index 00000000..ac8d76e8 --- /dev/null +++ b/packages/notion/src/workspace-switcher-2/constant.ts @@ -0,0 +1,9 @@ +import { Plan } from "@swy/validators"; + +export const planTitle: Record<Plan, string> = { + [Plan.FREE]: "Free Plan", + [Plan.EDUCATION]: "Education Plus Plan", + [Plan.PLUS]: "Plan Plan", + [Plan.BUSINESS]: "Business Plan", + [Plan.ENTERPRISE]: "Enterprise Plan", +}; diff --git a/packages/notion/src/workspace-switcher-2/index.ts b/packages/notion/src/workspace-switcher-2/index.ts new file mode 100644 index 00000000..3eebb0f7 --- /dev/null +++ b/packages/notion/src/workspace-switcher-2/index.ts @@ -0,0 +1 @@ +export * from "./switcher"; diff --git a/packages/notion/src/workspace-switcher-2/switcher.test.tsx b/packages/notion/src/workspace-switcher-2/switcher.test.tsx new file mode 100644 index 00000000..32d95df9 --- /dev/null +++ b/packages/notion/src/workspace-switcher-2/switcher.test.tsx @@ -0,0 +1,17 @@ +import { render } from "@testing-library/react"; + +import { user, workspaces } from "../__mock__"; +import { WorkspaceSwitcher2 } from "./switcher"; + +describe("<WorkspaceSwitcher2 />", () => { + it("should render the default workspace", () => { + const { getByText } = render( + <WorkspaceSwitcher2 + user={user} + workspaces={workspaces} + activeWorkspace={workspaces[0]!} + />, + ); + expect(getByText("John's Private")).toBeDefined(); + }); +}); diff --git a/packages/notion/src/workspace-switcher-2/switcher.tsx b/packages/notion/src/workspace-switcher-2/switcher.tsx new file mode 100644 index 00000000..cc11dda8 --- /dev/null +++ b/packages/notion/src/workspace-switcher-2/switcher.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { ChevronsUpDown } from "lucide-react"; + +import { cn } from "@swy/ui/lib"; +import { + buttonVariants, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@swy/ui/shadcn"; +import { IconBlock } from "@swy/ui/shared"; +import type { User } from "@swy/validators"; + +import type { Workspace } from "../workspace-provider"; +import { HeaderDropdown, WorkspaceList } from "./_components"; + +const styles = { + action: "w-full text-xs text-secondary dark:text-secondary-dark", +}; + +interface WorkspaceSwitcherProps { + user: User; + activeWorkspace: Workspace; + workspaces: Workspace[]; + onCreateAccount?: () => void; + onCreateWorkspace?: () => void; + onLogout?: () => void; + onSelect?: (id: string) => void; +} + +export const WorkspaceSwitcher2: React.FC<WorkspaceSwitcherProps> = ({ + user, + activeWorkspace, + workspaces, + onCreateAccount, + onCreateWorkspace, + onLogout, + onSelect, +}) => { + const handleGetMac = () => + window.open("https://www.notion.so/desktop", "_blank"); + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <div + role="button" + className={cn( + buttonVariants({ + variant: "hint", + className: "w-full justify-normal rounded-sm p-3", + }), + )} + > + <div className="flex max-w-[150px] items-center gap-x-2"> + <IconBlock size="sm" icon={activeWorkspace.icon} /> + <span className="overflow-hidden text-ellipsis text-start font-medium text-primary dark:text-primary/80"> + {activeWorkspace.name} + </span> + </div> + <ChevronsUpDown className="ml-2 size-4 text-primary/45" /> + </div> + </DropdownMenuTrigger> + <DropdownMenuContent + className="w-80" + align="start" + alignOffset={11} + forceMount + > + <DropdownMenuGroup className="flex flex-col space-y-2 p-1"> + <div className="flex items-center"> + <p className="flex-1 text-xs font-medium leading-none text-secondary dark:text-secondary-dark"> + {user.email} + </p> + <HeaderDropdown + onLogout={onLogout} + onCreateWorkspace={onCreateWorkspace} + /> + </div> + <WorkspaceList + workspaces={workspaces} + activeWorkspace={activeWorkspace} + onSelect={onSelect} + /> + </DropdownMenuGroup> + <DropdownMenuSeparator /> + <DropdownMenuGroup> + <DropdownMenuItem className={styles.action} onClick={onCreateAccount}> + Add another account + </DropdownMenuItem> + <DropdownMenuItem className={styles.action} onClick={onLogout}> + Log out + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem className={styles.action} onClick={handleGetMac}> + Get Mac App + </DropdownMenuItem> + </DropdownMenuGroup> + </DropdownMenuContent> + </DropdownMenu> + ); +};