mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
fix: detail topic (#117)
* feat: keyboard nav * fix: link update * feat: reusable learning state * chore: use new learning state * feat: add to my profile * . * . * feat: on enter open the link * fix: lint * fix: use eslint v8 instead of v9 * fix: add to my profile * chore: update personal link schema * chore: update personal page schema * fix: update detail wrapper * fix: update page section * removing option for learning status * removing option for learning status for topic * feat: add createdAt and updatedAt for personal Page * chore: update page section component * chore: remove chevron from sub menu * fix: sidebar * chore: add focus and disable toast * feat: la editor add execption for no command class * fix: la editor style and fix page detail * fix: title * fix: topic learning state * chore: add showSearch for learning state * fix: bunch stuff * chore: link list and item handle learning state * chore: set expand to false * feat: personal link for topic detail * chore: hook use topic data * chore: go to list * fix: link and topic * feat(utils): new keyboard utils * feat(store): add linkOpenPopoverForIdAtom for link * chore: using memo for use topic data * fix: remove duplicate component * chore: performance for topic detail lint item * refactor: remove LinkOptions component * chore: improve performance for list * feat: added LinkRoute copmonent * chore: link manage * feat: bottom bar * fix: link * fix: page wrapper * fix: import thing * chore: added a displayname * refactor: page detail * refactor: page detail * fix: add topic to personal link form link * fix: only show page count if more than zero * fix: sidebar topic section --------- Co-authored-by: Nikita <github@nikiv.dev> Co-authored-by: marshennikovaolga <marshennikova@gmail.com>
This commit is contained in:
@@ -1,32 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import { ListOfTopics, Topic } from "@/lib/schema"
|
||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { LearningStateValue } from "@/lib/constants"
|
||||
|
||||
interface TopicDetailHeaderProps {
|
||||
topic: Topic
|
||||
}
|
||||
|
||||
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
|
||||
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 handleAddToProfile = (learningState: LearningStateValue) => {
|
||||
const topicLists: Record<LearningStateValue, (ListOfTopics | null) | undefined> = {
|
||||
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 (learningState === p.learningState) {
|
||||
removeFromList(p.learningState, p.index)
|
||||
return
|
||||
}
|
||||
removeFromList(p.learningState, p.index)
|
||||
}
|
||||
|
||||
topicLists[learningState]?.push(topic)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left text-xl font-bold">{topic.prettyName}</span>
|
||||
</div>
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left text-xl font-bold">{topic.prettyName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
<div className="flex flex-auto"></div>
|
||||
|
||||
<Button variant="secondary" size="sm" className="gap-x-2 text-sm">
|
||||
<span>Add to my profile</span>
|
||||
</Button>
|
||||
</ContentHeader>
|
||||
</>
|
||||
<LearningStateSelector
|
||||
showSearch={false}
|
||||
value={p?.learningState || ""}
|
||||
onChange={handleAddToProfile}
|
||||
defaultLabel="Add to my profile"
|
||||
/>
|
||||
</ContentHeader>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,108 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import Link from "next/link"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
import { ID } from "jazz-tools"
|
||||
import { TopicDetailHeader } from "./Header"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { cn, ensureUrlProtocol } from "@/lib/utils"
|
||||
import { Section as SectionSchema, Link as LinkSchema } from "@/lib/schema"
|
||||
import { TopicSections } from "./partials/topic-sections"
|
||||
import { useLinkNavigation } from "./use-link-navigation"
|
||||
import { useTopicData } from "@/hooks/use-topic-data"
|
||||
import { atom } from "jotai"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
|
||||
interface TopicDetailRouteProps {
|
||||
topicName: string
|
||||
}
|
||||
|
||||
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
|
||||
const topics = useCoState(PublicGlobalGroup, process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>, {
|
||||
root: {
|
||||
topics: []
|
||||
}
|
||||
})
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const { topic, allLinks } = useTopicData(topicName)
|
||||
const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks)
|
||||
|
||||
const topic = topics?.root.topics.find(topic => topic?.name === topicName)
|
||||
|
||||
if (!topic) {
|
||||
if (!topic || !me) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<TopicDetailHeader topic={topic} />
|
||||
<div className="flex w-full flex-1 flex-col gap-4 focus-visible:outline-none">
|
||||
<div tabIndex={-1} className="outline-none">
|
||||
<div className="flex flex-1 flex-col gap-4" role="listbox" aria-label="Topic sections">
|
||||
{topic.latestGlobalGuide?.sections?.map(
|
||||
(section, index) => section?.id && <Section key={index} section={section} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TopicSections
|
||||
topic={topic}
|
||||
sections={topic.latestGlobalGuide?.sections}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={setActiveIndex}
|
||||
linkRefs={linkRefs}
|
||||
containerRef={containerRef}
|
||||
me={me}
|
||||
personalLinks={me.root.personalLinks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
section: SectionSchema
|
||||
}
|
||||
|
||||
function Section({ section }: SectionProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-4 px-4 py-2">
|
||||
<p className="text-foreground text-sm font-medium">{section.title}</p>
|
||||
<div className="border-b-secondary flex-1 border-b"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-px py-2">
|
||||
{section.links?.map((link, index) => link?.url && <LinkItem key={index} link={link} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkItemProps {
|
||||
link: LinkSchema
|
||||
}
|
||||
|
||||
function LinkItem({ link }: LinkItemProps) {
|
||||
return (
|
||||
<li
|
||||
tabIndex={0}
|
||||
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11")}
|
||||
>
|
||||
<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">
|
||||
<LaIcon name="GraduationCap" className="size-5" />
|
||||
<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="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
|
||||
{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 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="xl:truncate">{link.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-x-4"></div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
198
web/components/routes/topics/detail/partials/link-item.tsx
Normal file
198
web/components/routes/topics/detail/partials/link-item.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAtom } from "jotai"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
|
||||
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
|
||||
import { LaAccount, Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
|
||||
import { openPopoverForIdAtom } from "../TopicDetailRoute"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
|
||||
interface LinkItemProps {
|
||||
topic: Topic
|
||||
link: LinkSchema
|
||||
isActive: boolean
|
||||
index: number
|
||||
setActiveIndex: (index: number) => void
|
||||
me: {
|
||||
root: {
|
||||
personalLinks: PersonalLinkLists
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
export const LinkItem = React.memo(
|
||||
React.forwardRef<HTMLLIElement, LinkItemProps>(
|
||||
({ topic, link, isActive, index, setActiveIndex, me, personalLinks }, ref) => {
|
||||
const router = useRouter()
|
||||
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
|
||||
const personalLink = useMemo(() => {
|
||||
return personalLinks.find(pl => pl?.link?.id === link.id)
|
||||
}, [personalLinks, link.id])
|
||||
|
||||
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) => {
|
||||
const defaultToast = {
|
||||
duration: 5000,
|
||||
position: "bottom-right" as const,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: "Go to list",
|
||||
onClick: () => router.push("/")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const slug = generateUniqueSlug(personalLinks.toJSON(), 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.`
|
||||
})
|
||||
}
|
||||
|
||||
setOpenPopoverForId(null)
|
||||
setIsPopoverOpen(false)
|
||||
},
|
||||
[personalLink, personalLinks, me, link, router, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
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"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="xl:truncate">{link.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-x-4"></div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
LinkItem.displayName = "LinkItem"
|
||||
59
web/components/routes/topics/detail/partials/section.tsx
Normal file
59
web/components/routes/topics/detail/partials/section.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react"
|
||||
import { LinkItem } from "./link-item"
|
||||
import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema"
|
||||
|
||||
interface SectionProps {
|
||||
topic: Topic
|
||||
section: SectionSchema
|
||||
activeIndex: number
|
||||
startIndex: number
|
||||
linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
|
||||
setActiveIndex: (index: number) => void
|
||||
me: {
|
||||
root: {
|
||||
personalLinks: PersonalLinkLists
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
export function Section({
|
||||
topic,
|
||||
section,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
startIndex,
|
||||
linkRefs,
|
||||
me,
|
||||
personalLinks
|
||||
}: SectionProps) {
|
||||
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">
|
||||
{section.links?.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
|
||||
}}
|
||||
me={me}
|
||||
personalLinks={personalLinks}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
me: {
|
||||
root: {
|
||||
personalLinks: PersonalLinkLists
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
export function TopicSections({
|
||||
topic,
|
||||
sections,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
linkRefs,
|
||||
containerRef,
|
||||
me,
|
||||
personalLinks
|
||||
}: 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}
|
||||
me={me}
|
||||
personalLinks={personalLinks}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
web/components/routes/topics/detail/use-link-navigation.ts
Normal file
61
web/components/routes/topics/detail/use-link-navigation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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) => {
|
||||
console.log("handleKeyDown")
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user