Merge branch 'main' into tasks

This commit is contained in:
Nikita
2024-09-19 19:38:31 +03:00
56 changed files with 1889 additions and 1257 deletions

View File

@@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs"
import { atom, useAtom } from "jotai"
import { LinkBottomBar } from "./bottom-bar"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { useKey } from "react-use"
export const isDeleteConfirmShownAtom = atom(false)
export function LinkRoute(): React.ReactElement {
const [nuqsEditId] = useQueryState("editId")
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
@@ -50,8 +51,13 @@ export function LinkRoute(): React.ReactElement {
}
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
useKey("Escape", () => {
setDisableEnterKey(false)
setNuqsEditId(null)
})
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<>
<LinkHeader />
<LinkManage />
<LinkList
@@ -61,6 +67,6 @@ export function LinkRoute(): React.ReactElement {
disableEnterKey={disableEnterKey}
/>
<LinkBottomBar />
</div>
</>
)
}

View File

@@ -1,9 +1,11 @@
"use client"
import React, { useCallback, useEffect, useRef } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { icons } from "lucide-react"
import type { icons } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils"
import { cn, getShortcutKeys } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { useAtom } from "jotai"
import { parseAsBoolean, useQueryState } from "nuqs"
@@ -13,6 +15,7 @@ import { PersonalLink } from "@/lib/schema"
import { ID } from "jazz-tools"
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
import { useLinkActions } from "./hooks/use-link-actions"
import { useKeydownListener } from "@/hooks/use-keydown-listener"
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
icon: keyof typeof icons
@@ -21,9 +24,9 @@ interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Butto
}
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
({ icon, onClick, tooltip, ...props }, ref) => {
({ icon, onClick, tooltip, className, ...props }, ref) => {
const button = (
<Button variant="ghost" className="h-8 min-w-14" onClick={onClick} ref={ref} {...props}>
<Button variant="ghost" className={cn("h-8 min-w-14 p-0", className)} onClick={onClick} ref={ref} {...props}>
<LaIcon name={icon} />
</Button>
)
@@ -116,28 +119,25 @@ export const LinkBottomBar: React.FC = () => {
}
}
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isCreateShortcut = isMacOS()
? event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n"
: event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)
const handleKeydown = useCallback(
(event: KeyboardEvent) => {
const isCreateShortcut = event.key === "c"
if (isCreateShortcut) {
event.preventDefault()
handleCreateMode()
}
}
},
[handleCreateMode]
)
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleCreateMode])
useKeydownListener(handleKeydown)
const shortcutKeys = getSpecialShortcut("expandToolbar")
const shortcutText = formatShortcut(shortcutKeys)
const shortcutText = getShortcutKeys(["c"])
return (
<motion.div
className="bg-background absolute bottom-0 left-0 right-0 border-t"
className="bg-background absolute bottom-0 left-0 right-0 h-11 border-t"
animate={{ y: 0 }}
initial={{ y: "100%" }}
>
@@ -145,7 +145,7 @@ export const LinkBottomBar: React.FC = () => {
{editId && (
<motion.div
key="expanded"
className="flex items-center justify-center gap-1 px-2 py-1"
className="flex h-full items-center justify-center gap-1 px-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
@@ -165,7 +165,7 @@ export const LinkBottomBar: React.FC = () => {
{!editId && (
<motion.div
key="collapsed"
className="flex items-center justify-center gap-1 px-2 py-1"
className="flex h-full items-center justify-center gap-1 px-2"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
@@ -176,11 +176,10 @@ export const LinkBottomBar: React.FC = () => {
<ToolbarButton
icon={"Plus"}
onClick={handleCreateMode}
tooltip={`New Link (${shortcutText})`}
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
ref={plusBtnRef}
/>
)}
{/* <ToolbarButton icon={"Ellipsis"} ref={plusMoreBtnRef} /> */}
</motion.div>
)}
</AnimatePresence>

View File

@@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => {
return (
<>
<ContentHeader className="px-6 max-lg:px-4 lg:py-5">
<ContentHeader className="px-6 max-lg:px-4 lg:py-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">

View File

@@ -24,6 +24,9 @@ import { commandPaletteOpenAtom } from "@/components/custom/command-palette/comm
import { useConfirm } from "@omit/react-confirm-dialog"
import { useLinkActions } from "./hooks/use-link-actions"
import { isDeleteConfirmShownAtom } from "./LinkRoute"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
import { useKeydownListener } from "@/hooks/use-keydown-listener"
interface LinkListProps {
activeItemIndex: number | null
@@ -77,12 +80,6 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
})
)
useKey("Escape", () => {
if (editId) {
setEditId(null)
}
})
useKey(
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
async () => {
@@ -136,59 +133,52 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
})
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isCommandPalettePpen || !me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex =
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
useKeydownListener((e: KeyboardEvent) => {
if (
isKeyboardDisabled ||
isCommandPalettePpen ||
!me?.root?.personalLinks ||
sortedLinks.length === 0 ||
editId !== null
)
return
if (e.metaKey && sort === "manual") {
const linksArray = [...me.root.personalLinks]
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex =
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
while (me.root.personalLinks.length > 0) {
me.root.personalLinks.pop()
}
if (e.metaKey && sort === "manual") {
const linksArray = [...me.root.personalLinks]
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
newLinks.forEach(link => {
if (link) {
me.root.personalLinks.push(link)
}
})
updateSequences(me.root.personalLinks)
while (me.root.personalLinks.length > 0) {
me.root.personalLinks.pop()
}
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
e.preventDefault()
const activeLink = sortedLinks[activeItemIndex]
if (activeLink) {
setEditId(activeLink.id)
newLinks.forEach(link => {
if (link) {
me.root.personalLinks.push(link)
}
})
updateSequences(me.root.personalLinks)
}
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
e.preventDefault()
const activeLink = sortedLinks[activeItemIndex]
if (activeLink) {
setEditId(activeLink.id)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [
me?.root?.personalLinks,
sortedLinks,
editId,
sort,
updateSequences,
isCommandPalettePpen,
activeItemIndex,
setEditId,
setActiveItemIndex,
disableEnterKey
])
})
const handleDragStart = useCallback(
(event: DragStartEvent) => {
@@ -245,9 +235,11 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
setDraggingId(null)
}
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div
className="mb-14 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={0}
>
<DndContext
@@ -271,6 +263,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
isActive={activeItemIndex === index}
setActiveItemIndex={setActiveItemIndex}
index={index}
ref={el => setElementRef(el, index)}
/>
)
)}

View File

@@ -21,7 +21,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
<TextareaAutosize
{...field}
autoComplete="off"
placeholder="Description (optional)"
placeholder="Description"
className="placeholder:text-muted-foreground/70 resize-none overflow-y-auto border-none p-1.5 text-[13px] font-medium shadow-none focus-visible:ring-0"
/>
</FormControl>

View File

@@ -231,7 +231,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
<TopicSelector
{...field}
renderSelectedText={() => (
<span className="truncate">{selectedTopic?.prettyName || "Select a topic"}</span>
<span className="truncate">{selectedTopic?.prettyName || "Topic"}</span>
)}
/>
</FormItem>

View File

@@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => {
<Input
{...field}
autoComplete="off"
placeholder="Take a notes..."
placeholder="Notes"
className={cn("placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0")}
/>
</>

View File

@@ -15,7 +15,7 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { linkOpenPopoverForIdAtom } from "@/store/link"
interface LinkItemProps {
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
personalLink: PersonalLink
disabled?: boolean
isEditing: boolean
@@ -26,134 +26,138 @@ interface LinkItemProps {
index: number
}
export const LinkItem: React.FC<LinkItemProps> = ({
isEditing,
setEditId,
personalLink,
disabled = false,
isDragging,
isActive,
setActiveItemIndex,
index
}) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
}),
[transform, transition, isDragging]
)
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
}),
[transform, transition, isDragging]
)
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
[personalLink.learningState]
)
const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
[personalLink.learningState]
)
const handleLearningStateSelect = useCallback(
(value: string) => {
const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
setOpenPopoverForId(null)
},
[personalLink, setOpenPopoverForId]
)
const handleLearningStateSelect = useCallback(
(value: string) => {
const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
setOpenPopoverForId(null)
},
[personalLink, setOpenPopoverForId]
)
if (isEditing) {
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
}
if (isEditing) {
return (
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
)
}
return (
<li
ref={setNodeRef}
style={style as React.CSSProperties}
{...attributes}
{...listeners}
tabIndex={0}
onFocus={() => setActiveItemIndex(index)}
onBlur={() => setActiveItemIndex(null)}
className={cn(
"relative cursor-default outline-none",
"mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2",
{
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
}
)}
onDoubleClick={handleRowDoubleClick}
>
<Popover
open={openPopoverForId === personalLink.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
>
<PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
{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="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink.learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 shrink-0 rounded-full"
width={16}
height={16}
/>
return (
<li
ref={node => {
setNodeRef(node)
if (typeof ref === "function") {
ref(node)
} else if (ref) {
ref.current = node
}
}}
style={style as React.CSSProperties}
{...attributes}
{...listeners}
tabIndex={0}
onFocus={() => setActiveItemIndex(index)}
onBlur={() => setActiveItemIndex(null)}
className={cn(
"relative cursor-default outline-none",
"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
{
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
}
)}
onDoubleClick={handleRowDoubleClick}
>
<Popover
open={openPopoverForId === personalLink.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
>
<PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
{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="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink.learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
{personalLink.url && (
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
<Link
href={ensureUrlProtocol(personalLink.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="hover:text-primary truncate text-xs"
>
{personalLink.url}
</Link>
</div>
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 shrink-0 rounded-full"
width={16}
height={16}
/>
)}
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
{personalLink.url && (
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
<Link
href={ensureUrlProtocol(personalLink.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="hover:text-primary truncate text-xs"
>
{personalLink.url}
</Link>
</div>
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
{personalLink.topic && (
<Badge variant="secondary" className="border-muted-foreground/25">
{personalLink.topic.prettyName}
</Badge>
)}
</div>
</div>
</li>
)
}
)
<div className="flex shrink-0 items-center justify-end">
{personalLink.topic && (
<Badge variant="secondary" className="border-muted-foreground/25">
{personalLink.topic.prettyName}
</Badge>
)}
</div>
</li>
)
}
LinkItem.displayName = "LinkItem"

View File

@@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions"
const TITLE_PLACEHOLDER = "Untitled"
const emptyPage = (page: PersonalPage): boolean => {
const isPageEmpty = (page: PersonalPage): boolean => {
return (!page.title || page.title.trim() === "") && (!page.content || Object.keys(page.content).length === 0)
}
export const DeleteEmptyPage = (currentPageId: string | null) => {
const useDeleteEmptyPage = (currentPageId: string | null) => {
const router = useRouter()
const { me } = useAccount({
root: {
@@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => {
})
useEffect(() => {
const handleRouteChange = () => {
return () => {
if (!currentPageId || !me?.root?.personalPages) return
const currentPage = me.root.personalPages.find(page => page?.id === currentPageId)
if (currentPage && emptyPage(currentPage)) {
if (currentPage && isPageEmpty(currentPage)) {
const index = me.root.personalPages.findIndex(page => page?.id === currentPageId)
if (index !== -1) {
me.root.personalPages.splice(index, 1)
}
}
}
return () => {
handleRouteChange()
}
}, [currentPageId, me, router])
}
@@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
const { deletePage } = usePageActions()
const confirm = useConfirm()
DeleteEmptyPage(pageId)
// useDeleteEmptyPage(pageId)
const handleDelete = async () => {
const handleDelete = useCallback(async () => {
const result = await confirm({
title: "Delete page",
description: "Are you sure you want to delete this page?",
@@ -78,7 +74,7 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
deletePage(me, pageId as ID<PersonalPage>)
router.push("/pages")
}
}
}, [confirm, deletePage, me, pageId, router])
if (!page) return null
@@ -130,30 +126,34 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const contentEditorRef = useRef<LAEditorRef>(null)
const isTitleInitialMount = useRef(true)
const isContentInitialMount = useRef(true)
const isInitialFocusApplied = useRef(false)
const updatePageContent = (content: Content, model: PersonalPage) => {
const updatePageContent = useCallback((content: Content, model: PersonalPage) => {
if (isContentInitialMount.current) {
isContentInitialMount.current = false
return
}
model.content = content
model.updatedAt = new Date()
}
}, [])
const handleUpdateTitle = (editor: Editor) => {
if (isTitleInitialMount.current) {
isTitleInitialMount.current = false
return
}
const handleUpdateTitle = useCallback(
(editor: Editor) => {
if (isTitleInitialMount.current) {
isTitleInitialMount.current = false
return
}
const newTitle = editor.getText()
if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "")
page.title = newTitle
page.slug = slug
page.updatedAt = new Date()
}
}
const newTitle = editor.getText()
if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "")
page.title = newTitle
page.slug = slug
page.updatedAt = new Date()
}
},
[page]
)
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
const editor = titleEditorRef.current
@@ -201,7 +201,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const titleEditor = useEditor({
immediatelyRender: false,
autofocus: false,
extensions: [
FocusClasses,
Paragraph,
@@ -246,12 +245,15 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
isTitleInitialMount.current = true
isContentInitialMount.current = true
if (!page.title) {
titleEditor?.commands.focus()
} else {
contentEditorRef.current?.editor?.commands.focus()
if (!isInitialFocusApplied.current && titleEditor && contentEditorRef.current?.editor) {
isInitialFocusApplied.current = true
if (!page.title) {
titleEditor?.commands.focus()
} else {
contentEditorRef.current.editor.commands.focus()
}
}
}, [page.title, titleEditor, contentEditorRef])
}, [page.title, titleEditor])
return (
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">

View File

@@ -1,54 +1,58 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { LaIcon } from "@/components/custom/la-icon"
import { useAccount } from "@/lib/providers/jazz-provider"
import { useRouter } from "next/navigation"
import { PersonalPage } from "@/lib/schema"
import { toast } from "sonner"
import { usePageActions } from "./hooks/use-page-actions"
export const PageHeader = React.memo(() => {
interface PageHeaderProps {}
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
const { me } = useAccount()
const router = useRouter()
const { newPage } = usePageActions()
if (!me) return null
const handleClick = () => {
try {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
const handleNewPageClick = () => {
const page = newPage(me)
router.push(`/pages/${page.id}`)
}
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 font-bold lg:text-xl">Pages</span>
</div>
</div>
<div className="flex flex-auto"></div>
<div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2">
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={handleClick}>
<LaIcon name="Plus" />
<span className="hidden md:block">New page</span>
</Button>
</div>
</div>
<ContentHeader className="px-6 py-4 max-lg:px-4">
<HeaderTitle />
<div className="flex flex-auto" />
<NewPageButton onClick={handleNewPageClick} />
</ContentHeader>
)
})
PageHeader.displayName = "PageHeader"
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">Pages</span>
</div>
</div>
)
interface NewPageButtonProps {
onClick: () => void
}
const NewPageButton: React.FC<NewPageButtonProps> = ({ onClick }) => (
<div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2">
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={onClick}>
<LaIcon name="Plus" />
<span className="hidden md:block">New page</span>
</Button>
</div>
</div>
)

View File

@@ -1,69 +0,0 @@
import React, { useEffect, useRef, useCallback } from "react"
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
interface UseKeyboardNavigationProps {
personalPages?: PersonalPageLists | null
activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
isCommandPaletteOpen: boolean
disableEnterKey: boolean
onEnter?: (selectedPage: PersonalPage) => void
}
export const useKeyboardNavigation = ({
personalPages,
activeItemIndex,
setActiveItemIndex,
isCommandPaletteOpen,
disableEnterKey,
onEnter
}: UseKeyboardNavigationProps) => {
const listRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLAnchorElement | null)[]>([])
const itemCount = personalPages?.length || 0
const scrollIntoView = useCallback((index: number) => {
if (itemRefs.current[index]) {
itemRefs.current[index]?.scrollIntoView({
block: "nearest"
})
}
}, [])
useEffect(() => {
if (activeItemIndex !== null) {
scrollIntoView(activeItemIndex)
}
}, [activeItemIndex, scrollIntoView])
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 && personalPages) {
e.preventDefault()
const selectedPage = personalPages[activeItemIndex]
if (selectedPage) onEnter?.(selectedPage)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, onEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
const setItemRef = useCallback((el: HTMLAnchorElement | null, index: number) => {
itemRefs.current[index] = el
}, [])
return { listRef, setItemRef }
}

View File

@@ -4,6 +4,15 @@ import { LaAccount, PersonalPage } from "@/lib/schema"
import { ID } from "jazz-tools"
export const usePageActions = () => {
const newPage = useCallback((me: LaAccount): PersonalPage => {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
return newPersonalPage
}, [])
const deletePage = useCallback((me: LaAccount, pageId: ID<PersonalPage>): void => {
if (!me.root?.personalPages) return
@@ -32,5 +41,5 @@ export const usePageActions = () => {
}
}, [])
return { deletePage }
return { newPage, deletePage }
}

View File

@@ -1,15 +1,15 @@
import React, { useMemo, useCallback } from "react"
import React, { useMemo, useCallback, useEffect } from "react"
import { Primitive } from "@radix-ui/react-primitive"
import { useAccount } from "@/lib/providers/jazz-provider"
import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { PageItem } from "./partials/page-item"
import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation"
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
@@ -23,6 +23,7 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
const { me } = useAccount({ root: { personalPages: [] } })
const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
const router = useRouter()
const itemCount = personalPages?.length || 0
const handleEnter = useCallback(
(selectedPage: PersonalPage) => {
@@ -31,24 +32,35 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
[router]
)
const { listRef, setItemRef } = useKeyboardNavigation({
personalPages,
activeItemIndex,
setActiveItemIndex,
isCommandPaletteOpen,
disableEnterKey,
onEnter: handleEnter
})
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 && personalPages) {
e.preventDefault()
const selectedPage = personalPages[activeItemIndex]
if (selectedPage) handleEnter?.(selectedPage)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, handleEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
return (
<div className="flex h-full w-full flex-col">
<div className="flex h-full w-full flex-col overflow-hidden border-t">
{!isTablet && <ColumnHeader />}
<PageListItems
listRef={listRef}
setItemRef={setItemRef}
personalPages={personalPages}
activeItemIndex={activeItemIndex}
/>
<PageListItems personalPages={personalPages} activeItemIndex={activeItemIndex} />
</div>
)
}
@@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => {
}
interface PageListItemsProps {
listRef: React.RefObject<HTMLDivElement>
setItemRef: (el: HTMLAnchorElement | null, index: number) => void
personalPages?: PersonalPageLists | null
activeItemIndex: number | null
}
const PageListItems: React.FC<PageListItemsProps> = ({ listRef, setItemRef, personalPages, activeItemIndex }) => (
<Primitive.div
ref={listRef}
className="divide-primary/5 mx-auto my-2 flex w-[99%] flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={-1}
role="list"
>
{personalPages?.map(
(page, index) =>
page?.id && (
<PageItem
key={page.id}
ref={(el: HTMLAnchorElement | null) => setItemRef(el, index)}
page={page}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ 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"
>
{personalPages?.map(
(page, index) =>
page?.id && (
<PageItem
key={page.id}
ref={el => setElementRef(el, index)}
page={page}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
}

View File

@@ -1,42 +0,0 @@
import React from "react"
import { cn } from "@/lib/utils"
interface ColumnWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
style?: { [key: string]: string }
}
interface ColumnTextProps extends React.HTMLAttributes<HTMLSpanElement> {}
const ColumnWrapper = React.forwardRef<HTMLDivElement, ColumnWrapperProps>(
({ children, className, style, ...props }, ref) => (
<div
className={cn("flex grow flex-row items-center justify-start", className)}
style={{
width: "var(--width)",
minWidth: "var(--min-width, min-content)",
maxWidth: "min(var(--width), var(--max-width))",
flexBasis: "var(--width)",
...style
}}
ref={ref}
{...props}
>
{children}
</div>
)
)
ColumnWrapper.displayName = "ColumnWrapper"
const ColumnText = React.forwardRef<HTMLSpanElement, ColumnTextProps>(({ children, className, ...props }, ref) => (
<span className={cn("text-left text-xs", className)} ref={ref} {...props}>
{children}
</span>
))
ColumnText.displayName = "ColumnText"
export const Column = {
Wrapper: ColumnWrapper,
Text: ColumnText
}

View File

@@ -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
@@ -21,14 +21,10 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
<Link
ref={ref}
tabIndex={isActive ? 0 : -1}
className={cn(
"relative block cursor-default rounded-lg outline-none",
"h-12 items-center gap-x-2 py-2 max-lg:px-4 sm:px-6",
{
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
}
)}
className={cn("relative block cursor-default outline-none", "min-h-12 py-2 max-lg:px-4 sm:px-6", {
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
})}
href={`/pages/${page.id}`}
role="listitem"
>
@@ -38,14 +34,9 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
</Column.Wrapper>
{!isTablet && (
<>
{/* <Column.Wrapper style={columnStyles.content}>
<Column.Text className="text-[13px]">{page.slug}</Column.Text>
</Column.Wrapper> */}
<Column.Wrapper style={columnStyles.topic}>
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
</Column.Wrapper>
</>
<Column.Wrapper style={columnStyles.topic}>
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
</Column.Wrapper>
)}
<Column.Wrapper style={columnStyles.updated} className="flex justify-end">

View 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>
)
}

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,71 @@ 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

@@ -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>
)

View 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" }
}
}

View 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>
)
}

View 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"