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
This commit is contained in:
Aslam
2024-09-19 21:11:38 +07:00
committed by GitHub
parent 26d938e66c
commit 8871a8959c
7 changed files with 324 additions and 395 deletions

View File

@@ -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<string | null>(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<HTMLDivElement>(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 <NotFoundPlaceholder />
}
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 <TopicDetailSkeleton />
}
return (
<div className="flex h-full flex-auto flex-col">
<>
<TopicDetailHeader topic={topic} />
<TopicSections
topic={topic}
sections={topic.latestGlobalGuide?.sections}
activeIndex={0}
setActiveIndex={() => {}}
linkRefs={linksRefDummy}
containerRef={containerRefDummy}
/>
<TopicDetailList items={flattenedItems} topic={topic} activeIndex={activeIndex} setActiveIndex={setActiveIndex} />
</>
)
}
function NotFoundPlaceholder() {
return (
<div className="flex h-full grow flex-col items-center justify-center gap-3">
<div className="flex flex-row items-center gap-1.5">
<LaIcon name="CircleAlert" />
<span className="text-left font-medium">Topic not found</span>
</div>
<span className="max-w-sm text-left text-sm">There is no topic with the given identifier.</span>
</div>
)
}
function TopicDetailSkeleton() {
return (
<>
<div className="flex items-center justify-between px-6 py-5 max-lg:px-4">
<div className="flex items-center space-x-4">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-6 w-48" />
</div>
<Skeleton className="h-9 w-36" />
</div>
<div className="space-y-4 p-6 max-lg:px-4">
{[...Array(10)].map((_, index) => (
<div key={index} className="flex items-center space-x-4">
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</>
)
}

View File

@@ -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<HTMLDivElement>(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 (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="flex flex-col"
>
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
<p className="text-foreground text-sm font-medium">{item.data?.title}</p>
<div className="flex-1 border-b" />
</div>
</div>
)
}
if (item.data?.id) {
return (
<LinkItem
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
topic={topic}
link={item.data as LinkSchema}
isActive={activeIndex === virtualRow.index}
index={virtualRow.index}
setActiveIndex={setActiveIndex}
personalLinks={personalLinks}
/>
)
}
return null
},
[items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks]
)
return (
<div ref={parentRef} className="flex-1 overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative"
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`
}}
>
{virtualizer.getVirtualItems().map(renderItem)}
</div>
</div>
</div>
)
}

View File

@@ -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<HTMLLIElement, LinkItemProps>(({ 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<HTMLDivElement, LinkItemProps>(
({ 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 (
<div
ref={ref}
tabIndex={0}
onClick={handleClick}
className={cn(
"relative flex h-14 cursor-pointer items-center outline-none xl:h-11",
{
url: link.url,
title: link.title,
slug,
link,
learningState,
sequence: personalLinks.length + 1,
completed: false,
topic,
createdAt: new Date(),
updatedAt: new Date()
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
},
{ owner: me }
)
personalLinks.push(newPersonalLink)
toast.success("Link added.", {
...defaultToast,
description: `${link.title} has been added to your personal link.`
})
}
setOpenPopoverForId(null)
setIsPopoverOpen(false)
},
[personalLink, personalLinks, me, link, router, setOpenPopoverForId, topic, clerk, pathname]
)
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
setIsPopoverOpen(open)
setOpenPopoverForId(open ? link.id : null)
},
[link.id, setOpenPopoverForId]
)
return (
<li
ref={ref}
tabIndex={0}
onClick={handleClick}
className={cn("relative flex h-14 cursor-pointer items-center outline-none xl:h-11", {
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
})}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
onClick={e => e.stopPropagation()}
>
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={selectedLearningState.className} />
) : (
<LaIcon name="Circle" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink?.learningState}
onSelect={(value: string) => handleSelectLearningState(value as LearningStateValue)}
/>
</PopoverContent>
</Popover>
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p
className={cn(
"text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate",
isActive && "font-bold"
)}
>
{link.title}
</p>
<div className="group flex items-center gap-x-1">
<LaIcon
name="Link"
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary flex-none"
/>
<Link
href={ensureUrlProtocol(link.url)}
passHref
prefetch={false}
target="_blank"
className
)}
{...props}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
onClick={e => e.stopPropagation()}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="xl:truncate">{link.url}</span>
</Link>
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={selectedLearningState.className} />
) : (
<LaIcon name="Circle" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink?.learningState}
onSelect={(value: string) => handleSelectLearningState(value as LearningStateValue)}
/>
</PopoverContent>
</Popover>
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p
className={cn(
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
isActive && "font-bold"
)}
>
{link.title}
</p>
<div className="group flex items-center gap-x-1">
<LaIcon
name="Link"
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
/>
<Link
href={ensureUrlProtocol(link.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="line-clamp-1">{link.url}</span>
</Link>
</div>
</div>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-x-4"></div>
</div>
</li>
)
})
)
}
)
)
LinkItem.displayName = "LinkItem"

View File

@@ -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 (
<div className="flex flex-col">
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
<p className="text-foreground text-sm font-medium">{section.title}</p>
<div className="flex-1 border-b"></div>
</div>
<div className="flex flex-col gap-px py-2">
{linksToLoad?.map((link, index) =>
link?.url ? (
<LinkItem
key={index}
topic={topic}
link={link}
isActive={activeIndex === startIndex + index}
index={startIndex + index}
setActiveIndex={setActiveIndex}
ref={el => {
linkRefs.current[startIndex + index] = el
}}
/>
) : (
<Skeleton key={index} className="h-14 w-full xl:h-11" />
)
)}
{section.links?.length && section.links?.length > nLinksToLoad && (
<LoadMoreSpinner onLoadMore={() => setNLinksToLoad(n => n + 10)} />
)}
</div>
</div>
)
}
const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => {
const spinnerRef = useRef<HTMLDivElement>(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 (
<div ref={spinnerRef} className="flex justify-center py-4">
<LaIcon name="Loader" className="size-6 animate-spin" />
</div>
)
}

View File

@@ -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<HTMLDivElement>
}
export function TopicSections({
topic,
sections,
activeIndex,
setActiveIndex,
linkRefs,
containerRef,
}: TopicSectionsProps) {
return (
<div ref={containerRef} className="flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
<div tabIndex={-1} className="outline-none">
<div className="flex flex-1 flex-col gap-4" role="listbox" aria-label="Topic sections">
{sections?.map(
(section, sectionIndex) =>
section?.id && (
<Section
key={sectionIndex}
topic={topic}
section={section}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
startIndex={sections.slice(0, sectionIndex).reduce((acc, s) => acc + (s?.links?.length || 0), 0)}
linkRefs={linkRefs}
/>
)
)}
</div>
</div>
</div>
)
}

View File

@@ -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<HTMLDivElement>(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 }
}

View File

@@ -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<PublicGlobalGroup>
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 }
}