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 = () => (
+
+)
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"