From 1a6c2ab42070376e5e0064fb09f02e040c80646b Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:28:48 +0700 Subject: [PATCH] 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"