From 8871a8959c0b950cc9608c7b0427caed0756d46b Mon Sep 17 00:00:00 2001 From: Aslam Date: Thu, 19 Sep 2024 21:11:38 +0700 Subject: [PATCH] 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 } -}