mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
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
This commit is contained in:
5
web/app/(pages)/topics/page.tsx
Normal file
5
web/app/(pages)/topics/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <TopicRoute />
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@ import { useAtom } from "jotai"
|
|||||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||||
import { PageItem } from "./partials/page-item"
|
import { PageItem } from "./partials/page-item"
|
||||||
import { useMedia } from "react-use"
|
import { useMedia } from "react-use"
|
||||||
import { Column } from "./partials/column"
|
|
||||||
import { useColumnStyles } from "./hooks/use-column-styles"
|
import { useColumnStyles } from "./hooks/use-column-styles"
|
||||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
|
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||||
|
import { Column } from "@/components/custom/column"
|
||||||
|
|
||||||
interface PageListProps {
|
interface PageListProps {
|
||||||
activeItemIndex: number | null
|
activeItemIndex: number | null
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import Link from "next/link"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PersonalPage } from "@/lib/schema"
|
import { PersonalPage } from "@/lib/schema"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Column } from "./column"
|
|
||||||
import { useMedia } from "react-use"
|
import { useMedia } from "react-use"
|
||||||
import { useColumnStyles } from "../hooks/use-column-styles"
|
import { useColumnStyles } from "../hooks/use-column-styles"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
import { Column } from "@/components/custom/column"
|
||||||
|
|
||||||
interface PageItemProps {
|
interface PageItemProps {
|
||||||
page: PersonalPage
|
page: PersonalPage
|
||||||
|
|||||||
35
web/components/routes/topics/TopicRoute.tsx
Normal file
35
web/components/routes/topics/TopicRoute.tsx
Normal file
@@ -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<number | null>(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 (
|
||||||
|
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||||
|
<TopicHeader />
|
||||||
|
<TopicList
|
||||||
|
activeItemIndex={activeItemIndex}
|
||||||
|
setActiveItemIndex={setActiveItemIndex}
|
||||||
|
disableEnterKey={disableEnterKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
web/components/routes/topics/header.tsx
Normal file
31
web/components/routes/topics/header.tsx
Normal file
@@ -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<TopicHeaderProps> = React.memo(() => {
|
||||||
|
const { me } = useAccount()
|
||||||
|
|
||||||
|
if (!me) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||||
|
<HeaderTitle />
|
||||||
|
<div className="flex flex-auto" />
|
||||||
|
</ContentHeader>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
TopicHeader.displayName = "TopicHeader"
|
||||||
|
|
||||||
|
const HeaderTitle: React.FC = () => (
|
||||||
|
<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 font-bold lg:text-xl">Topics</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
14
web/components/routes/topics/hooks/use-column-styles.ts
Normal file
14
web/components/routes/topics/hooks/use-column-styles.ts
Normal file
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
157
web/components/routes/topics/list.tsx
Normal file
157
web/components/routes/topics/list.tsx
Normal file
@@ -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<React.SetStateAction<number | null>>
|
||||||
|
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<string | null>(null)
|
||||||
|
|
||||||
|
export const TopicList: React.FC<TopicListProps> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => {
|
||||||
|
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
|
||||||
|
|
||||||
|
if (!me) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainTopicList
|
||||||
|
me={me}
|
||||||
|
activeItemIndex={activeItemIndex}
|
||||||
|
setActiveItemIndex={setActiveItemIndex}
|
||||||
|
disableEnterKey={disableEnterKey}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainTopicList: React.FC<MainTopicListProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||||
|
{!isTablet && <ColumnHeader />}
|
||||||
|
<TopicListItems personalTopics={personalTopics} activeItemIndex={activeItemIndex} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHeader: React.FC = () => {
|
||||||
|
const columnStyles = useColumnStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||||
|
<Column.Wrapper style={columnStyles.title}>
|
||||||
|
<Column.Text>Name</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
<Column.Wrapper style={columnStyles.topic}>
|
||||||
|
<Column.Text>State</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicListItemsProps {
|
||||||
|
personalTopics: PersonalTopic[] | null
|
||||||
|
activeItemIndex: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
|
||||||
|
const setElementRef = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Primitive.div
|
||||||
|
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||||
|
tabIndex={-1}
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
{personalTopics?.map(
|
||||||
|
(pt, index) =>
|
||||||
|
pt.topic?.id && (
|
||||||
|
<TopicItem
|
||||||
|
key={pt.topic.id}
|
||||||
|
ref={el => setElementRef(el, index)}
|
||||||
|
topic={pt.topic}
|
||||||
|
learningState={pt.learningState}
|
||||||
|
isActive={index === activeItemIndex}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Primitive.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
web/components/routes/topics/partials/topic-item.tsx
Normal file
158
web/components/routes/topics/partials/topic-item.tsx
Normal file
@@ -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<HTMLDivElement, TopicItemProps>(({ 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<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 (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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative block", "min-h-12 py-2 max-lg:px-5 sm:px-6", {
|
||||||
|
"bg-muted-foreground/5": isActive,
|
||||||
|
"hover:bg-muted/50": !isActive
|
||||||
|
})}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${topic.name}`}
|
||||||
|
className="flex h-full cursor-default items-center gap-4 outline-none"
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
|
<Column.Wrapper style={columnStyles.title}>
|
||||||
|
<Column.Text className="truncate text-[13px] font-medium">{topic.prettyName}</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
|
||||||
|
<Column.Wrapper style={columnStyles.topic} className="max-sm:justify-end">
|
||||||
|
<Popover
|
||||||
|
open={openPopoverForId === topic.id}
|
||||||
|
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? topic.id : null)}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
variant="secondary"
|
||||||
|
className="size-7 shrink-0 p-0"
|
||||||
|
onClick={handlePopoverTriggerClick}
|
||||||
|
>
|
||||||
|
{selectedLearningState?.icon ? (
|
||||||
|
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||||
|
) : (
|
||||||
|
<LaIcon name="Circle" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-52 rounded-lg p-0"
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onCloseAutoFocus={e => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<LearningStateSelectorContent
|
||||||
|
showSearch={false}
|
||||||
|
searchPlaceholder="Search state..."
|
||||||
|
value={learningState}
|
||||||
|
onSelect={handleLearningStateSelect}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</Column.Wrapper>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
TopicItem.displayName = "TopicItem"
|
||||||
Reference in New Issue
Block a user