From dfad7651d660d39fad01ad4999b2ef145c6362b0 Mon Sep 17 00:00:00 2001 From: Aslam H Date: Wed, 11 Sep 2024 17:12:16 +0700 Subject: [PATCH 01/20] fix(page): should only do focus on init load --- .../routes/page/detail/PageDetailRoute.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/web/components/routes/page/detail/PageDetailRoute.tsx b/web/components/routes/page/detail/PageDetailRoute.tsx index 1e10b180..ebeeb250 100644 --- a/web/components/routes/page/detail/PageDetailRoute.tsx +++ b/web/components/routes/page/detail/PageDetailRoute.tsx @@ -130,6 +130,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const contentEditorRef = useRef(null) const isTitleInitialMount = useRef(true) const isContentInitialMount = useRef(true) + const isInitialFocusApplied = useRef(false) const updatePageContent = (content: Content, model: PersonalPage) => { if (isContentInitialMount.current) { @@ -201,7 +202,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const titleEditor = useEditor({ immediatelyRender: false, - autofocus: false, extensions: [ FocusClasses, Paragraph, @@ -246,10 +246,13 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { isTitleInitialMount.current = true isContentInitialMount.current = true - if (!page.title) { - titleEditor?.commands.focus() - } else { - contentEditorRef.current?.editor?.commands.focus() + if (!isInitialFocusApplied.current && titleEditor && contentEditorRef.current?.editor) { + isInitialFocusApplied.current = true + if (!page.title) { + titleEditor?.commands.focus() + } else { + contentEditorRef.current.editor.commands.focus() + } } }, [page.title, titleEditor, contentEditorRef]) From 78d8b7c8d106795e5cd9b886dd043cfc5c0fa97a Mon Sep 17 00:00:00 2001 From: Aslam H Date: Wed, 11 Sep 2024 17:27:19 +0700 Subject: [PATCH 02/20] fix(bottom-bar): fixed height --- web/components/routes/link/bottom-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index b51e4190..c5144889 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -137,7 +137,7 @@ export const LinkBottomBar: React.FC = () => { return ( From 3b68836378c87d6eb4c65ce192e334296b567f98 Mon Sep 17 00:00:00 2001 From: Kisuyo <93681660+Kisuyo@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:47:12 +0300 Subject: [PATCH 03/20] sliding model (#162) --- web/app/(pages)/layout.tsx | 4 +- web/components/routes/link/bottom-bar.tsx | 14 +++- web/components/ui/sliding-menu.tsx | 82 +++++++++++++++++++++++ web/store/sidebar.ts | 1 + 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 web/components/ui/sliding-menu.tsx diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 4b11bfbc..f39bb728 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -3,6 +3,7 @@ import { Sidebar } from "@/components/custom/sidebar/sidebar" import { CommandPalette } from "@/components/custom/command-palette/command-palette" import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import SlidingMenu from "@/components/ui/sliding-menu" export default function PageLayout({ children }: { children: React.ReactNode }) { const { me } = useAccountOrGuest() @@ -13,7 +14,8 @@ export default function PageLayout({ children }: { children: React.ReactNode }) {me._type !== "Anonymous" && } -
+
+
{children}
diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index c5144889..d41e5dd1 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef } from "react" import { motion, AnimatePresence } from "framer-motion" -import { icons } from "lucide-react" +import { icons, ZapIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils" @@ -13,6 +13,7 @@ import { PersonalLink } from "@/lib/schema" import { ID } from "jazz-tools" import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form" import { useLinkActions } from "./hooks/use-link-actions" +import { showHotkeyPanelAtom } from "@/store/sidebar" interface ToolbarButtonProps extends React.ComponentPropsWithoutRef { icon: keyof typeof icons @@ -72,6 +73,8 @@ export const LinkBottomBar: React.FC = () => { }, 100) }, [setEditId, setCreateMode]) + const [, setShowHotkeyPanel] = useAtom(showHotkeyPanelAtom) + useEffect(() => { setGlobalLinkFormExceptionRefsAtom([ overlayRef, @@ -184,6 +187,15 @@ export const LinkBottomBar: React.FC = () => { )} +
+ { + setShowHotkeyPanel(true) + }} + /> +
) } diff --git a/web/components/ui/sliding-menu.tsx b/web/components/ui/sliding-menu.tsx new file mode 100644 index 00000000..5378aeff --- /dev/null +++ b/web/components/ui/sliding-menu.tsx @@ -0,0 +1,82 @@ +import { XIcon } from "lucide-react" +import { useState, useEffect, useRef } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { showHotkeyPanelAtom } from "@/store/sidebar" +import { useAtom } from "jotai/react" + +export default function SlidingMenu() { + const [isOpen, setIsOpen] = useAtom(showHotkeyPanelAtom) + const panelRef = useRef(null) + const [shortcuts] = useState<{ name: string; shortcut: string[] }[]>([ + // TODO: change to better keybind + // TODO: windows users don't understand these symbols, figure out better way to show keybinds + { name: "New Todo", shortcut: ["⌘", "⌃", "n"] }, + { name: "CMD Palette", shortcut: ["⌘", "k"] } + // TODO: add + // { name: "Global Search", shortcut: ["."] }, + // { name: "(/pages)", shortcut: [".", "."] } + ]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside) + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + }, [isOpen, setIsOpen]) + + return ( + + {isOpen && ( + <> + setIsOpen(false)} + /> + +
+
+
Shortcuts
+ +
+
+ {shortcuts.map((shortcut, index) => ( +
+
{shortcut.name}
+
+ {shortcut.shortcut.join(" ")} +
+
+ ))} +
+
+
+ + )} +
+ ) +} diff --git a/web/store/sidebar.ts b/web/store/sidebar.ts index a3638924..79854872 100644 --- a/web/store/sidebar.ts +++ b/web/store/sidebar.ts @@ -5,3 +5,4 @@ export const toggleCollapseAtom = atom( get => get(isCollapseAtom), (get, set) => set(isCollapseAtom, !get(isCollapseAtom)) ) +export const showHotkeyPanelAtom = atom(false) From d8521a36eb8b3e712dbc7db6a7f90e9c1b06597a Mon Sep 17 00:00:00 2001 From: Kisuyo Date: Wed, 11 Sep 2024 14:13:41 +0200 Subject: [PATCH 04/20] fix: hotkey panel colors --- web/components/ui/sliding-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/ui/sliding-menu.tsx b/web/components/ui/sliding-menu.tsx index 5378aeff..1381d753 100644 --- a/web/components/ui/sliding-menu.tsx +++ b/web/components/ui/sliding-menu.tsx @@ -67,7 +67,7 @@ export default function SlidingMenu() { {shortcuts.map((shortcut, index) => (
{shortcut.name}
-
+
{shortcut.shortcut.join(" ")}
From fb774f381478ebef8ad729c96c92595133276389 Mon Sep 17 00:00:00 2001 From: Aslam H Date: Wed, 11 Sep 2024 19:28:05 +0700 Subject: [PATCH 05/20] chore(layout): remove overflow --- web/app/(pages)/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 4b11bfbc..71592f77 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -14,7 +14,7 @@ export default function PageLayout({ children }: { children: React.ReactNode }) {me._type !== "Anonymous" && }
-
+
{children}
From c5325147015e371c13b4a457418f66511365d57c Mon Sep 17 00:00:00 2001 From: Aslam H Date: Thu, 12 Sep 2024 05:22:34 +0700 Subject: [PATCH 06/20] chore: scrollable and remove link wrapper --- web/app/(pages)/layout.tsx | 2 +- web/components/routes/link/LinkRoute.tsx | 4 ++-- web/components/routes/link/list.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index f12905a6..4f86a094 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -18,7 +18,7 @@ export default function PageLayout({ children }: { children: React.ReactNode })
-
+
{children}
diff --git a/web/components/routes/link/LinkRoute.tsx b/web/components/routes/link/LinkRoute.tsx index 62e5f8a9..ce81bee5 100644 --- a/web/components/routes/link/LinkRoute.tsx +++ b/web/components/routes/link/LinkRoute.tsx @@ -51,7 +51,7 @@ export function LinkRoute(): React.ReactElement { }, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose]) return ( -
+ <> -
+ ) } diff --git a/web/components/routes/link/list.tsx b/web/components/routes/link/list.tsx index 67a0154a..47f510ca 100644 --- a/web/components/routes/link/list.tsx +++ b/web/components/routes/link/list.tsx @@ -247,7 +247,7 @@ const LinkList: React.FC = ({ activeItemIndex, setActiveItemIndex return ( Date: Thu, 12 Sep 2024 06:30:38 +0700 Subject: [PATCH 07/20] fix(link): bottom bar --- web/components/routes/link/bottom-bar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index d41e5dd1..ebffc4da 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -3,7 +3,7 @@ import { motion, AnimatePresence } from "framer-motion" import { icons, ZapIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils" +import { getSpecialShortcut, formatShortcut, isMacOS, cn } from "@/lib/utils" import { LaIcon } from "@/components/custom/la-icon" import { useAtom } from "jotai" import { parseAsBoolean, useQueryState } from "nuqs" @@ -22,9 +22,9 @@ interface ToolbarButtonProps extends React.ComponentPropsWithoutRef( - ({ icon, onClick, tooltip, ...props }, ref) => { + ({ icon, onClick, tooltip, className, ...props }, ref) => { const button = ( - ) @@ -148,7 +148,7 @@ export const LinkBottomBar: React.FC = () => { {editId && ( { {!editId && ( Date: Fri, 13 Sep 2024 11:19:39 +0300 Subject: [PATCH 08/20] hide hotkeys on small screens --- web/components/routes/link/bottom-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index ebffc4da..a9e8eae3 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -187,7 +187,7 @@ export const LinkBottomBar: React.FC = () => { )} -
+
Date: Sat, 14 Sep 2024 14:32:59 +0300 Subject: [PATCH 09/20] discord link --- bun.lockb | Bin 488392 -> 487416 bytes web/components/custom/discordIcon.tsx | 23 ++++++++++++++++++ .../sidebar/partial/profile-section.tsx | 11 +++++++++ 3 files changed, 34 insertions(+) create mode 100644 web/components/custom/discordIcon.tsx diff --git a/bun.lockb b/bun.lockb index 263389b0ea30e2c18f8c97f78ddf45d7869532fb..69ef0f39632a76c99619b241e7556d27f099ecfb 100755 GIT binary patch delta 54054 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 diff --git a/web/components/custom/discordIcon.tsx b/web/components/custom/discordIcon.tsx new file mode 100644 index 00000000..9311a3e0 --- /dev/null +++ b/web/components/custom/discordIcon.tsx @@ -0,0 +1,23 @@ +export const DiscordIcon = () => ( + + + + + +) diff --git a/web/components/custom/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx index 97ad4a7c..2f768c5d 100644 --- a/web/components/custom/sidebar/partial/profile-section.tsx +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -1,4 +1,5 @@ import { LaIcon } from "@/components/custom/la-icon" +import { DiscordIcon } from "../../discordIcon" import { useState } from "react" import { SignInButton, useAuth, useUser } from "@clerk/nextjs" import { @@ -97,6 +98,16 @@ export const ProfileSection: React.FC = () => { + + +
+ + Discord +
+ +
+ + signOut()}>
From 4ff9868e8e394cac503e7166701b9d231d4c99f3 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sat, 14 Sep 2024 14:38:27 +0300 Subject: [PATCH 10/20] clean text --- web/components/custom/learning-state-selector.tsx | 2 +- web/components/routes/link/partials/form/description-input.tsx | 2 +- web/components/routes/link/partials/form/link-form.tsx | 2 +- web/components/routes/link/partials/form/notes-section.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/custom/learning-state-selector.tsx b/web/components/custom/learning-state-selector.tsx index c1b2e4a9..5b467b86 100644 --- a/web/components/custom/learning-state-selector.tsx +++ b/web/components/custom/learning-state-selector.tsx @@ -20,7 +20,7 @@ interface LearningStateSelectorProps { export const LearningStateSelector: React.FC = ({ showSearch = true, - defaultLabel = "Select state", + defaultLabel = "State", searchPlaceholder = "Search state...", value, onChange, diff --git a/web/components/routes/link/partials/form/description-input.tsx b/web/components/routes/link/partials/form/description-input.tsx index 1ac7887d..9cc2ab7e 100644 --- a/web/components/routes/link/partials/form/description-input.tsx +++ b/web/components/routes/link/partials/form/description-input.tsx @@ -21,7 +21,7 @@ export const DescriptionInput: React.FC = () => { diff --git a/web/components/routes/link/partials/form/link-form.tsx b/web/components/routes/link/partials/form/link-form.tsx index e1f95ead..3fbce3d7 100644 --- a/web/components/routes/link/partials/form/link-form.tsx +++ b/web/components/routes/link/partials/form/link-form.tsx @@ -231,7 +231,7 @@ export const LinkForm: React.FC = ({ ( - {selectedTopic?.prettyName || "Select a topic"} + {selectedTopic?.prettyName || "Topic"} )} /> diff --git a/web/components/routes/link/partials/form/notes-section.tsx b/web/components/routes/link/partials/form/notes-section.tsx index ff3f5456..c14ff4f7 100644 --- a/web/components/routes/link/partials/form/notes-section.tsx +++ b/web/components/routes/link/partials/form/notes-section.tsx @@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => { From 26d938e66c48844403e9f63ff7cf4631528316df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Sep 2024 14:28:02 +0700 Subject: [PATCH 11/20] chore(deps): bump next from 14.2.5 to 14.2.10 in /web (#170) Bumps [next](https://github.com/vercel/next.js) from 14.2.5 to 14.2.10. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v14.2.5...v14.2.10) --- updated-dependencies: - dependency-name: next dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 278aa010..1aca7cc3 100644 --- a/web/package.json +++ b/web/package.json @@ -84,7 +84,7 @@ "jotai": "^2.9.3", "lowlight": "^3.1.0", "lucide-react": "^0.429.0", - "next": "14.2.5", + "next": "14.2.10", "next-themes": "^0.3.0", "nuqs": "^1.19.1", "react": "^18.3.1", From 8871a8959c0b950cc9608c7b0427caed0756d46b Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:11:38 +0700 Subject: [PATCH 12/20] chore(topic): Enhancement using virtual (#164) * chore: use tanstack virtual * fix: topic learning state * chore: add skeleton loading and not found topic placeholder * fix: personal links load in list --- .../routes/topics/detail/TopicDetailRoute.tsx | 88 +++-- web/components/routes/topics/detail/list.tsx | 93 +++++ .../topics/detail/partials/link-item.tsx | 325 +++++++++--------- .../routes/topics/detail/partials/section.tsx | 94 ----- .../topics/detail/partials/topic-sections.tsx | 44 --- .../topics/detail/use-link-navigation.ts | 60 ---- web/hooks/use-topic-data.ts | 15 - 7 files changed, 324 insertions(+), 395 deletions(-) create mode 100644 web/components/routes/topics/detail/list.tsx delete mode 100644 web/components/routes/topics/detail/partials/section.tsx delete mode 100644 web/components/routes/topics/detail/partials/topic-sections.tsx delete mode 100644 web/components/routes/topics/detail/use-link-navigation.ts delete mode 100644 web/hooks/use-topic-data.ts diff --git a/web/components/routes/topics/detail/TopicDetailRoute.tsx b/web/components/routes/topics/detail/TopicDetailRoute.tsx index 1bd5f42c..e805fcc8 100644 --- a/web/components/routes/topics/detail/TopicDetailRoute.tsx +++ b/web/components/routes/topics/detail/TopicDetailRoute.tsx @@ -1,12 +1,17 @@ "use client" -import React, { useMemo, useRef } from "react" +import React, { useMemo, useState } from "react" import { TopicDetailHeader } from "./Header" -import { TopicSections } from "./partials/topic-sections" +import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" +import { Topic } from "@/lib/schema" +import { TopicDetailList } from "./list" import { atom } from "jotai" -import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" -import { useTopicData } from "@/hooks/use-topic-data" +import { Skeleton } from "@/components/ui/skeleton" +import { GraphNode } from "../../public/PublicHomeRoute" +import { LaIcon } from "@/components/custom/la-icon" +const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default) interface TopicDetailRouteProps { topicName: string } @@ -14,27 +19,70 @@ interface TopicDetailRouteProps { export const openPopoverForIdAtom = atom(null) export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) { - const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) - const { topic } = useTopicData(topicName, me) - // const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks) - const linksRefDummy = useRef<(HTMLLIElement | null)[]>([]) - const containerRefDummy = useRef(null) + const raw_graph_data = React.use(graph_data_promise) as GraphNode[] - if (!topic || !me) { - return null + const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) + const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } }) + const [activeIndex, setActiveIndex] = useState(-1) + + const topicExists = raw_graph_data.find(node => node.name === topicName) + + if (!topicExists) { + return + } + + const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap(section => [ + { type: "section" as const, data: section }, + ...(section?.links?.map(link => ({ type: "link" as const, data: link })) || []) + ]) + + if (!topic || !me || !flattenedItems) { + return } return ( -
+ <> - {}} - linkRefs={linksRefDummy} - containerRef={containerRefDummy} - /> + + + ) +} + +function NotFoundPlaceholder() { + return ( +
+
+ + Topic not found +
+ There is no topic with the given identifier.
) } + +function TopicDetailSkeleton() { + return ( + <> +
+
+ + +
+ +
+ +
+ {[...Array(10)].map((_, index) => ( +
+ +
+ + +
+
+ ))} +
+ + ) +} diff --git a/web/components/routes/topics/detail/list.tsx b/web/components/routes/topics/detail/list.tsx new file mode 100644 index 00000000..30a4dcd6 --- /dev/null +++ b/web/components/routes/topics/detail/list.tsx @@ -0,0 +1,93 @@ +import React, { useRef, useCallback } from "react" +import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual" +import { Link as LinkSchema, Section as SectionSchema, Topic } from "@/lib/schema" +import { LinkItem } from "./partials/link-item" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" + +export type FlattenedItem = { type: "link"; data: LinkSchema | null } | { type: "section"; data: SectionSchema | null } + +interface TopicDetailListProps { + items: FlattenedItem[] + topic: Topic + activeIndex: number + setActiveIndex: (index: number) => void +} + +export function TopicDetailList({ items, topic, activeIndex, setActiveIndex }: TopicDetailListProps) { + const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const personalLinks = !me || me._type === "Anonymous" ? undefined : me.root.personalLinks + + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 44, + overscan: 5 + }) + + const renderItem = useCallback( + (virtualRow: VirtualItem) => { + const item = items[virtualRow.index] + + if (item.type === "section") { + return ( +
+
+

{item.data?.title}

+
+
+
+ ) + } + + if (item.data?.id) { + return ( + + ) + } + + return null + }, + [items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks] + ) + + return ( +
+
+
+ {virtualizer.getVirtualItems().map(renderItem)} +
+
+
+ ) +} diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx index 0c99fd01..77964333 100644 --- a/web/components/routes/topics/detail/partials/link-item.tsx +++ b/web/components/routes/topics/detail/partials/link-item.tsx @@ -10,198 +10,199 @@ import { Button } from "@/components/ui/button" import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector" import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils" -import { Link as LinkSchema, PersonalLink, Topic } from "@/lib/schema" +import { Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic } from "@/lib/schema" import { openPopoverForIdAtom } from "../TopicDetailRoute" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { useAccountOrGuest } from "@/lib/providers/jazz-provider" import { useClerk } from "@clerk/nextjs" -interface LinkItemProps { +interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> { topic: Topic link: LinkSchema isActive: boolean index: number setActiveIndex: (index: number) => void + personalLinks?: PersonalLinkLists } export const LinkItem = React.memo( - React.forwardRef(({ topic, link, isActive, index, setActiveIndex }, ref) => { - const clerk = useClerk() - const pathname = usePathname() - const router = useRouter() - const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom) - const [isPopoverOpen, setIsPopoverOpen] = useState(false) + React.forwardRef( + ({ topic, link, isActive, index, setActiveIndex, className, personalLinks, ...props }, ref) => { + const clerk = useClerk() + const pathname = usePathname() + const router = useRouter() + const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const { me } = useAccountOrGuest() - const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const personalLink = useMemo(() => { + return personalLinks?.find(pl => pl?.link?.id === link.id) + }, [personalLinks, link.id]) - const personalLinks = useMemo(() => { - if (!me || me._type === "Anonymous") return undefined - return me?.root?.personalLinks || [] - }, [me]) + const selectedLearningState = useMemo(() => { + return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState) + }, [personalLink?.learningState]) - const personalLink = useMemo(() => { - return personalLinks?.find(pl => pl?.link?.id === link.id) - }, [personalLinks, link.id]) + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + setActiveIndex(index) + }, + [index, setActiveIndex] + ) - const selectedLearningState = useMemo(() => { - return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState) - }, [personalLink?.learningState]) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - setActiveIndex(index) - }, - [index, setActiveIndex] - ) - - const handleSelectLearningState = useCallback( - (learningState: LearningStateValue) => { - if (!personalLinks || !me || me?._type === "Anonymous") { - return clerk.redirectToSignIn({ - redirectUrl: pathname - }) - } - - const defaultToast = { - duration: 5000, - position: "bottom-right" as const, - closeButton: true, - action: { - label: "Go to list", - onClick: () => router.push("/links") + const handleSelectLearningState = useCallback( + (learningState: LearningStateValue) => { + if (!personalLinks || !me || me?._type === "Anonymous") { + return clerk.redirectToSignIn({ + redirectUrl: pathname + }) } - } - if (personalLink) { - if (personalLink.learningState === learningState) { - personalLink.learningState = undefined - toast.error("Link learning state removed", defaultToast) + const defaultToast = { + duration: 5000, + position: "bottom-right" as const, + closeButton: true, + action: { + label: "Go to list", + onClick: () => router.push("/links") + } + } + + if (personalLink) { + if (personalLink.learningState === learningState) { + personalLink.learningState = undefined + toast.error("Link learning state removed", defaultToast) + } else { + personalLink.learningState = learningState + toast.success("Link learning state updated", defaultToast) + } } else { - personalLink.learningState = learningState - toast.success("Link learning state updated", defaultToast) + const slug = generateUniqueSlug(link.title) + const newPersonalLink = PersonalLink.create( + { + url: link.url, + title: link.title, + slug, + link, + learningState, + sequence: personalLinks.length + 1, + completed: false, + topic, + createdAt: new Date(), + updatedAt: new Date() + }, + { owner: me } + ) + + personalLinks.push(newPersonalLink) + + toast.success("Link added.", { + ...defaultToast, + description: `${link.title} has been added to your personal link.` + }) } - } else { - const slug = generateUniqueSlug(link.title) - const newPersonalLink = PersonalLink.create( + + setOpenPopoverForId(null) + setIsPopoverOpen(false) + }, + [personalLink, personalLinks, me, link, router, topic, setOpenPopoverForId, clerk, pathname] + ) + + const handlePopoverOpenChange = useCallback( + (open: boolean) => { + setIsPopoverOpen(open) + setOpenPopoverForId(open ? link.id : null) + }, + [link.id, setOpenPopoverForId] + ) + + return ( +
{ - setIsPopoverOpen(open) - setOpenPopoverForId(open ? link.id : null) - }, - [link.id, setOpenPopoverForId] - ) - - return ( -
  • -
    -
    - - - - - e.preventDefault()} - > - handleSelectLearningState(value as LearningStateValue)} - /> - - - -
    -
    -

    - {link.title} -

    - -
    -
    -
  • - ) - }) + ) + } + ) ) LinkItem.displayName = "LinkItem" diff --git a/web/components/routes/topics/detail/partials/section.tsx b/web/components/routes/topics/detail/partials/section.tsx deleted file mode 100644 index 7f816432..00000000 --- a/web/components/routes/topics/detail/partials/section.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { LinkItem } from "./link-item" -import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema" -import { Skeleton } from "@/components/ui/skeleton" -import { LaIcon } from "@/components/custom/la-icon" - -interface SectionProps { - topic: Topic - section: SectionSchema - activeIndex: number - startIndex: number - linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]> - setActiveIndex: (index: number) => void -} - -export function Section({ topic, section, activeIndex, setActiveIndex, startIndex, linkRefs }: SectionProps) { - const [nLinksToLoad, setNLinksToLoad] = useState(10) - - const linksToLoad = useMemo(() => { - return section.links?.slice(0, nLinksToLoad) - }, [section.links, nLinksToLoad]) - - return ( -
    -
    -

    {section.title}

    -
    -
    - -
    - {linksToLoad?.map((link, index) => - link?.url ? ( - { - linkRefs.current[startIndex + index] = el - }} - /> - ) : ( - - ) - )} - {section.links?.length && section.links?.length > nLinksToLoad && ( - setNLinksToLoad(n => n + 10)} /> - )} -
    -
    - ) -} - -const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => { - const spinnerRef = useRef(null) - - const handleIntersection = useCallback( - (entries: IntersectionObserverEntry[]) => { - const [entry] = entries - if (entry.isIntersecting) { - onLoadMore() - } - }, - [onLoadMore] - ) - - useEffect(() => { - const observer = new IntersectionObserver(handleIntersection, { - root: null, - rootMargin: "0px", - threshold: 1.0 - }) - - const currentSpinnerRef = spinnerRef.current - - if (currentSpinnerRef) { - observer.observe(currentSpinnerRef) - } - - return () => { - if (currentSpinnerRef) { - observer.unobserve(currentSpinnerRef) - } - } - }, [handleIntersection]) - - return ( -
    - -
    - ) -} diff --git a/web/components/routes/topics/detail/partials/topic-sections.tsx b/web/components/routes/topics/detail/partials/topic-sections.tsx deleted file mode 100644 index a8b6ed5f..00000000 --- a/web/components/routes/topics/detail/partials/topic-sections.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react" -import { Section } from "./section" -import { LaAccount, ListOfSections, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema" - -interface TopicSectionsProps { - topic: Topic - sections: (ListOfSections | null) | undefined - activeIndex: number - setActiveIndex: (index: number) => void - linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]> - containerRef: React.RefObject -} - -export function TopicSections({ - topic, - sections, - activeIndex, - setActiveIndex, - linkRefs, - containerRef, -}: TopicSectionsProps) { - return ( -
    -
    -
    - {sections?.map( - (section, sectionIndex) => - section?.id && ( -
    acc + (s?.links?.length || 0), 0)} - linkRefs={linkRefs} - /> - ) - )} -
    -
    -
    - ) -} diff --git a/web/components/routes/topics/detail/use-link-navigation.ts b/web/components/routes/topics/detail/use-link-navigation.ts deleted file mode 100644 index 5e72054b..00000000 --- a/web/components/routes/topics/detail/use-link-navigation.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useState, useRef, useCallback, useEffect } from "react" -import { Link as LinkSchema } from "@/lib/schema" -import { ensureUrlProtocol } from "@/lib/utils" - -export function useLinkNavigation(allLinks: (LinkSchema | null)[]) { - const [activeIndex, setActiveIndex] = useState(-1) - const containerRef = useRef(null) - const linkRefs = useRef<(HTMLLIElement | null)[]>(allLinks.map(() => null)) - - const scrollToLink = useCallback((index: number) => { - if (linkRefs.current[index] && containerRef.current) { - const linkElement = linkRefs.current[index] - const container = containerRef.current - - const linkRect = linkElement?.getBoundingClientRect() - const containerRect = container.getBoundingClientRect() - - if (linkRect && containerRect) { - if (linkRect.bottom > containerRect.bottom) { - container.scrollTop += linkRect.bottom - containerRect.bottom - } else if (linkRect.top < containerRect.top) { - container.scrollTop -= containerRect.top - linkRect.top - } - } - } - }, []) - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault() - setActiveIndex(prevIndex => { - const newIndex = (prevIndex + 1) % allLinks.length - scrollToLink(newIndex) - return newIndex - }) - } else if (e.key === "ArrowUp") { - e.preventDefault() - setActiveIndex(prevIndex => { - const newIndex = (prevIndex - 1 + allLinks.length) % allLinks.length - scrollToLink(newIndex) - return newIndex - }) - } else if (e.key === "Enter" && activeIndex !== -1) { - const link = allLinks[activeIndex] - if (link) { - window.open(ensureUrlProtocol(link.url), "_blank") - } - } - }, - [activeIndex, allLinks, scrollToLink] - ) - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleKeyDown]) - - return { activeIndex, setActiveIndex, containerRef, linkRefs } -} diff --git a/web/hooks/use-topic-data.ts b/web/hooks/use-topic-data.ts deleted file mode 100644 index 8280762b..00000000 --- a/web/hooks/use-topic-data.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useMemo } from "react" -import { useCoState } from "@/lib/providers/jazz-provider" -import { PublicGlobalGroup } from "@/lib/schema/master/public-group" -import { Account, AnonymousJazzAgent, ID } from "jazz-tools" -import { Link, Topic } from "@/lib/schema" - -const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID - -export function useTopicData(topicName: string, me: Account | AnonymousJazzAgent | undefined) { - const topicID = useMemo(() => me && Topic.findUnique({ topicName }, GLOBAL_GROUP_ID, me), [topicName, me]) - - const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [{ links: [] }] } }) - - return { topic } -} From afaef5d3c564abb8980bcb3440f7bc688dbcb0d5 Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:11:52 +0700 Subject: [PATCH 13/20] fix(link): Navigate between item and fix Enter keybind (#165) * feat: add item scroll to active * fix: reset enterkey and scroll to view * fix: link item displayName --- web/components/routes/link/LinkRoute.tsx | 8 +- web/components/routes/link/header.tsx | 2 +- web/components/routes/link/list.tsx | 10 +- .../routes/link/partials/link-item.tsx | 246 +++++++++--------- web/hooks/use-active-item-scroll.ts | 30 +++ 5 files changed, 167 insertions(+), 129 deletions(-) create mode 100644 web/hooks/use-active-item-scroll.ts diff --git a/web/components/routes/link/LinkRoute.tsx b/web/components/routes/link/LinkRoute.tsx index ce81bee5..bfbc424a 100644 --- a/web/components/routes/link/LinkRoute.tsx +++ b/web/components/routes/link/LinkRoute.tsx @@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs" import { atom, useAtom } from "jotai" import { LinkBottomBar } from "./bottom-bar" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" +import { useKey } from "react-use" export const isDeleteConfirmShownAtom = atom(false) export function LinkRoute(): React.ReactElement { - const [nuqsEditId] = useQueryState("editId") + const [nuqsEditId, setNuqsEditId] = useQueryState("editId") const [activeItemIndex, setActiveItemIndex] = useState(null) const [isInCreateMode] = useQueryState("create", parseAsBoolean) const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom) @@ -50,6 +51,11 @@ export function LinkRoute(): React.ReactElement { } }, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose]) + useKey("Escape", () => { + setDisableEnterKey(false) + setNuqsEditId(null) + }) + return ( <> diff --git a/web/components/routes/link/header.tsx b/web/components/routes/link/header.tsx index 3fa3d863..93d14e7c 100644 --- a/web/components/routes/link/header.tsx +++ b/web/components/routes/link/header.tsx @@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => { return ( <> - +
    diff --git a/web/components/routes/link/list.tsx b/web/components/routes/link/list.tsx index 47f510ca..dfd10cd5 100644 --- a/web/components/routes/link/list.tsx +++ b/web/components/routes/link/list.tsx @@ -24,6 +24,7 @@ import { commandPaletteOpenAtom } from "@/components/custom/command-palette/comm import { useConfirm } from "@omit/react-confirm-dialog" import { useLinkActions } from "./hooks/use-link-actions" import { isDeleteConfirmShownAtom } from "./LinkRoute" +import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" interface LinkListProps { activeItemIndex: number | null @@ -77,12 +78,6 @@ const LinkList: React.FC = ({ activeItemIndex, setActiveItemIndex }) ) - useKey("Escape", () => { - if (editId) { - setEditId(null) - } - }) - useKey( event => (event.metaKey || event.ctrlKey) && event.key === "Backspace", async () => { @@ -245,6 +240,8 @@ const LinkList: React.FC = ({ activeItemIndex, setActiveItemIndex setDraggingId(null) } + const setElementRef = useActiveItemScroll({ activeIndex: activeItemIndex }) + return ( = ({ activeItemIndex, setActiveItemIndex isActive={activeItemIndex === index} setActiveItemIndex={setActiveItemIndex} index={index} + ref={el => setElementRef(el, index)} /> ) )} diff --git a/web/components/routes/link/partials/link-item.tsx b/web/components/routes/link/partials/link-item.tsx index 1718896f..9a430d8b 100644 --- a/web/components/routes/link/partials/link-item.tsx +++ b/web/components/routes/link/partials/link-item.tsx @@ -15,7 +15,7 @@ import { cn, ensureUrlProtocol } from "@/lib/utils" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { linkOpenPopoverForIdAtom } from "@/store/link" -interface LinkItemProps { +interface LinkItemProps extends React.HTMLAttributes { personalLink: PersonalLink disabled?: boolean isEditing: boolean @@ -26,134 +26,138 @@ interface LinkItemProps { index: number } -export const LinkItem: React.FC = ({ - isEditing, - setEditId, - personalLink, - disabled = false, - isDragging, - isActive, - setActiveItemIndex, - index -}) => { - const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) +export const LinkItem = React.forwardRef( + ({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => { + const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) - const style = useMemo( - () => ({ - transform: CSS.Transform.toString(transform), - transition, - pointerEvents: isDragging ? "none" : "auto" - }), - [transform, transition, isDragging] - ) + const style = useMemo( + () => ({ + transform: CSS.Transform.toString(transform), + transition, + pointerEvents: isDragging ? "none" : "auto" + }), + [transform, transition, isDragging] + ) - const handleSuccess = useCallback(() => setEditId(null), [setEditId]) - const handleOnClose = useCallback(() => setEditId(null), [setEditId]) - const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id]) + const handleSuccess = useCallback(() => setEditId(null), [setEditId]) + const handleOnClose = useCallback(() => setEditId(null), [setEditId]) + const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id]) - const selectedLearningState = useMemo( - () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), - [personalLink.learningState] - ) + const selectedLearningState = useMemo( + () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), + [personalLink.learningState] + ) - const handleLearningStateSelect = useCallback( - (value: string) => { - const learningState = value as LearningStateValue - personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState - setOpenPopoverForId(null) - }, - [personalLink, setOpenPopoverForId] - ) + const handleLearningStateSelect = useCallback( + (value: string) => { + const learningState = value as LearningStateValue + personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState + setOpenPopoverForId(null) + }, + [personalLink, setOpenPopoverForId] + ) - if (isEditing) { - return {}} /> - } + if (isEditing) { + return ( + {}} /> + ) + } - return ( -
  • setActiveItemIndex(index)} - onBlur={() => setActiveItemIndex(null)} - className={cn( - "relative cursor-default outline-none", - "mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2", - { - "bg-muted-foreground/5": isActive, - "hover:bg-muted/50": !isActive - } - )} - onDoubleClick={handleRowDoubleClick} - > - setOpenPopoverForId(open ? personalLink.id : null)} - > - - - - e.preventDefault()} - > - - - - -
    - {personalLink.icon && ( - {personalLink.title} + return ( +
  • { + setNodeRef(node) + if (typeof ref === "function") { + ref(node) + } else if (ref) { + ref.current = node + } + }} + style={style as React.CSSProperties} + {...attributes} + {...listeners} + tabIndex={0} + onFocus={() => setActiveItemIndex(index)} + onBlur={() => setActiveItemIndex(null)} + className={cn( + "relative cursor-default outline-none", + "grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2", + { + "bg-muted-foreground/5": isActive, + "hover:bg-muted/50": !isActive + } )} + onDoubleClick={handleRowDoubleClick} + > + setOpenPopoverForId(open ? personalLink.id : null)} + > + + + + e.preventDefault()} + > + + + +
    -

    {personalLink.title}

    - {personalLink.url && ( -
    -
    + {personalLink.icon && ( + {personalLink.title} + )} +
    +

    {personalLink.title}

    + {personalLink.url && ( +
    +
    + )} +
    +
    + +
    + {personalLink.topic && ( + + {personalLink.topic.prettyName} + )}
    -
  • + + ) + } +) -
    - {personalLink.topic && ( - - {personalLink.topic.prettyName} - - )} -
    - - ) -} +LinkItem.displayName = "LinkItem" diff --git a/web/hooks/use-active-item-scroll.ts b/web/hooks/use-active-item-scroll.ts new file mode 100644 index 00000000..81f01fee --- /dev/null +++ b/web/hooks/use-active-item-scroll.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, useCallback } from "react" + +type ElementRef = T | null +type ElementRefs = ElementRef[] + +interface ActiveItemScrollOptions { + activeIndex: number | null +} + +export function useActiveItemScroll(options: ActiveItemScrollOptions) { + const { activeIndex } = options + const elementRefs = useRef>([]) + + const scrollActiveElementIntoView = useCallback((index: number) => { + const activeElement = elementRefs.current[index] + activeElement?.scrollIntoView({ block: "nearest" }) + }, []) + + useEffect(() => { + if (activeIndex !== null) { + scrollActiveElementIntoView(activeIndex) + } + }, [activeIndex, scrollActiveElementIntoView]) + + const setElementRef = useCallback((element: ElementRef, index: number) => { + elementRefs.current[index] = element + }, []) + + return setElementRef +} From c003711905cbdfb65f32a0e5b9b769dcdd6a7e49 Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:12:05 +0700 Subject: [PATCH 14/20] fix(page): Add item scroll, fix display issues, refactor nav, and improve perf (#166) * feat: add item scroll to active * fix: reset enterkey and scroll to view * fix: link item displayName * refactor: remove keyboard page nav * chore: fix scrolling, perf, keys, highlight active item etc * chore: use new hook for create a page * chore: disabled auto delete page --- .../hooks/use-command-actions.ts | 19 ++-- .../custom/sidebar/partial/page-section.tsx | 15 +-- .../routes/page/detail/PageDetailRoute.tsx | 53 ++++++----- web/components/routes/page/header.tsx | 70 +++++++------- .../page/hooks/use-keyboard-navigation.ts | 69 -------------- .../routes/page/hooks/use-page-actions.ts | 11 ++- web/components/routes/page/list.tsx | 91 +++++++++++-------- .../routes/page/partials/page-item.tsx | 23 ++--- 8 files changed, 142 insertions(+), 209 deletions(-) delete mode 100644 web/components/routes/page/hooks/use-keyboard-navigation.ts diff --git a/web/components/custom/command-palette/hooks/use-command-actions.ts b/web/components/custom/command-palette/hooks/use-command-actions.ts index 453e365c..589d95d6 100644 --- a/web/components/custom/command-palette/hooks/use-command-actions.ts +++ b/web/components/custom/command-palette/hooks/use-command-actions.ts @@ -3,11 +3,13 @@ import { ensureUrlProtocol } from "@/lib/utils" import { useTheme } from "next-themes" import { toast } from "sonner" import { useRouter } from "next/navigation" -import { LaAccount, PersonalPage } from "@/lib/schema" +import { LaAccount } from "@/lib/schema" +import { usePageActions } from "@/components/routes/page/hooks/use-page-actions" export const useCommandActions = () => { const { setTheme } = useTheme() const router = useRouter() + const { newPage } = usePageActions() const changeTheme = React.useCallback( (theme: string) => { @@ -35,19 +37,10 @@ export const useCommandActions = () => { const createNewPage = React.useCallback( (me: LaAccount) => { - try { - const newPersonalPage = PersonalPage.create( - { public: false, createdAt: new Date(), updatedAt: new Date() }, - { owner: me._owner } - ) - - me.root?.personalPages?.push(newPersonalPage) - router.push(`/pages/${newPersonalPage.id}`) - } catch (error) { - toast.error("Failed to create page") - } + const page = newPage(me) + router.push(`/pages/${page.id}`) }, - [router] + [router, newPage] ) return { diff --git a/web/components/custom/sidebar/partial/page-section.tsx b/web/components/custom/sidebar/partial/page-section.tsx index 4b9daf28..c5845c58 100644 --- a/web/components/custom/sidebar/partial/page-section.tsx +++ b/web/components/custom/sidebar/partial/page-section.tsx @@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils" import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page" import { Button } from "@/components/ui/button" import { LaIcon } from "@/components/custom/la-icon" -import { toast } from "sonner" import Link from "next/link" import { DropdownMenu, @@ -21,6 +20,7 @@ import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { icons } from "lucide-react" +import { usePageActions } from "@/components/routes/page/hooks/use-page-actions" type SortOption = "title" | "recent" type ShowOption = 5 | 10 | 15 | 20 | 0 @@ -101,20 +101,13 @@ const PageSectionHeader: React.FC = ({ pageCount, isActi const NewPageButton: React.FC = () => { const { me } = useAccount() const router = useRouter() + const { newPage } = usePageActions() if (!me) return null const handleClick = () => { - try { - const newPersonalPage = PersonalPage.create( - { public: false, createdAt: new Date(), updatedAt: new Date() }, - { owner: me._owner } - ) - me.root?.personalPages?.push(newPersonalPage) - router.push(`/pages/${newPersonalPage.id}`) - } catch (error) { - toast.error("Failed to create page") - } + const page = newPage(me) + router.push(`/pages/${page.id}`) } return ( diff --git a/web/components/routes/page/detail/PageDetailRoute.tsx b/web/components/routes/page/detail/PageDetailRoute.tsx index ebeeb250..0c6467ff 100644 --- a/web/components/routes/page/detail/PageDetailRoute.tsx +++ b/web/components/routes/page/detail/PageDetailRoute.tsx @@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions" const TITLE_PLACEHOLDER = "Untitled" -const emptyPage = (page: PersonalPage): boolean => { +const isPageEmpty = (page: PersonalPage): boolean => { return (!page.title || page.title.trim() === "") && (!page.content || Object.keys(page.content).length === 0) } -export const DeleteEmptyPage = (currentPageId: string | null) => { +const useDeleteEmptyPage = (currentPageId: string | null) => { const router = useRouter() const { me } = useAccount({ root: { @@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => { }) useEffect(() => { - const handleRouteChange = () => { + return () => { if (!currentPageId || !me?.root?.personalPages) return const currentPage = me.root.personalPages.find(page => page?.id === currentPageId) - if (currentPage && emptyPage(currentPage)) { + if (currentPage && isPageEmpty(currentPage)) { const index = me.root.personalPages.findIndex(page => page?.id === currentPageId) if (index !== -1) { me.root.personalPages.splice(index, 1) } } } - - return () => { - handleRouteChange() - } }, [currentPageId, me, router]) } @@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) { const { deletePage } = usePageActions() const confirm = useConfirm() - DeleteEmptyPage(pageId) + // useDeleteEmptyPage(pageId) - const handleDelete = async () => { + const handleDelete = useCallback(async () => { const result = await confirm({ title: "Delete page", description: "Are you sure you want to delete this page?", @@ -78,7 +74,7 @@ export function PageDetailRoute({ pageId }: { pageId: string }) { deletePage(me, pageId as ID) router.push("/pages") } - } + }, [confirm, deletePage, me, pageId, router]) if (!page) return null @@ -132,29 +128,32 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const isContentInitialMount = useRef(true) const isInitialFocusApplied = useRef(false) - const updatePageContent = (content: Content, model: PersonalPage) => { + const updatePageContent = useCallback((content: Content, model: PersonalPage) => { if (isContentInitialMount.current) { isContentInitialMount.current = false return } model.content = content model.updatedAt = new Date() - } + }, []) - const handleUpdateTitle = (editor: Editor) => { - if (isTitleInitialMount.current) { - isTitleInitialMount.current = false - return - } + const handleUpdateTitle = useCallback( + (editor: Editor) => { + if (isTitleInitialMount.current) { + isTitleInitialMount.current = false + return + } - const newTitle = editor.getText() - if (newTitle !== page.title) { - const slug = generateUniqueSlug(page.title?.toString() || "") - page.title = newTitle - page.slug = slug - page.updatedAt = new Date() - } - } + const newTitle = editor.getText() + if (newTitle !== page.title) { + const slug = generateUniqueSlug(page.title?.toString() || "") + page.title = newTitle + page.slug = slug + page.updatedAt = new Date() + } + }, + [page] + ) const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => { const editor = titleEditorRef.current @@ -254,7 +253,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { contentEditorRef.current.editor.commands.focus() } } - }, [page.title, titleEditor, contentEditorRef]) + }, [page.title, titleEditor]) return (
    diff --git a/web/components/routes/page/header.tsx b/web/components/routes/page/header.tsx index af1c1b22..93ea9664 100644 --- a/web/components/routes/page/header.tsx +++ b/web/components/routes/page/header.tsx @@ -1,54 +1,58 @@ "use client" import * as React from "react" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" import { LaIcon } from "@/components/custom/la-icon" import { useAccount } from "@/lib/providers/jazz-provider" -import { useRouter } from "next/navigation" -import { PersonalPage } from "@/lib/schema" -import { toast } from "sonner" +import { usePageActions } from "./hooks/use-page-actions" -export const PageHeader = React.memo(() => { +interface PageHeaderProps {} + +export const PageHeader: React.FC = React.memo(() => { const { me } = useAccount() const router = useRouter() + const { newPage } = usePageActions() if (!me) return null - const handleClick = () => { - try { - const newPersonalPage = PersonalPage.create( - { public: false, createdAt: new Date(), updatedAt: new Date() }, - { owner: me._owner } - ) - me.root?.personalPages?.push(newPersonalPage) - router.push(`/pages/${newPersonalPage.id}`) - } catch (error) { - toast.error("Failed to create page") - } + const handleNewPageClick = () => { + const page = newPage(me) + router.push(`/pages/${page.id}`) } return ( - -
    - -
    - Pages -
    -
    - -
    - -
    -
    - -
    -
    + + +
    + ) }) PageHeader.displayName = "PageHeader" + +const HeaderTitle: React.FC = () => ( +
    + +
    + Pages +
    +
    +) + +interface NewPageButtonProps { + onClick: () => void +} + +const NewPageButton: React.FC = ({ onClick }) => ( +
    +
    + +
    +
    +) diff --git a/web/components/routes/page/hooks/use-keyboard-navigation.ts b/web/components/routes/page/hooks/use-keyboard-navigation.ts deleted file mode 100644 index 3878b653..00000000 --- a/web/components/routes/page/hooks/use-keyboard-navigation.ts +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useEffect, useRef, useCallback } from "react" -import { PersonalPage, PersonalPageLists } from "@/lib/schema" - -interface UseKeyboardNavigationProps { - personalPages?: PersonalPageLists | null - activeItemIndex: number | null - setActiveItemIndex: React.Dispatch> - isCommandPaletteOpen: boolean - disableEnterKey: boolean - onEnter?: (selectedPage: PersonalPage) => void -} - -export const useKeyboardNavigation = ({ - personalPages, - activeItemIndex, - setActiveItemIndex, - isCommandPaletteOpen, - disableEnterKey, - onEnter -}: UseKeyboardNavigationProps) => { - const listRef = useRef(null) - const itemRefs = useRef<(HTMLAnchorElement | null)[]>([]) - const itemCount = personalPages?.length || 0 - - const scrollIntoView = useCallback((index: number) => { - if (itemRefs.current[index]) { - itemRefs.current[index]?.scrollIntoView({ - block: "nearest" - }) - } - }, []) - - useEffect(() => { - if (activeItemIndex !== null) { - scrollIntoView(activeItemIndex) - } - }, [activeItemIndex, scrollIntoView]) - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (isCommandPaletteOpen) return - - if (e.key === "ArrowUp" || e.key === "ArrowDown") { - e.preventDefault() - setActiveItemIndex(prevIndex => { - if (prevIndex === null) return 0 - const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount - return newIndex - }) - } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) { - e.preventDefault() - const selectedPage = personalPages[activeItemIndex] - if (selectedPage) onEnter?.(selectedPage) - } - }, - [itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, onEnter] - ) - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleKeyDown]) - - const setItemRef = useCallback((el: HTMLAnchorElement | null, index: number) => { - itemRefs.current[index] = el - }, []) - - return { listRef, setItemRef } -} diff --git a/web/components/routes/page/hooks/use-page-actions.ts b/web/components/routes/page/hooks/use-page-actions.ts index 986ea3d8..65bc7276 100644 --- a/web/components/routes/page/hooks/use-page-actions.ts +++ b/web/components/routes/page/hooks/use-page-actions.ts @@ -4,6 +4,15 @@ import { LaAccount, PersonalPage } from "@/lib/schema" import { ID } from "jazz-tools" export const usePageActions = () => { + const newPage = useCallback((me: LaAccount): PersonalPage => { + const newPersonalPage = PersonalPage.create( + { public: false, createdAt: new Date(), updatedAt: new Date() }, + { owner: me._owner } + ) + me.root?.personalPages?.push(newPersonalPage) + return newPersonalPage + }, []) + const deletePage = useCallback((me: LaAccount, pageId: ID): void => { if (!me.root?.personalPages) return @@ -32,5 +41,5 @@ export const usePageActions = () => { } }, []) - return { deletePage } + return { newPage, deletePage } } diff --git a/web/components/routes/page/list.tsx b/web/components/routes/page/list.tsx index fb525661..c226559a 100644 --- a/web/components/routes/page/list.tsx +++ b/web/components/routes/page/list.tsx @@ -1,15 +1,15 @@ -import React, { useMemo, useCallback } from "react" +import React, { useMemo, useCallback, useEffect } from "react" import { Primitive } from "@radix-ui/react-primitive" import { useAccount } from "@/lib/providers/jazz-provider" import { useAtom } from "jotai" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { PageItem } from "./partials/page-item" -import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation" import { useMedia } from "react-use" import { Column } from "./partials/column" import { useColumnStyles } from "./hooks/use-column-styles" import { PersonalPage, PersonalPageLists } from "@/lib/schema" import { useRouter } from "next/navigation" +import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" interface PageListProps { activeItemIndex: number | null @@ -23,6 +23,7 @@ export const PageList: React.FC = ({ activeItemIndex, setActiveIt const { me } = useAccount({ root: { personalPages: [] } }) const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages]) const router = useRouter() + const itemCount = personalPages?.length || 0 const handleEnter = useCallback( (selectedPage: PersonalPage) => { @@ -31,24 +32,35 @@ export const PageList: React.FC = ({ activeItemIndex, setActiveIt [router] ) - const { listRef, setItemRef } = useKeyboardNavigation({ - personalPages, - activeItemIndex, - setActiveItemIndex, - isCommandPaletteOpen, - disableEnterKey, - onEnter: handleEnter - }) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isCommandPaletteOpen) return + + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault() + setActiveItemIndex(prevIndex => { + if (prevIndex === null) return 0 + const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount + return newIndex + }) + } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) { + e.preventDefault() + const selectedPage = personalPages[activeItemIndex] + if (selectedPage) handleEnter?.(selectedPage) + } + }, + [itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, handleEnter] + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) return ( -
    +
    {!isTablet && } - +
    ) } @@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => { } interface PageListItemsProps { - listRef: React.RefObject - setItemRef: (el: HTMLAnchorElement | null, index: number) => void personalPages?: PersonalPageLists | null activeItemIndex: number | null } -const PageListItems: React.FC = ({ listRef, setItemRef, personalPages, activeItemIndex }) => ( - - {personalPages?.map( - (page, index) => - page?.id && ( - setItemRef(el, index)} - page={page} - isActive={index === activeItemIndex} - /> - ) - )} - -) +const PageListItems: React.FC = ({ personalPages, activeItemIndex }) => { + const setElementRef = useActiveItemScroll({ activeIndex: activeItemIndex }) + + return ( + + {personalPages?.map( + (page, index) => + page?.id && ( + setElementRef(el, index)} + page={page} + isActive={index === activeItemIndex} + /> + ) + )} + + ) +} diff --git a/web/components/routes/page/partials/page-item.tsx b/web/components/routes/page/partials/page-item.tsx index 8b8488b7..65dacf66 100644 --- a/web/components/routes/page/partials/page-item.tsx +++ b/web/components/routes/page/partials/page-item.tsx @@ -21,14 +21,10 @@ export const PageItem = React.forwardRef(({ pa @@ -38,14 +34,9 @@ export const PageItem = React.forwardRef(({ pa {!isTablet && ( - <> - {/* - {page.slug} - */} - - {page.topic && {page.topic.prettyName}} - - + + {page.topic && {page.topic.prettyName}} + )} From 0df105f186278de7a123cdbb931b9413a20f407e Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:12:19 +0700 Subject: [PATCH 15/20] chore: add item value and cut line 1 (#167) --- web/components/custom/command-palette/command-data.ts | 1 + web/components/custom/command-palette/command-items.tsx | 6 +++--- web/components/custom/command-palette/command-palette.tsx | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/components/custom/command-palette/command-data.ts b/web/components/custom/command-palette/command-data.ts index ac3f4cb1..01221ec1 100644 --- a/web/components/custom/command-palette/command-data.ts +++ b/web/components/custom/command-palette/command-data.ts @@ -6,6 +6,7 @@ import { HTMLLikeElement } from "@/lib/utils" export type CommandAction = string | (() => void) export interface CommandItemType { + id?: string icon?: keyof typeof icons value: string label: HTMLLikeElement | string diff --git a/web/components/custom/command-palette/command-items.tsx b/web/components/custom/command-palette/command-items.tsx index 9555ad16..51f64d3a 100644 --- a/web/components/custom/command-palette/command-items.tsx +++ b/web/components/custom/command-palette/command-items.tsx @@ -11,14 +11,14 @@ export interface CommandItemProps extends Omit { } const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = React.memo(({ content }) => { - return <>{renderHTMLLikeElement(content)} + return {renderHTMLLikeElement(content)} }) HTMLLikeRenderer.displayName = "HTMLLikeRenderer" export const CommandItem: React.FC = React.memo( - ({ icon, label, action, payload, shortcut, handleAction }) => ( - handleAction(action, payload)}> + ({ icon, label, action, payload, shortcut, handleAction, ...item }) => ( + handleAction(action, payload)}> {icon && } {shortcut && {shortcut}} diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx index 4e2fd40c..d797e381 100644 --- a/web/components/custom/command-palette/command-palette.tsx +++ b/web/components/custom/command-palette/command-palette.tsx @@ -86,6 +86,7 @@ export function CommandPalette() { heading: "Personal Links", items: me?.root.personalLinks?.map(link => ({ + id: link?.id, icon: "Link" as const, value: link?.title || "Untitled", label: link?.title || "Untitled", @@ -100,6 +101,7 @@ export function CommandPalette() { heading: "Personal Pages", items: me?.root.personalPages?.map(page => ({ + id: page?.id, icon: "FileText" as const, value: page?.title || "Untitled", label: page?.title || "Untitled", @@ -184,7 +186,7 @@ export function CommandPalette() { const commandKey = React.useMemo(() => { return filteredCommands .map(group => { - const itemsKey = group.items.map(item => `${item.label}-${item.action}`).join("|") + const itemsKey = group.items.map(item => `${item.label}-${item.value}`).join("|") return `${group.heading}:${itemsKey}` }) .join("__") From 8eed3f8cc29ac07c496acbec7650cfb68b4b0a41 Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:17:11 +0700 Subject: [PATCH 16/20] feat(shortcut): Keyboard Navigation (#168) * chore: remove sliding menu * feat(ui): sheet * feat: shortcut component * chore: register new shortcut component to layout * fix: react attr naming * fix: set default to false for shortcut * feat(store): keydown-manager * feat(hooks): keyboard manager * chore: use util from base for la-editor * chore: use util from base for minimal-tiptap-editor * chore(utils): keyboard * chore: use new keyboard manager * fix: uniqueness of certain component * feat: global key handler * chore: implement new key handler --- web/app/(pages)/layout.tsx | 12 +- web/components/custom/Shortcut/shortcut.tsx | 155 +++++++++++++ .../command-palette/command-palette.tsx | 16 +- web/components/custom/discordIcon.tsx | 14 +- .../custom/global-keydown-handler.tsx | 63 ++++++ .../sidebar/partial/profile-section.tsx | 203 ++++++++++-------- .../la-editor/components/ui/shortcut.tsx | 4 +- .../extensions/slash-command/menu-list.tsx | 8 +- web/components/la-editor/lib/utils/index.ts | 2 - .../la-editor/lib/utils/keyboard.ts | 25 --- .../la-editor/lib/utils/platform.ts | 46 ---- .../components/shortcut-key.tsx | 48 ++--- .../components/toolbar-section.tsx | 196 ++++++++--------- web/components/minimal-tiptap/utils.ts | 89 +------- web/components/routes/link/bottom-bar.tsx | 41 ++-- .../routes/topics/detail/TopicDetailRoute.tsx | 1 + web/components/ui/sheet.tsx | 109 ++++++++++ web/components/ui/sliding-menu.tsx | 82 ------- web/hooks/use-keyboard-manager.ts | 38 ++++ web/hooks/use-keydown-listener.ts | 21 ++ web/lib/utils/keyboard.ts | 24 +-- web/store/keydown-manager.ts | 3 + web/store/sidebar.ts | 1 - 23 files changed, 686 insertions(+), 515 deletions(-) create mode 100644 web/components/custom/Shortcut/shortcut.tsx create mode 100644 web/components/custom/global-keydown-handler.tsx delete mode 100644 web/components/la-editor/lib/utils/keyboard.ts delete mode 100644 web/components/la-editor/lib/utils/platform.ts create mode 100644 web/components/ui/sheet.tsx delete mode 100644 web/components/ui/sliding-menu.tsx create mode 100644 web/hooks/use-keyboard-manager.ts create mode 100644 web/hooks/use-keydown-listener.ts create mode 100644 web/store/keydown-manager.ts diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 4f86a094..c5928be9 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -3,8 +3,9 @@ import { Sidebar } from "@/components/custom/sidebar/sidebar" import { CommandPalette } from "@/components/custom/command-palette/command-palette" import { useAccountOrGuest } from "@/lib/providers/jazz-provider" -import SlidingMenu from "@/components/ui/sliding-menu" import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" +import { Shortcut } from "@/components/custom/Shortcut/shortcut" +import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler" export default function PageLayout({ children }: { children: React.ReactNode }) { const { me } = useAccountOrGuest() @@ -13,11 +14,16 @@ export default function PageLayout({ children }: { children: React.ReactNode })
    + - {me._type !== "Anonymous" && } + {me._type !== "Anonymous" && ( + <> + + + + )}
    -
    {children}
    diff --git a/web/components/custom/Shortcut/shortcut.tsx b/web/components/custom/Shortcut/shortcut.tsx new file mode 100644 index 00000000..e4bd5fc6 --- /dev/null +++ b/web/components/custom/Shortcut/shortcut.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { atom, useAtom } from "jotai" +import { Sheet, SheetPortal, SheetOverlay, SheetTitle, sheetVariants, SheetDescription } from "@/components/ui/sheet" +import { LaIcon } from "../la-icon" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" + +export const showShortcutAtom = atom(false) + +type ShortcutItem = { + label: string + keys: string[] + then?: string[] +} + +type ShortcutSection = { + title: string + shortcuts: ShortcutItem[] +} + +const SHORTCUTS: ShortcutSection[] = [ + { + title: "General", + shortcuts: [ + { label: "Open command menu", keys: ["⌘", "k"] }, + { label: "Log out", keys: ["⌥", "⇧", "q"] } + ] + }, + { + title: "Navigation", + shortcuts: [ + { label: "Go to link", keys: ["G"], then: ["L"] }, + { label: "Go to page", keys: ["G"], then: ["P"] }, + { label: "Go to topic", keys: ["G"], then: ["T"] } + ] + } +] + +const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => ( + +) + +const ShortcutItem: React.FC = ({ label, keys, then }) => ( +
    +
    + {label} +
    +
    + + + {keys.map((key, index) => ( + + ))} + {then && ( + <> + then + {then.map((key, index) => ( + + ))} + + )} + + +
    +
    +) + +const ShortcutSection: React.FC = ({ title, shortcuts }) => ( +
    +

    {title}

    +
    + {shortcuts.map((shortcut, index) => ( + + ))} +
    +
    +) + +export function Shortcut() { + const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom) + const [searchQuery, setSearchQuery] = React.useState("") + + const { disableKeydown } = useKeyboardManager("shortcutSection") + + React.useEffect(() => { + disableKeydown(showShortcut) + }, [showShortcut, disableKeydown]) + + const filteredShortcuts = React.useMemo(() => { + if (!searchQuery) return SHORTCUTS + + return SHORTCUTS.map(section => ({ + ...section, + shortcuts: section.shortcuts.filter(shortcut => shortcut.label.toLowerCase().includes(searchQuery.toLowerCase())) + })).filter(section => section.shortcuts.length > 0) + }, [searchQuery]) + + return ( + + + + +
    + Keyboard Shortcuts + Quickly navigate around the app + +
    + + + + Close + +
    + +
    +
    + + setSearchQuery(e.target.value)} + /> + +
    + +
    +
    +
    + {filteredShortcuts.map((section, index) => ( + + ))} +
    +
    +
    +
    +
    +
    + ) +} diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx index d797e381..39aad7c9 100644 --- a/web/components/custom/command-palette/command-palette.tsx +++ b/web/components/custom/command-palette/command-palette.tsx @@ -9,6 +9,7 @@ import { searchSafeRegExp } from "@/lib/utils" import { GraphNode } from "@/components/routes/public/PublicHomeRoute" import { useCommandActions } from "./hooks/use-command-actions" import { atom, useAtom } from "jotai" +import { useKeydownListener } from "@/hooks/use-keydown-listener" const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default) @@ -29,17 +30,17 @@ export function CommandPalette() { const raw_graph_data = React.use(graph_data_promise) as GraphNode[] - React.useEffect(() => { - const down = (e: KeyboardEvent) => { + const handleKeydown = React.useCallback( + (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen(prev => !prev) } - } + }, + [setOpen] + ) - document.addEventListener("keydown", down) - return () => document.removeEventListener("keydown", down) - }, [setOpen]) + useKeydownListener(handleKeydown) const bounce = React.useCallback(() => { if (dialogRef.current) { @@ -118,11 +119,9 @@ export function CommandPalette() { if (activePage === "home") { if (!inputValue) { - // Only show items from the home object when there's no search input return commandGroups.home } - // When there's a search input, search across all categories const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics] return allGroups @@ -133,7 +132,6 @@ export function CommandPalette() { .filter(group => group.items.length > 0) } - // Handle other active pages (searchLinks, searchPages, etc.) switch (activePage) { case "searchLinks": return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }] diff --git a/web/components/custom/discordIcon.tsx b/web/components/custom/discordIcon.tsx index 9311a3e0..9d172b4b 100644 --- a/web/components/custom/discordIcon.tsx +++ b/web/components/custom/discordIcon.tsx @@ -3,21 +3,21 @@ export const DiscordIcon = () => ( ) diff --git a/web/components/custom/global-keydown-handler.tsx b/web/components/custom/global-keydown-handler.tsx new file mode 100644 index 00000000..527162cd --- /dev/null +++ b/web/components/custom/global-keydown-handler.tsx @@ -0,0 +1,63 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useKeydownListener } from "@/hooks/use-keydown-listener" +import { useAuth } from "@clerk/nextjs" +import { useRouter } from "next/navigation" + +type Sequence = { + [key: string]: string +} + +const SEQUENCES: Sequence = { + GL: "/links", + GP: "/pages", + GT: "/topics" +} + +const MAX_SEQUENCE_TIME = 1000 + +export function GlobalKeydownHandler() { + const [sequence, setSequence] = useState([]) + const { signOut } = useAuth() + const router = useRouter() + + const resetSequence = useCallback(() => { + setSequence([]) + }, []) + + const checkSequence = useCallback(() => { + const sequenceStr = sequence.join("") + const route = SEQUENCES[sequenceStr] + + if (route) { + console.log(`Navigating to ${route}...`) + router.push(route) + resetSequence() + } + }, [sequence, router, resetSequence]) + + useKeydownListener((e: KeyboardEvent) => { + // Check for logout shortcut + if (e.altKey && e.shiftKey && e.code === "KeyQ") { + signOut() + return + } + + // Key sequence handling + const key = e.key.toUpperCase() + setSequence(prev => [...prev, key]) + }) + + useEffect(() => { + checkSequence() + + const timeoutId = setTimeout(() => { + resetSequence() + }, MAX_SEQUENCE_TIME) + + return () => clearTimeout(timeoutId) + }, [sequence, checkSequence, resetSequence]) + + return null +} diff --git a/web/components/custom/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx index 2f768c5d..2396c7ac 100644 --- a/web/components/custom/sidebar/partial/profile-section.tsx +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -1,7 +1,14 @@ -import { LaIcon } from "@/components/custom/la-icon" -import { DiscordIcon } from "../../discordIcon" -import { useState } from "react" +"use client" + +import { useEffect, useState } from "react" import { SignInButton, useAuth, useUser } from "@clerk/nextjs" +import { useAtom } from "jotai" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { icons } from "lucide-react" + +import { LaIcon } from "@/components/custom/la-icon" +import { DiscordIcon } from "@/components/custom/discordIcon" import { DropdownMenu, DropdownMenuContent, @@ -10,17 +17,25 @@ import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Avatar, AvatarImage } from "@/components/ui/avatar" -import Link from "next/link" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { usePathname } from "next/navigation" +import { cn } from "@/lib/utils" import { Feedback } from "./feedback" +import { showShortcutAtom } from "@/components/custom/Shortcut/shortcut" +import { ShortcutKey } from "@/components/minimal-tiptap/components/shortcut-key" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" export const ProfileSection: React.FC = () => { const { user, isSignedIn } = useUser() const { signOut } = useAuth() const [menuOpen, setMenuOpen] = useState(false) const pathname = usePathname() + const [, setShowShortcut] = useAtom(showShortcutAtom) + + const { disableKeydown } = useKeyboardManager("profileSection") + + useEffect(() => { + disableKeydown(menuOpen) + }, [menuOpen, disableKeydown]) if (!isSignedIn) { return ( @@ -38,88 +53,104 @@ export const ProfileSection: React.FC = () => { return (
    -
    - - - - - - - -
    - - My profile -
    - -
    - - - -
    - - Onboarding -
    - -
    - - - - -
    - - Docs -
    - -
    - - - - -
    - - GitHub -
    - -
    - - - - -
    - - Discord -
    - -
    - - - signOut()}> -
    - - Log out -
    -
    -
    -
    -
    - +
    ) } + +interface ProfileDropdownProps { + user: any + menuOpen: boolean + setMenuOpen: (open: boolean) => void + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const ProfileDropdown: React.FC = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => ( +
    + + + + + + + + +
    +) + +interface DropdownMenuItemsProps { + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const DropdownMenuItems: React.FC = ({ signOut, setShowShortcut }) => ( + <> + + setShowShortcut(true)}> + + Shortcut + + + + + + + + +
    + + Log out +
    + +
    +
    +
    + +) + +interface MenuLinkProps { + href: string + icon: keyof typeof icons | React.FC + text: string + iconClass?: string +} + +const MenuLink: React.FC = ({ href, icon, text, iconClass = "" }) => { + const IconComponent = typeof icon === "string" ? icons[icon] : icon + return ( + + +
    + + {text} +
    + +
    + ) +} + +export default ProfileSection diff --git a/web/components/la-editor/components/ui/shortcut.tsx b/web/components/la-editor/components/ui/shortcut.tsx index 978a2b4a..cdb2f976 100644 --- a/web/components/la-editor/components/ui/shortcut.tsx +++ b/web/components/la-editor/components/ui/shortcut.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { cn } from "@/lib/utils" -import { getShortcutKey } from "../../lib/utils" +import { getShortcutKey } from "@/lib/utils" export interface ShortcutKeyWrapperProps extends React.HTMLAttributes { ariaLabel: string @@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef(({ class {...props} ref={ref} > - {getShortcutKey(shortcut)} + {getShortcutKey(shortcut).symbol} ) }) diff --git a/web/components/la-editor/extensions/slash-command/menu-list.tsx b/web/components/la-editor/extensions/slash-command/menu-list.tsx index 10377d18..ebf92265 100644 --- a/web/components/la-editor/extensions/slash-command/menu-list.tsx +++ b/web/components/la-editor/extensions/slash-command/menu-list.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Command, MenuListProps } from "./types" -import { getShortcutKeys } from "../../lib/utils" +import { getShortcutKeys } from "@/lib/utils" import { Icon } from "../../components/ui/icon" import { PopoverWrapper } from "../../components/ui/popover-wrapper" import { Shortcut } from "../../components/ui/shortcut" @@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => { {command.label}
    - + shortcut.readable) + .join(" + ")} + > {command.shortcuts.map(shortcut => ( ))} diff --git a/web/components/la-editor/lib/utils/index.ts b/web/components/la-editor/lib/utils/index.ts index 885f4fa4..60123d5b 100644 --- a/web/components/la-editor/lib/utils/index.ts +++ b/web/components/la-editor/lib/utils/index.ts @@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) { return "" } -export * from "./keyboard" -export * from "./platform" export * from "./isCustomNodeSelected" export * from "./isTextSelected" diff --git a/web/components/la-editor/lib/utils/keyboard.ts b/web/components/la-editor/lib/utils/keyboard.ts deleted file mode 100644 index 09739fc2..00000000 --- a/web/components/la-editor/lib/utils/keyboard.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isMacOS } from "./platform" - -export const getShortcutKey = (key: string) => { - const lowercaseKey = key.toLowerCase() - const macOS = isMacOS() - - switch (lowercaseKey) { - case "mod": - return macOS ? "⌘" : "Ctrl" - case "alt": - return macOS ? "⌥" : "Alt" - case "shift": - return macOS ? "⇧" : "Shift" - default: - return key - } -} - -export const getShortcutKeys = (keys: string | string[], separator: string = "") => { - const keyArray = Array.isArray(keys) ? keys : keys.split(/\s+/) - const shortcutKeys = keyArray.map(getShortcutKey) - return shortcutKeys.join(separator) -} - -export default { getShortcutKey, getShortcutKeys } diff --git a/web/components/la-editor/lib/utils/platform.ts b/web/components/la-editor/lib/utils/platform.ts deleted file mode 100644 index 2dffa98a..00000000 --- a/web/components/la-editor/lib/utils/platform.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface NavigatorWithUserAgentData extends Navigator { - userAgentData?: { - brands: { brand: string; version: string }[] - mobile: boolean - platform: string - getHighEntropyValues: (hints: string[]) => Promise<{ - platform: string - platformVersion: string - uaFullVersion: string - }> - } -} - -let isMac: boolean | undefined - -const getPlatform = () => { - const nav = navigator as NavigatorWithUserAgentData - if (nav.userAgentData) { - if (nav.userAgentData.platform) { - return nav.userAgentData.platform - } - - nav.userAgentData - .getHighEntropyValues(["platform"]) - .then(highEntropyValues => { - if (highEntropyValues.platform) { - return highEntropyValues.platform - } - }) - .catch(() => { - return navigator.platform || "" - }) - } - - return navigator.platform || "" -} - -export const isMacOS = () => { - if (isMac === undefined) { - isMac = getPlatform().toLowerCase().includes("mac") - } - - return isMac -} - -export default isMacOS diff --git a/web/components/minimal-tiptap/components/shortcut-key.tsx b/web/components/minimal-tiptap/components/shortcut-key.tsx index 2691528d..e81bdd88 100644 --- a/web/components/minimal-tiptap/components/shortcut-key.tsx +++ b/web/components/minimal-tiptap/components/shortcut-key.tsx @@ -1,33 +1,33 @@ -import * as React from 'react' -import { cn } from '@/lib/utils' -import { getShortcutKey } from '../utils' +import * as React from "react" +import { cn } from "@/lib/utils" +import { getShortcutKey } from "@/lib/utils" export interface ShortcutKeyProps extends React.HTMLAttributes { - keys: string[] + keys: string[] } export const ShortcutKey = React.forwardRef(({ className, keys, ...props }, ref) => { - const modifiedKeys = keys.map(key => getShortcutKey(key)) - const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ') + const modifiedKeys = keys.map(key => getShortcutKey(key)) + const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ") - return ( - - {modifiedKeys.map(shortcut => ( - + {modifiedKeys.map(shortcut => ( + - {shortcut.symbol} - - ))} - - ) + className + )} + {...props} + ref={ref} + > + {shortcut.symbol} + + ))} + + ) }) -ShortcutKey.displayName = 'ShortcutKey' +ShortcutKey.displayName = "ShortcutKey" diff --git a/web/components/minimal-tiptap/components/toolbar-section.tsx b/web/components/minimal-tiptap/components/toolbar-section.tsx index e296fd17..9aacc01b 100644 --- a/web/components/minimal-tiptap/components/toolbar-section.tsx +++ b/web/components/minimal-tiptap/components/toolbar-section.tsx @@ -1,112 +1,112 @@ -import * as React from 'react' -import type { Editor } from '@tiptap/react' -import { cn } from '@/lib/utils' -import { CaretDownIcon } from '@radix-ui/react-icons' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { ToolbarButton } from './toolbar-button' -import { ShortcutKey } from './shortcut-key' -import { getShortcutKey } from '../utils' -import type { FormatAction } from '../types' -import type { VariantProps } from 'class-variance-authority' -import type { toggleVariants } from '@/components/ui/toggle' +import * as React from "react" +import type { Editor } from "@tiptap/react" +import { cn } from "@/lib/utils" +import { CaretDownIcon } from "@radix-ui/react-icons" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { ToolbarButton } from "./toolbar-button" +import { ShortcutKey } from "./shortcut-key" +import { getShortcutKey } from "@/lib/utils" +import type { FormatAction } from "../types" +import type { VariantProps } from "class-variance-authority" +import type { toggleVariants } from "@/components/ui/toggle" interface ToolbarSectionProps extends VariantProps { - editor: Editor - actions: FormatAction[] - activeActions?: string[] - mainActionCount?: number - dropdownIcon?: React.ReactNode - dropdownTooltip?: string - dropdownClassName?: string + editor: Editor + actions: FormatAction[] + activeActions?: string[] + mainActionCount?: number + dropdownIcon?: React.ReactNode + dropdownTooltip?: string + dropdownClassName?: string } export const ToolbarSection: React.FC = ({ - editor, - actions, - activeActions = actions.map(action => action.value), - mainActionCount = 0, - dropdownIcon, - dropdownTooltip = 'More options', - dropdownClassName = 'w-12', - size, - variant + editor, + actions, + activeActions = actions.map(action => action.value), + mainActionCount = 0, + dropdownIcon, + dropdownTooltip = "More options", + dropdownClassName = "w-12", + size, + variant }) => { - const { mainActions, dropdownActions } = React.useMemo(() => { - const sortedActions = actions - .filter(action => activeActions.includes(action.value)) - .sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value)) + const { mainActions, dropdownActions } = React.useMemo(() => { + const sortedActions = actions + .filter(action => activeActions.includes(action.value)) + .sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value)) - return { - mainActions: sortedActions.slice(0, mainActionCount), - dropdownActions: sortedActions.slice(mainActionCount) - } - }, [actions, activeActions, mainActionCount]) + return { + mainActions: sortedActions.slice(0, mainActionCount), + dropdownActions: sortedActions.slice(mainActionCount) + } + }, [actions, activeActions, mainActionCount]) - const renderToolbarButton = React.useCallback( - (action: FormatAction) => ( - action.action(editor)} - disabled={!action.canExecute(editor)} - isActive={action.isActive(editor)} - tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`} - aria-label={action.label} - size={size} - variant={variant} - > - {action.icon} - - ), - [editor, size, variant] - ) + const renderToolbarButton = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + isActive={action.isActive(editor)} + tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`} + aria-label={action.label} + size={size} + variant={variant} + > + {action.icon} + + ), + [editor, size, variant] + ) - const renderDropdownMenuItem = React.useCallback( - (action: FormatAction) => ( - action.action(editor)} - disabled={!action.canExecute(editor)} - className={cn('flex flex-row items-center justify-between gap-4', { - 'bg-accent': action.isActive(editor) - })} - aria-label={action.label} - > - {action.label} - - - ), - [editor] - ) + const renderDropdownMenuItem = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + className={cn("flex flex-row items-center justify-between gap-4", { + "bg-accent": action.isActive(editor) + })} + aria-label={action.label} + > + {action.label} + + + ), + [editor] + ) - const isDropdownActive = React.useMemo( - () => dropdownActions.some(action => action.isActive(editor)), - [dropdownActions, editor] - ) + const isDropdownActive = React.useMemo( + () => dropdownActions.some(action => action.isActive(editor)), + [dropdownActions, editor] + ) - return ( - <> - {mainActions.map(renderToolbarButton)} - {dropdownActions.length > 0 && ( - - - - {dropdownIcon || } - - - - {dropdownActions.map(renderDropdownMenuItem)} - - - )} - - ) + return ( + <> + {mainActions.map(renderToolbarButton)} + {dropdownActions.length > 0 && ( + + + + {dropdownIcon || } + + + + {dropdownActions.map(renderDropdownMenuItem)} + + + )} + + ) } export default ToolbarSection diff --git a/web/components/minimal-tiptap/utils.ts b/web/components/minimal-tiptap/utils.ts index d8772d84..d749a3cc 100644 --- a/web/components/minimal-tiptap/utils.ts +++ b/web/components/minimal-tiptap/utils.ts @@ -1,81 +1,14 @@ -import type { Editor } from '@tiptap/core' -import type { MinimalTiptapProps } from './minimal-tiptap' +import type { Editor } from "@tiptap/core" +import type { MinimalTiptapProps } from "./minimal-tiptap" -let isMac: boolean | undefined +export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) { + if (format === "json") { + return editor.getJSON() + } -interface Navigator { - userAgentData?: { - brands: { brand: string; version: string }[] - mobile: boolean - platform: string - getHighEntropyValues: (hints: string[]) => Promise<{ - platform: string - platformVersion: string - uaFullVersion: string - }> - } -} - -function getPlatform(): string { - const nav = navigator as Navigator - - if (nav.userAgentData) { - if (nav.userAgentData.platform) { - return nav.userAgentData.platform - } - - nav.userAgentData.getHighEntropyValues(['platform']).then(highEntropyValues => { - if (highEntropyValues.platform) { - return highEntropyValues.platform - } - }) - } - - if (typeof navigator.platform === 'string') { - return navigator.platform - } - - return '' -} - -export function isMacOS() { - if (isMac === undefined) { - isMac = getPlatform().toLowerCase().includes('mac') - } - - return isMac -} - -interface ShortcutKeyResult { - symbol: string - readable: string -} - -export function getShortcutKey(key: string): ShortcutKeyResult { - const lowercaseKey = key.toLowerCase() - if (lowercaseKey === 'mod') { - return isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' } - } else if (lowercaseKey === 'alt') { - return isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' } - } else if (lowercaseKey === 'shift') { - return isMacOS() ? { symbol: '⇧', readable: 'Shift' } : { symbol: 'Shift', readable: 'Shift' } - } else { - return { symbol: key, readable: key } - } -} - -export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { - return keys.map(key => getShortcutKey(key)) -} - -export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) { - if (format === 'json') { - return editor.getJSON() - } - - if (format === 'html') { - return editor.getText() ? editor.getHTML() : '' - } - - return editor.getText() + if (format === "html") { + return editor.getText() ? editor.getHTML() : "" + } + + return editor.getText() } diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index a9e8eae3..df2165ab 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -1,9 +1,11 @@ +"use client" + import React, { useCallback, useEffect, useRef } from "react" import { motion, AnimatePresence } from "framer-motion" -import { icons, ZapIcon } from "lucide-react" +import type { icons } from "lucide-react" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { getSpecialShortcut, formatShortcut, isMacOS, cn } from "@/lib/utils" +import { cn, getShortcutKeys } from "@/lib/utils" import { LaIcon } from "@/components/custom/la-icon" import { useAtom } from "jotai" import { parseAsBoolean, useQueryState } from "nuqs" @@ -13,7 +15,7 @@ import { PersonalLink } from "@/lib/schema" import { ID } from "jazz-tools" import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form" import { useLinkActions } from "./hooks/use-link-actions" -import { showHotkeyPanelAtom } from "@/store/sidebar" +import { useKeydownListener } from "@/hooks/use-keydown-listener" interface ToolbarButtonProps extends React.ComponentPropsWithoutRef { icon: keyof typeof icons @@ -73,8 +75,6 @@ export const LinkBottomBar: React.FC = () => { }, 100) }, [setEditId, setCreateMode]) - const [, setShowHotkeyPanel] = useAtom(showHotkeyPanelAtom) - useEffect(() => { setGlobalLinkFormExceptionRefsAtom([ overlayRef, @@ -119,24 +119,21 @@ export const LinkBottomBar: React.FC = () => { } } - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isCreateShortcut = isMacOS() - ? event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n" - : event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey) + const handleKeydown = useCallback( + (event: KeyboardEvent) => { + const isCreateShortcut = event.key === "c" if (isCreateShortcut) { event.preventDefault() handleCreateMode() } - } + }, + [handleCreateMode] + ) - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleCreateMode]) + useKeydownListener(handleKeydown) - const shortcutKeys = getSpecialShortcut("expandToolbar") - const shortcutText = formatShortcut(shortcutKeys) + const shortcutText = getShortcutKeys(["c"]) return ( { s.symbol).join("")})`} ref={plusBtnRef} /> )} - {/* */} )} -
    - { - setShowHotkeyPanel(true) - }} - /> -
    ) } diff --git a/web/components/routes/topics/detail/TopicDetailRoute.tsx b/web/components/routes/topics/detail/TopicDetailRoute.tsx index e805fcc8..310ae004 100644 --- a/web/components/routes/topics/detail/TopicDetailRoute.tsx +++ b/web/components/routes/topics/detail/TopicDetailRoute.tsx @@ -22,6 +22,7 @@ export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) { const raw_graph_data = React.use(graph_data_promise) as GraphNode[] const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } }) const [activeIndex, setActiveIndex] = useState(-1) diff --git a/web/components/ui/sheet.tsx b/web/components/ui/sheet.tsx new file mode 100644 index 00000000..3b5c5dc7 --- /dev/null +++ b/web/components/ui/sheet.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +export const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm" + } + }, + defaultVariants: { + side: "right" + } + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>( + ({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + + ) +) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription +} diff --git a/web/components/ui/sliding-menu.tsx b/web/components/ui/sliding-menu.tsx deleted file mode 100644 index 1381d753..00000000 --- a/web/components/ui/sliding-menu.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { XIcon } from "lucide-react" -import { useState, useEffect, useRef } from "react" -import { motion, AnimatePresence } from "framer-motion" -import { showHotkeyPanelAtom } from "@/store/sidebar" -import { useAtom } from "jotai/react" - -export default function SlidingMenu() { - const [isOpen, setIsOpen] = useAtom(showHotkeyPanelAtom) - const panelRef = useRef(null) - const [shortcuts] = useState<{ name: string; shortcut: string[] }[]>([ - // TODO: change to better keybind - // TODO: windows users don't understand these symbols, figure out better way to show keybinds - { name: "New Todo", shortcut: ["⌘", "⌃", "n"] }, - { name: "CMD Palette", shortcut: ["⌘", "k"] } - // TODO: add - // { name: "Global Search", shortcut: ["."] }, - // { name: "(/pages)", shortcut: [".", "."] } - ]) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (panelRef.current && !panelRef.current.contains(event.target as Node)) { - setIsOpen(false) - } - } - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside) - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, [isOpen, setIsOpen]) - - return ( - - {isOpen && ( - <> - setIsOpen(false)} - /> - -
    -
    -
    Shortcuts
    - -
    -
    - {shortcuts.map((shortcut, index) => ( -
    -
    {shortcut.name}
    -
    - {shortcut.shortcut.join(" ")} -
    -
    - ))} -
    -
    -
    - - )} -
    - ) -} diff --git a/web/hooks/use-keyboard-manager.ts b/web/hooks/use-keyboard-manager.ts new file mode 100644 index 00000000..bd8c34de --- /dev/null +++ b/web/hooks/use-keyboard-manager.ts @@ -0,0 +1,38 @@ +import { useAtom } from "jotai" +import { useEffect, useCallback } from "react" +import { keyboardDisableSourcesAtom } from "@/store/keydown-manager" + +export function useKeyboardManager(sourceId: string) { + const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (disableSources.size > 0) { + event.preventDefault() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [disableSources]) + + const disableKeydown = useCallback( + (disable: boolean) => { + console.log(`${sourceId} disable:`, disable) + setDisableSources(prev => { + const next = new Set(prev) + if (disable) { + next.add(sourceId) + } else { + next.delete(sourceId) + } + return next + }) + }, + [setDisableSources, sourceId] + ) + + const isKeyboardDisabled = disableSources.size > 0 + + return { disableKeydown, isKeyboardDisabled } +} diff --git a/web/hooks/use-keydown-listener.ts b/web/hooks/use-keydown-listener.ts new file mode 100644 index 00000000..888f140e --- /dev/null +++ b/web/hooks/use-keydown-listener.ts @@ -0,0 +1,21 @@ +import { useAtomValue } from "jotai" +import { useEffect, useCallback } from "react" +import { keyboardDisableSourcesAtom } from "@/store/keydown-manager" + +export function useKeydownListener(callback: (event: KeyboardEvent) => void) { + const disableSources = useAtomValue(keyboardDisableSourcesAtom) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (disableSources.size === 0) { + callback(event) + } + }, + [disableSources, callback] + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) +} diff --git a/web/lib/utils/keyboard.ts b/web/lib/utils/keyboard.ts index faaaf7ce..50255db7 100644 --- a/web/lib/utils/keyboard.ts +++ b/web/lib/utils/keyboard.ts @@ -55,11 +55,7 @@ export function getShortcutKey(key: string): ShortcutKeyResult { } else if (lowercaseKey === "alt") { return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" } } else if (lowercaseKey === "shift") { - return { symbol: "⇧", readable: "Shift" } - } else if (lowercaseKey === "control") { - return { symbol: "⌃", readable: "Control" } - } else if (lowercaseKey === "windows" && !isMacOS()) { - return { symbol: "Win", readable: "Windows" } + return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" } } else { return { symbol: key.toUpperCase(), readable: key } } @@ -68,21 +64,3 @@ export function getShortcutKey(key: string): ShortcutKeyResult { export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { return keys.map(key => getShortcutKey(key)) } - -export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] { - if (shortcutName === "expandToolbar") { - return isMacOS() - ? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")] - : [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")] - } - - return [] -} - -export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string { - return shortcutKeys.map(key => key.symbol).join("") -} - -export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string { - return shortcutKeys.map(key => key.readable).join(" + ") -} diff --git a/web/store/keydown-manager.ts b/web/store/keydown-manager.ts new file mode 100644 index 00000000..d6617dc1 --- /dev/null +++ b/web/store/keydown-manager.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai" + +export const keyboardDisableSourcesAtom = atom>(new Set()) diff --git a/web/store/sidebar.ts b/web/store/sidebar.ts index 79854872..a3638924 100644 --- a/web/store/sidebar.ts +++ b/web/store/sidebar.ts @@ -5,4 +5,3 @@ export const toggleCollapseAtom = atom( get => get(isCollapseAtom), (get, set) => set(isCollapseAtom, !get(isCollapseAtom)) ) -export const showHotkeyPanelAtom = atom(false) From b648c8cd99688482352ba0aca73446cd253e8b0d Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:22:25 +0700 Subject: [PATCH 17/20] feat(metadata): Viewport (#169) * chore: remove sliding menu * feat(ui): sheet * feat: shortcut component * chore: register new shortcut component to layout * fix: react attr naming * fix: set default to false for shortcut * feat: viewport --- web/app/(pages)/layout.tsx | 20 +++++++++---------- web/app/layout.tsx | 9 ++++++++- .../command-palette/command-palette.tsx | 12 ++++++++++- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index c5928be9..3b897096 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -1,27 +1,25 @@ -"use client" - +import type { Viewport } from "next" import { Sidebar } from "@/components/custom/sidebar/sidebar" import { CommandPalette } from "@/components/custom/command-palette/command-palette" -import { useAccountOrGuest } from "@/lib/providers/jazz-provider" import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" import { Shortcut } from "@/components/custom/Shortcut/shortcut" import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler" -export default function PageLayout({ children }: { children: React.ReactNode }) { - const { me } = useAccountOrGuest() +export const viewport: Viewport = { + width: "device-width, shrink-to-fit=no", + maximumScale: 1, + userScalable: false +} +export default function PageLayout({ children }: { children: React.ReactNode }) { return (
    - {me._type !== "Anonymous" && ( - <> - - - - )} + +
    diff --git a/web/app/layout.tsx b/web/app/layout.tsx index c9fd5e6c..871c2b75 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next" +import type { Metadata, Viewport } from "next" import { cn } from "@/lib/utils" import { ThemeProvider } from "@/lib/providers/theme-provider" import "./globals.css" @@ -11,6 +11,13 @@ import { GeistMono, GeistSans } from "./fonts" import { JazzAndAuth } from "@/lib/providers/jazz-provider" import { TooltipProvider } from "@/components/ui/tooltip" +export const viewport: Viewport = { + width: "device-width", + height: "device-height", + initialScale: 1, + viewportFit: "cover" +} + export const metadata: Metadata = { title: "Learn Anything", description: "Organize world's knowledge, explore connections and curate learning paths" diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx index 39aad7c9..11feb4dd 100644 --- a/web/components/custom/command-palette/command-palette.tsx +++ b/web/components/custom/command-palette/command-palette.tsx @@ -1,10 +1,12 @@ +"use client" + import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { Command } from "cmdk" import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" import { CommandGroup } from "./command-items" import { CommandAction, CommandItemType, createCommandGroups } from "./command-data" -import { useAccount } from "@/lib/providers/jazz-provider" +import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" import { searchSafeRegExp } from "@/lib/utils" import { GraphNode } from "@/components/routes/public/PublicHomeRoute" import { useCommandActions } from "./hooks/use-command-actions" @@ -19,6 +21,14 @@ const filterItems = (items: CommandItemType[], searchRegex: RegExp) => export const commandPaletteOpenAtom = atom(false) export function CommandPalette() { + const { me } = useAccountOrGuest() + + if (me._type === "Anonymous") return null + + return +} + +export function RealCommandPalette() { const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } }) const dialogRef = React.useRef(null) const [inputValue, setInputValue] = React.useState("") From 333dcd26ef65ad0d19ccd9e8e63d85541a1deb51 Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:22:37 +0700 Subject: [PATCH 18/20] chore(deps): bump multiple dependencies in root and web (#171) --- package.json | 6 ++-- web/package.json | 86 ++++++++++++++++++++++++------------------------ 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index eb489b5d..3d94d0f8 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,14 @@ "web" ], "dependencies": { - "@clerk/themes": "^2.1.27", - "@tauri-apps/cli": "^2.0.0-rc.12", + "@clerk/themes": "^2.1.30", + "@tauri-apps/cli": "^2.0.0-rc.16", "@tauri-apps/plugin-fs": "^2.0.0-rc.2", "jazz-nodejs": "0.7.35-guest-auth.5", "react-icons": "^5.3.0" }, "devDependencies": { - "bun-types": "^1.1.27" + "bun-types": "^1.1.28" }, "prettier": { "plugins": [ diff --git a/web/package.json b/web/package.json index 1aca7cc3..67037938 100644 --- a/web/package.json +++ b/web/package.json @@ -9,11 +9,11 @@ "test": "jest" }, "dependencies": { - "@clerk/nextjs": "^5.4.1", + "@clerk/nextjs": "^5.6.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@hookform/resolvers": "^3.9.0", - "@nothing-but/force-graph": "^0.9.4", + "@nothing-but/force-graph": "^0.9.5", "@nothing-but/utils": "^0.16.0", "@omit/react-confirm-dialog": "^1.1.5", "@omit/react-fancy-switch": "^0.1.3", @@ -37,45 +37,45 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@sentry/nextjs": "^8.30.0", - "@tanstack/react-virtual": "^3.10.7", - "@tiptap/core": "^2.6.6", - "@tiptap/extension-blockquote": "^2.6.6", - "@tiptap/extension-bold": "^2.6.6", - "@tiptap/extension-bullet-list": "^2.6.6", - "@tiptap/extension-code": "^2.6.6", - "@tiptap/extension-code-block-lowlight": "^2.6.6", - "@tiptap/extension-color": "^2.6.6", - "@tiptap/extension-document": "^2.6.6", - "@tiptap/extension-dropcursor": "^2.6.6", - "@tiptap/extension-focus": "^2.6.6", - "@tiptap/extension-gapcursor": "^2.6.6", - "@tiptap/extension-hard-break": "^2.6.6", - "@tiptap/extension-heading": "^2.6.6", - "@tiptap/extension-history": "^2.6.6", - "@tiptap/extension-horizontal-rule": "^2.6.6", - "@tiptap/extension-image": "^2.6.6", - "@tiptap/extension-italic": "^2.6.6", - "@tiptap/extension-link": "^2.6.6", - "@tiptap/extension-list-item": "^2.6.6", - "@tiptap/extension-ordered-list": "^2.6.6", - "@tiptap/extension-paragraph": "^2.6.6", - "@tiptap/extension-placeholder": "^2.6.6", - "@tiptap/extension-strike": "^2.6.6", - "@tiptap/extension-task-item": "^2.6.6", - "@tiptap/extension-task-list": "^2.6.6", - "@tiptap/extension-text": "^2.6.6", - "@tiptap/extension-typography": "^2.6.6", - "@tiptap/pm": "^2.6.6", - "@tiptap/react": "^2.6.6", - "@tiptap/starter-kit": "^2.6.6", - "@tiptap/suggestion": "^2.6.6", + "@tanstack/react-virtual": "^3.10.8", + "@tiptap/core": "^2.7.2", + "@tiptap/extension-blockquote": "^2.7.2", + "@tiptap/extension-bold": "^2.7.2", + "@tiptap/extension-bullet-list": "^2.7.2", + "@tiptap/extension-code": "^2.7.2", + "@tiptap/extension-code-block-lowlight": "^2.7.2", + "@tiptap/extension-color": "^2.7.2", + "@tiptap/extension-document": "^2.7.2", + "@tiptap/extension-dropcursor": "^2.7.2", + "@tiptap/extension-focus": "^2.7.2", + "@tiptap/extension-gapcursor": "^2.7.2", + "@tiptap/extension-hard-break": "^2.7.2", + "@tiptap/extension-heading": "^2.7.2", + "@tiptap/extension-history": "^2.7.2", + "@tiptap/extension-horizontal-rule": "^2.7.2", + "@tiptap/extension-image": "^2.7.2", + "@tiptap/extension-italic": "^2.7.2", + "@tiptap/extension-link": "^2.7.2", + "@tiptap/extension-list-item": "^2.7.2", + "@tiptap/extension-ordered-list": "^2.7.2", + "@tiptap/extension-paragraph": "^2.7.2", + "@tiptap/extension-placeholder": "^2.7.2", + "@tiptap/extension-strike": "^2.7.2", + "@tiptap/extension-task-item": "^2.7.2", + "@tiptap/extension-task-list": "^2.7.2", + "@tiptap/extension-text": "^2.7.2", + "@tiptap/extension-typography": "^2.7.2", + "@tiptap/pm": "^2.7.2", + "@tiptap/react": "^2.7.2", + "@tiptap/starter-kit": "^2.7.2", + "@tiptap/suggestion": "^2.7.2", "axios": "^1.7.7", "cheerio": "1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", - "framer-motion": "^11.5.4", + "framer-motion": "^11.5.5", "geist": "^1.3.1", "jazz-browser-auth-clerk": "0.7.35-guest-auth.5", "jazz-react": "0.7.35-guest-auth.5", @@ -99,27 +99,27 @@ "streaming-markdown": "^0.0.14", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.2", + "vaul": "^0.9.4", "zod": "^3.23.8", "zsa": "^0.6.0", "zsa-react": "^0.2.2" }, "devDependencies": { - "@ronin/learn-anything": "^0.0.0-3451954511456", + "@ronin/learn-anything": "^0.0.0-3452357373461", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", - "@types/jest": "^29.5.12", - "@types/node": "^22.5.4", - "@types/react": "^18.3.5", + "@types/jest": "^29.5.13", + "@types/node": "^22.5.5", + "@types/react": "^18.3.7", "@types/react-dom": "^18.3.0", "dotenv": "^16.4.5", - "eslint": "^8.57.0", + "eslint": "^8.57.1", "eslint-config-next": "14.2.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "postcss": "^8.4.45", + "postcss": "^8.4.47", "prettier-plugin-tailwindcss": "^0.6.6", - "tailwindcss": "^3.4.10", + "tailwindcss": "^3.4.12", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.6.2" From 1a6c2ab42070376e5e0064fb09f02e040c80646b Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:28:48 +0700 Subject: [PATCH 19/20] feat(topic): Topic List Route (#172) * feat: add item scroll to active * fix: reset enterkey and scroll to view * fix: link item displayName * refactor: remove keyboard page nav * chore: fix scrolling, perf, keys, highlight active item etc * chore: use new hook for create a page * chore: disabled auto delete page * wip * chore: add learning selector * chore: learning selector update --- web/app/(pages)/topics/page.tsx | 5 + .../page/partials => custom}/column.tsx | 0 web/components/routes/page/list.tsx | 2 +- .../routes/page/partials/page-item.tsx | 2 +- web/components/routes/topics/TopicRoute.tsx | 35 ++++ web/components/routes/topics/header.tsx | 31 ++++ .../routes/topics/hooks/use-column-styles.ts | 14 ++ web/components/routes/topics/list.tsx | 157 +++++++++++++++++ .../routes/topics/partials/topic-item.tsx | 158 ++++++++++++++++++ 9 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 web/app/(pages)/topics/page.tsx rename web/components/{routes/page/partials => custom}/column.tsx (100%) create mode 100644 web/components/routes/topics/TopicRoute.tsx create mode 100644 web/components/routes/topics/header.tsx create mode 100644 web/components/routes/topics/hooks/use-column-styles.ts create mode 100644 web/components/routes/topics/list.tsx create mode 100644 web/components/routes/topics/partials/topic-item.tsx diff --git a/web/app/(pages)/topics/page.tsx b/web/app/(pages)/topics/page.tsx new file mode 100644 index 00000000..6251415e --- /dev/null +++ b/web/app/(pages)/topics/page.tsx @@ -0,0 +1,5 @@ +import { TopicRoute } from "@/components/routes/topics/TopicRoute" + +export default function Page() { + return +} diff --git a/web/components/routes/page/partials/column.tsx b/web/components/custom/column.tsx similarity index 100% rename from web/components/routes/page/partials/column.tsx rename to web/components/custom/column.tsx diff --git a/web/components/routes/page/list.tsx b/web/components/routes/page/list.tsx index c226559a..62be86d2 100644 --- a/web/components/routes/page/list.tsx +++ b/web/components/routes/page/list.tsx @@ -5,11 +5,11 @@ import { useAtom } from "jotai" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { PageItem } from "./partials/page-item" import { useMedia } from "react-use" -import { Column } from "./partials/column" import { useColumnStyles } from "./hooks/use-column-styles" import { PersonalPage, PersonalPageLists } from "@/lib/schema" import { useRouter } from "next/navigation" import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" +import { Column } from "@/components/custom/column" interface PageListProps { activeItemIndex: number | null diff --git a/web/components/routes/page/partials/page-item.tsx b/web/components/routes/page/partials/page-item.tsx index 65dacf66..002a01e1 100644 --- a/web/components/routes/page/partials/page-item.tsx +++ b/web/components/routes/page/partials/page-item.tsx @@ -3,10 +3,10 @@ import Link from "next/link" import { cn } from "@/lib/utils" import { PersonalPage } from "@/lib/schema" import { Badge } from "@/components/ui/badge" -import { Column } from "./column" import { useMedia } from "react-use" import { useColumnStyles } from "../hooks/use-column-styles" import { format } from "date-fns" +import { Column } from "@/components/custom/column" interface PageItemProps { page: PersonalPage diff --git a/web/components/routes/topics/TopicRoute.tsx b/web/components/routes/topics/TopicRoute.tsx new file mode 100644 index 00000000..b0ce6356 --- /dev/null +++ b/web/components/routes/topics/TopicRoute.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { TopicHeader } from "./header" +import { TopicList } from "./list" +import { useAtom } from "jotai" +import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" + +export function TopicRoute() { + const [activeItemIndex, setActiveItemIndex] = useState(null) + const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom) + const [disableEnterKey, setDisableEnterKey] = useState(false) + + const handleCommandPaletteClose = useCallback(() => { + setDisableEnterKey(true) + setTimeout(() => setDisableEnterKey(false), 100) + }, []) + + useEffect(() => { + if (!isCommandPaletteOpen) { + handleCommandPaletteClose() + } + }, [isCommandPaletteOpen, handleCommandPaletteClose]) + + return ( +
    + + +
    + ) +} diff --git a/web/components/routes/topics/header.tsx b/web/components/routes/topics/header.tsx new file mode 100644 index 00000000..9b949313 --- /dev/null +++ b/web/components/routes/topics/header.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" +import { useAccount } from "@/lib/providers/jazz-provider" + +interface TopicHeaderProps {} + +export const TopicHeader: React.FC = React.memo(() => { + const { me } = useAccount() + + if (!me) return null + + return ( + + +
    + + ) +}) + +TopicHeader.displayName = "TopicHeader" + +const HeaderTitle: React.FC = () => ( +
    + +
    + Topics +
    +
    +) diff --git a/web/components/routes/topics/hooks/use-column-styles.ts b/web/components/routes/topics/hooks/use-column-styles.ts new file mode 100644 index 00000000..7cecc98b --- /dev/null +++ b/web/components/routes/topics/hooks/use-column-styles.ts @@ -0,0 +1,14 @@ +import { useMedia } from "react-use" + +export const useColumnStyles = () => { + const isTablet = useMedia("(max-width: 640px)") + + return { + title: { + "--width": "69px", + "--min-width": "200px", + "--max-width": isTablet ? "none" : "auto" + }, + topic: { "--width": "65px", "--min-width": "120px", "--max-width": "120px" } + } +} diff --git a/web/components/routes/topics/list.tsx b/web/components/routes/topics/list.tsx new file mode 100644 index 00000000..dc96124b --- /dev/null +++ b/web/components/routes/topics/list.tsx @@ -0,0 +1,157 @@ +import React, { useCallback, useEffect, useMemo } from "react" +import { Primitive } from "@radix-ui/react-primitive" +import { useAccount } from "@/lib/providers/jazz-provider" +import { atom, useAtom } from "jotai" +import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" +import { TopicItem } from "./partials/topic-item" +import { useMedia } from "react-use" +import { useRouter } from "next/navigation" +import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" +import { Column } from "@/components/custom/column" +import { useColumnStyles } from "./hooks/use-column-styles" +import { LaAccount, ListOfTopics, Topic, UserRoot } from "@/lib/schema" +import { LearningStateValue } from "@/lib/constants" + +interface TopicListProps { + activeItemIndex: number | null + setActiveItemIndex: React.Dispatch> + disableEnterKey: boolean +} + +interface MainTopicListProps extends TopicListProps { + me: { + root: { + topicsWantToLearn: ListOfTopics + topicsLearning: ListOfTopics + topicsLearned: ListOfTopics + } & UserRoot + } & LaAccount +} + +export interface PersonalTopic { + topic: Topic | null + learningState: LearningStateValue +} + +export const topicOpenPopoverForIdAtom = atom(null) + +export const TopicList: React.FC = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => { + const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } }) + + if (!me) return null + + return ( + + ) +} + +export const MainTopicList: React.FC = ({ + me, + activeItemIndex, + setActiveItemIndex, + disableEnterKey +}) => { + const isTablet = useMedia("(max-width: 640px)") + const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom) + const router = useRouter() + + const personalTopics = useMemo( + () => [ + ...me.root.topicsWantToLearn.map(topic => ({ topic, learningState: "wantToLearn" as const })), + ...me.root.topicsLearning.map(topic => ({ topic, learningState: "learning" as const })), + ...me.root.topicsLearned.map(topic => ({ topic, learningState: "learned" as const })) + ], + [me.root.topicsWantToLearn, me.root.topicsLearning, me.root.topicsLearned] + ) + + const itemCount = personalTopics.length + + const handleEnter = useCallback( + (selectedTopic: Topic) => { + router.push(`/${selectedTopic.name}`) + }, + [router] + ) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isCommandPaletteOpen) return + + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault() + setActiveItemIndex(prevIndex => { + if (prevIndex === null) return 0 + const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount + return newIndex + }) + } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalTopics) { + e.preventDefault() + const selectedTopic = personalTopics[activeItemIndex] + if (selectedTopic?.topic) handleEnter?.(selectedTopic.topic) + } + }, + [itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalTopics, handleEnter] + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) + + return ( +
    + {!isTablet && } + +
    + ) +} + +export const ColumnHeader: React.FC = () => { + const columnStyles = useColumnStyles() + + return ( +
    + + Name + + + State + +
    + ) +} + +interface TopicListItemsProps { + personalTopics: PersonalTopic[] | null + activeItemIndex: number | null +} + +const TopicListItems: React.FC = ({ personalTopics, activeItemIndex }) => { + const setElementRef = useActiveItemScroll({ activeIndex: activeItemIndex }) + + return ( + + {personalTopics?.map( + (pt, index) => + pt.topic?.id && ( + setElementRef(el, index)} + topic={pt.topic} + learningState={pt.learningState} + isActive={index === activeItemIndex} + /> + ) + )} + + ) +} diff --git a/web/components/routes/topics/partials/topic-item.tsx b/web/components/routes/topics/partials/topic-item.tsx new file mode 100644 index 00000000..ffef13d8 --- /dev/null +++ b/web/components/routes/topics/partials/topic-item.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useMemo } from "react" +import Link from "next/link" +import { cn } from "@/lib/utils" +import { useColumnStyles } from "../hooks/use-column-styles" +import { ListOfTopics, Topic } from "@/lib/schema" +import { Column } from "@/components/custom/column" +import { Button } from "@/components/ui/button" +import { LaIcon } from "@/components/custom/la-icon" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector" +import { useAtom } from "jotai" +import { topicOpenPopoverForIdAtom } from "../list" +import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" +import { useAccount } from "@/lib/providers/jazz-provider" + +interface TopicItemProps { + topic: Topic + learningState: LearningStateValue + isActive: boolean +} + +export const TopicItem = React.forwardRef(({ topic, learningState, isActive }, ref) => { + const columnStyles = useColumnStyles() + const [openPopoverForId, setOpenPopoverForId] = useAtom(topicOpenPopoverForIdAtom) + const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } }) + + let p: { + index: number + topic?: Topic | null + learningState: LearningStateValue + } | null = null + + const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1 + if (wantToLearnIndex !== -1) { + p = { + index: wantToLearnIndex, + topic: me?.root.topicsWantToLearn[wantToLearnIndex], + learningState: "wantToLearn" + } + } + + const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1 + if (learningIndex !== -1) { + p = { + index: learningIndex, + topic: me?.root.topicsLearning[learningIndex], + learningState: "learning" + } + } + + const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1 + if (learnedIndex !== -1) { + p = { + index: learnedIndex, + topic: me?.root.topicsLearned[learnedIndex], + learningState: "learned" + } + } + + const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === learningState), [learningState]) + + const handleLearningStateSelect = useCallback( + (value: string) => { + const newLearningState = value as LearningStateValue + + const topicLists: Record = { + wantToLearn: me?.root.topicsWantToLearn, + learning: me?.root.topicsLearning, + learned: me?.root.topicsLearned + } + + const removeFromList = (state: LearningStateValue, index: number) => { + topicLists[state]?.splice(index, 1) + } + + if (p) { + if (newLearningState === p.learningState) { + removeFromList(p.learningState, p.index) + return + } + removeFromList(p.learningState, p.index) + } + + topicLists[newLearningState]?.push(topic) + + setOpenPopoverForId(null) + }, + [setOpenPopoverForId, me?.root.topicsWantToLearn, me?.root.topicsLearning, me?.root.topicsLearned, p, topic] + ) + + const handlePopoverTriggerClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + setOpenPopoverForId(openPopoverForId === topic.id ? null : topic.id) + } + + return ( +
    + + + {topic.prettyName} + + + + setOpenPopoverForId(open ? topic.id : null)} + > + + + + e.stopPropagation()} + onCloseAutoFocus={e => e.preventDefault()} + > + + + + + +
    + ) +}) + +TopicItem.displayName = "TopicItem" From bf5ae100ab4048247edf6d0fbe519c8308e15c7c Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 22:15:58 +0700 Subject: [PATCH 20/20] fix(key): Allow Esc and Any other input event (#173) * fix(key): Allow Esc and input handler * chore: set search autoFocus on shortcut component * fix: allow enter, arrow and disable list if keyboard --- web/components/custom/Shortcut/shortcut.tsx | 1 + web/components/routes/link/list.tsx | 83 ++++++++++----------- web/hooks/use-keyboard-manager.ts | 24 ++++-- 3 files changed, 58 insertions(+), 50 deletions(-) diff --git a/web/components/custom/Shortcut/shortcut.tsx b/web/components/custom/Shortcut/shortcut.tsx index e4bd5fc6..a8918077 100644 --- a/web/components/custom/Shortcut/shortcut.tsx +++ b/web/components/custom/Shortcut/shortcut.tsx @@ -131,6 +131,7 @@ export function Shortcut() {
    = ({ activeItemIndex, setActiveItemIndex }) }, []) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (isCommandPalettePpen || !me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return + const { isKeyboardDisabled } = useKeyboardManager("XComponent") - if (e.key === "ArrowUp" || e.key === "ArrowDown") { - e.preventDefault() - setActiveItemIndex(prevIndex => { - if (prevIndex === null) return 0 - const newIndex = - e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1) + useKeydownListener((e: KeyboardEvent) => { + if ( + isKeyboardDisabled || + isCommandPalettePpen || + !me?.root?.personalLinks || + sortedLinks.length === 0 || + editId !== null + ) + return - if (e.metaKey && sort === "manual") { - const linksArray = [...me.root.personalLinks] - const newLinks = arrayMove(linksArray, prevIndex, newIndex) + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault() + setActiveItemIndex(prevIndex => { + if (prevIndex === null) return 0 + const newIndex = + e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1) - while (me.root.personalLinks.length > 0) { - me.root.personalLinks.pop() - } + if (e.metaKey && sort === "manual") { + const linksArray = [...me.root.personalLinks] + const newLinks = arrayMove(linksArray, prevIndex, newIndex) - newLinks.forEach(link => { - if (link) { - me.root.personalLinks.push(link) - } - }) - - updateSequences(me.root.personalLinks) + while (me.root.personalLinks.length > 0) { + me.root.personalLinks.pop() } - return newIndex - }) - } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) { - e.preventDefault() - const activeLink = sortedLinks[activeItemIndex] - if (activeLink) { - setEditId(activeLink.id) + newLinks.forEach(link => { + if (link) { + me.root.personalLinks.push(link) + } + }) + + updateSequences(me.root.personalLinks) } + + return newIndex + }) + } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) { + e.preventDefault() + const activeLink = sortedLinks[activeItemIndex] + if (activeLink) { + setEditId(activeLink.id) } } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [ - me?.root?.personalLinks, - sortedLinks, - editId, - sort, - updateSequences, - isCommandPalettePpen, - activeItemIndex, - setEditId, - setActiveItemIndex, - disableEnterKey - ]) + }) const handleDragStart = useCallback( (event: DragStartEvent) => { diff --git a/web/hooks/use-keyboard-manager.ts b/web/hooks/use-keyboard-manager.ts index bd8c34de..f73d0994 100644 --- a/web/hooks/use-keyboard-manager.ts +++ b/web/hooks/use-keyboard-manager.ts @@ -2,19 +2,31 @@ import { useAtom } from "jotai" import { useEffect, useCallback } from "react" import { keyboardDisableSourcesAtom } from "@/store/keydown-manager" +const allowedKeys = ["Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"] + export function useKeyboardManager(sourceId: string) { const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (disableSources.size > 0) { - event.preventDefault() + if (disableSources.has(sourceId)) { + if (allowedKeys.includes(event.key)) { + if (event.key === "Escape") { + setDisableSources(prev => { + const next = new Set(prev) + next.delete(sourceId) + return next + }) + } + } else { + event.stopPropagation() + } } } - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [disableSources]) + window.addEventListener("keydown", handleKeyDown, true) + return () => window.removeEventListener("keydown", handleKeyDown, true) + }, [disableSources, sourceId, setDisableSources]) const disableKeydown = useCallback( (disable: boolean) => { @@ -32,7 +44,7 @@ export function useKeyboardManager(sourceId: string) { [setDisableSources, sourceId] ) - const isKeyboardDisabled = disableSources.size > 0 + const isKeyboardDisabled = disableSources.has(sourceId) return { disableKeydown, isKeyboardDisabled } }