fix: conflict

This commit is contained in:
Aslam H
2024-09-23 23:28:04 +07:00
46 changed files with 972 additions and 755 deletions

View File

@@ -1,71 +1,20 @@
"use client"
import React, { useEffect, useState, useCallback, useRef } from "react"
import * as React from "react"
import { LinkHeader } from "@/components/routes/link/header"
import { LinkList } from "@/components/routes/link/list"
import { LinkManage } from "@/components/routes/link/manage"
import { parseAsBoolean, useQueryState } from "nuqs"
import { atom, useAtom } from "jotai"
import { atom } 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, setNuqsEditId] = useQueryState("editId")
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const [isDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
const [disableEnterKey, setDisableEnterKey] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleCommandPaletteClose = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setDisableEnterKey(true)
timeoutRef.current = setTimeout(() => {
setDisableEnterKey(false)
timeoutRef.current = null
}, 100)
}, [])
useEffect(() => {
if (isDeleteConfirmShown || isCommandPaletteOpen || isInCreateMode) {
setDisableEnterKey(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
} else if (!isCommandPaletteOpen) {
handleCommandPaletteClose()
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
useKey("Escape", () => {
setDisableEnterKey(false)
setNuqsEditId(null)
})
return (
<>
<LinkHeader />
<LinkManage />
<LinkList
key={nuqsEditId}
activeItemIndex={activeItemIndex}
setActiveItemIndex={setActiveItemIndex}
disableEnterKey={disableEnterKey}
/>
<LinkList />
<LinkBottomBar />
</>
)

View File

@@ -1,6 +1,6 @@
"use client"
import React, { useCallback, useEffect, useRef } from "react"
import * as React from "react"
import { motion, AnimatePresence } from "framer-motion"
import type { icons } from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -15,7 +15,6 @@ 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
@@ -55,28 +54,28 @@ export const LinkBottomBar: React.FC = () => {
const { me } = useAccount({ root: { personalLinks: [] } })
const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>)
const cancelBtnRef = useRef<HTMLButtonElement>(null)
const confirmBtnRef = useRef<HTMLButtonElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const cancelBtnRef = React.useRef<HTMLButtonElement>(null)
const confirmBtnRef = React.useRef<HTMLButtonElement>(null)
const overlayRef = React.useRef<HTMLDivElement>(null)
const contentRef = React.useRef<HTMLDivElement>(null)
const deleteBtnRef = useRef<HTMLButtonElement>(null)
const editMoreBtnRef = useRef<HTMLButtonElement>(null)
const plusBtnRef = useRef<HTMLButtonElement>(null)
const plusMoreBtnRef = useRef<HTMLButtonElement>(null)
const deleteBtnRef = React.useRef<HTMLButtonElement>(null)
const editMoreBtnRef = React.useRef<HTMLButtonElement>(null)
const plusBtnRef = React.useRef<HTMLButtonElement>(null)
const plusMoreBtnRef = React.useRef<HTMLButtonElement>(null)
const { deleteLink } = useLinkActions()
const confirm = useConfirm()
const handleCreateMode = useCallback(() => {
const handleCreateMode = React.useCallback(() => {
setEditId(null)
setTimeout(() => {
requestAnimationFrame(() => {
setCreateMode(prev => !prev)
}, 100)
})
}, [setEditId, setCreateMode])
useEffect(() => {
setGlobalLinkFormExceptionRefsAtom([
const exceptionRefs = React.useMemo(
() => [
overlayRef,
contentRef,
deleteBtnRef,
@@ -85,8 +84,13 @@ export const LinkBottomBar: React.FC = () => {
confirmBtnRef,
plusBtnRef,
plusMoreBtnRef
])
}, [setGlobalLinkFormExceptionRefsAtom])
],
[]
)
React.useEffect(() => {
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
const handleDelete = async (e: React.MouseEvent) => {
if (!personalLink || !me) return
@@ -119,46 +123,29 @@ export const LinkBottomBar: React.FC = () => {
}
}
const handleKeydown = useCallback(
(event: KeyboardEvent) => {
const isCreateShortcut = event.key === "c"
if (isCreateShortcut) {
event.preventDefault()
handleCreateMode()
}
},
[handleCreateMode]
)
useKeydownListener(handleKeydown)
const shortcutText = getShortcutKeys(["c"])
return (
<motion.div
className="bg-background absolute bottom-0 left-0 right-0 h-11 border-t"
animate={{ y: 0 }}
initial={{ y: "100%" }}
>
<div className="bg-background min-h-11 border-t">
<AnimatePresence mode="wait">
{editId && (
<motion.div
key="expanded"
className="flex h-full items-center justify-center gap-1 px-2"
className="flex h-full items-center justify-center gap-1 border-t px-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.1 }}
>
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} />
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} aria-label="Go back" />
<ToolbarButton
icon={"Trash"}
onClick={handleDelete}
className="text-destructive hover:text-destructive"
ref={deleteBtnRef}
aria-label="Delete link"
/>
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} />
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} aria-label="More options" />
</motion.div>
)}
@@ -171,19 +158,20 @@ export const LinkBottomBar: React.FC = () => {
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.1 }}
>
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} />}
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} aria-label="Go back" />}
{!createMode && (
<ToolbarButton
icon={"Plus"}
onClick={handleCreateMode}
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
ref={plusBtnRef}
aria-label="New link"
/>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
)
}

View File

@@ -1,10 +1,9 @@
"use client"
import * as React from "react"
import { ListFilterIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { useMedia } from "react-use"
import { useMedia } from "@/hooks/use-media"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -15,6 +14,7 @@ import { LEARNING_STATES } from "@/lib/constants"
import { useQueryState, parseAsStringLiteral } from "nuqs"
import { FancySwitch } from "@omit/react-fancy-switch"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
@@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => {
</ContentHeader>
{isTablet && (
<div className="flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
<div className="flex min-h-10 flex-row items-start justify-between border-b px-6 pb-4 pt-2 max-lg:pl-4">
<LearningTab />
</div>
)}
@@ -115,8 +115,8 @@ const FilterAndSort = React.memo(() => {
<div className="flex items-center gap-2">
<Popover open={sortOpen} onOpenChange={setSortOpen}>
<PopoverTrigger asChild>
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm">
<ListFilterIcon size={16} className="text-primary/60" />
<Button size="sm" type="button" variant="secondary" className="min-w-8 gap-x-2 text-sm max-sm:p-0">
<LaIcon name="ListFilter" className="text-primary/60" />
<span className="hidden md:block">Filter: {getFilterText()}</span>
</Button>
</PopoverTrigger>

View File

@@ -9,18 +9,20 @@ export const useLinkActions = () => {
try {
const index = me.root.personalLinks.findIndex(item => item?.id === link.id)
if (index === -1) {
console.error("Delete operation fail", { index, link })
return
throw new Error(`Link with id ${link.id} not found`)
}
me.root.personalLinks.splice(index, 1)
toast.success("Link deleted.", {
position: "bottom-right",
description: `${link.title} has been deleted.`
})
me.root.personalLinks.splice(index, 1)
} catch (error) {
toast.error("Failed to delete link")
console.error("Failed to delete link:", error)
toast.error("Failed to delete link", {
description: error instanceof Error ? error.message : "An unknown error occurred"
})
}
}, [])

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from "react"
import * as React from "react"
import {
DndContext,
closestCenter,
@@ -8,50 +8,55 @@ import {
useSensors,
DragEndEvent,
DragStartEvent,
UniqueIdentifier
UniqueIdentifier,
MeasuringStrategy,
TouchSensor
} from "@dnd-kit/core"
import { Primitive } from "@radix-ui/react-primitive"
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
import type { MeasuringConfiguration } from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import { useAccount } from "@/lib/providers/jazz-provider"
import { PersonalLinkLists } from "@/lib/schema/personal-link"
import { useAtom } from "jotai"
import { linkSortAtom } from "@/store/link"
import { useKey } from "react-use"
import { LinkItem } from "./partials/link-item"
import { useQueryState } from "nuqs"
import { parseAsBoolean, useQueryState } from "nuqs"
import { learningStateAtom } from "./header"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
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"
import { useTouchSensor } from "@/hooks/use-touch-sensor"
import { useKeyDown } from "@/hooks/use-key-down"
import { isModKey } from "@/lib/utils"
interface LinkListProps {
activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
disableEnterKey: boolean
interface LinkListProps {}
const measuring: MeasuringConfiguration = {
droppable: {
strategy: MeasuringStrategy.Always
}
}
const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => {
const [isCommandPalettePpen] = useAtom(commandPaletteOpenAtom)
const LinkList: React.FC<LinkListProps> = () => {
const isTouchDevice = useTouchSensor()
const lastActiveIndexRef = React.useRef<number | null>(null)
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(null)
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<number | null>(null)
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
const [editId, setEditId] = useQueryState("editId")
const [createMode] = useQueryState("create", parseAsBoolean)
const [activeLearningState] = useAtom(learningStateAtom)
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null)
const [sort] = useAtom(linkSortAtom)
const { deleteLink } = useLinkActions()
const confirm = useConfirm()
const { me } = useAccount({ root: { personalLinks: [] } })
const { me } = useAccount({
root: { personalLinks: [] }
})
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
const personalLinks = React.useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
const [sort] = useAtom(linkSortAtom)
const filteredLinks = useMemo(
const filteredLinks = React.useMemo(
() =>
personalLinks.filter(link => {
if (activeLearningState === "all") return true
@@ -61,7 +66,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
[personalLinks, activeLearningState]
)
const sortedLinks = useMemo(
const sortedLinks = React.useMemo(
() =>
sort === "title"
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
@@ -69,10 +74,22 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
[filteredLinks, sort]
)
React.useEffect(() => {
if (editId !== null) {
const index = sortedLinks.findIndex(link => link?.id === editId)
if (index !== -1) {
lastActiveIndexRef.current = index
setActiveItemIndex(index)
setKeyboardActiveIndex(index)
}
}
}, [editId, setActiveItemIndex, setKeyboardActiveIndex, sortedLinks])
const sensors = useSensors(
useSensor(PointerSensor, {
useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
activationConstraint: {
distance: 8
...(isTouchDevice ? { delay: 100, tolerance: 5 } : {}),
distance: 5
}
}),
useSensor(KeyboardSensor, {
@@ -80,52 +97,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
})
)
useKey(
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
async () => {
if (activeItemIndex !== null) {
setIsDeleteConfirmShown(true)
const activeLink = sortedLinks[activeItemIndex]
if (activeLink) {
const result = await confirm({
title: `Delete "${activeLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: {
className: "text-base"
},
cancelButton: {
variant: "outline"
},
confirmButton: {
variant: "destructive"
}
})
if (result) {
if (!me) return
deleteLink(me, activeLink)
setIsDeleteConfirmShown(false)
} else {
setIsDeleteConfirmShown(false)
}
}
}
},
{ event: "keydown" }
)
// on mounted, if editId is set, set activeItemIndex to the index of the item with the editId
useEffect(() => {
if (editId) {
const index = sortedLinks.findIndex(link => link?.id === editId)
if (index !== -1) {
setActiveItemIndex(index)
}
}
}, [editId, sortedLinks, setActiveItemIndex])
const updateSequences = useCallback((links: PersonalLinkLists) => {
const updateSequences = React.useCallback((links: PersonalLinkLists) => {
links.forEach((link, index) => {
if (link) {
link.sequence = index
@@ -133,62 +105,73 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
})
}, [])
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
const handleDeleteLink = React.useCallback(async () => {
if (activeItemIndex === null) return
setIsDeleteConfirmShown(true)
const activeLink = sortedLinks[activeItemIndex]
if (!activeLink || !me) return
useKeydownListener((e: KeyboardEvent) => {
if (
isKeyboardDisabled ||
isCommandPalettePpen ||
!me?.root?.personalLinks ||
sortedLinks.length === 0 ||
editId !== null
)
return
const result = await confirm({
title: `Delete "${activeLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: { className: "text-base" },
cancelButton: { variant: "outline" },
confirmButton: { variant: "destructive" }
})
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)
if (e.metaKey && sort === "manual") {
const linksArray = [...me.root.personalLinks]
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
while (me.root.personalLinks.length > 0) {
me.root.personalLinks.pop()
}
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)
}
if (result) {
deleteLink(me, activeLink)
}
})
setIsDeleteConfirmShown(false)
}, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown])
const handleDragStart = useCallback(
useKeyDown(e => isModKey(e) && e.key === "Backspace", handleDeleteLink)
const next = () => Math.min((activeItemIndex ?? 0) + 1, sortedLinks.length - 1)
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
const handleKeyDown = (ev: KeyboardEvent) => {
switch (ev.key) {
case "ArrowDown":
ev.preventDefault()
ev.stopPropagation()
setActiveItemIndex(next())
setKeyboardActiveIndex(next())
break
case "ArrowUp":
ev.preventDefault()
ev.stopPropagation()
setActiveItemIndex(prev())
setKeyboardActiveIndex(prev())
}
}
useKeyDown(() => true, handleKeyDown)
const handleDragStart = React.useCallback(
(event: DragStartEvent) => {
if (sort !== "manual") return
if (!me) return
const { active } = event
const activeIndex = me?.root.personalLinks.findIndex(item => item?.id === active.id)
if (activeIndex === -1) {
console.error("Drag operation fail", { activeIndex, activeId: active.id })
return
}
setActiveItemIndex(activeIndex)
setDraggingId(active.id)
},
[sort]
[sort, me, setActiveItemIndex]
)
const handleDragCancel = React.useCallback(() => {
setDraggingId(null)
}, [])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
@@ -226,51 +209,70 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
})
updateSequences(me.root.personalLinks)
setActiveItemIndex(newIndex)
} catch (error) {
console.error("Error during link reordering:", error)
}
}
setActiveItemIndex(null)
setDraggingId(null)
}
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({
activeIndex: keyboardActiveIndex
})
return (
<Primitive.div
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={0}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
measuring={measuring}
modifiers={[restrictToVerticalAxis]}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="relative flex h-full grow items-stretch overflow-hidden" tabIndex={-1}>
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
<ul role="list" className="divide-primary/5 divide-y">
{sortedLinks.map(
(linkItem, index) =>
linkItem && (
<LinkItem
key={linkItem.id}
isEditing={editId === linkItem.id}
setEditId={setEditId}
personalLink={linkItem}
disabled={sort !== "manual" || editId !== null}
isDragging={draggingId === linkItem.id}
isActive={activeItemIndex === index}
setActiveItemIndex={setActiveItemIndex}
index={index}
ref={el => setElementRef(el, index)}
/>
)
)}
</ul>
<div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
<div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
<div className="relative overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
{sortedLinks.map(
(linkItem, index) =>
linkItem && (
<LinkItem
key={linkItem.id}
isActive={activeItemIndex === index}
personalLink={linkItem}
editId={editId}
disabled={sort !== "manual" || editId !== null}
onPointerMove={() => {
if (editId !== null || draggingId !== null || createMode) {
return undefined
}
setKeyboardActiveIndex(null)
setActiveItemIndex(index)
}}
onFormClose={() => {
setEditId(null)
setActiveItemIndex(lastActiveIndexRef.current)
setKeyboardActiveIndex(lastActiveIndexRef.current)
console.log(keyboardActiveIndex)
}}
index={index}
onItemSelected={link => setEditId(link.id)}
data-keyboard-active={keyboardActiveIndex === index}
ref={el => setElementRef(el, index)}
/>
)
)}
</div>
</div>
</div>
</SortableContext>
</DndContext>
</Primitive.div>
</div>
</DndContext>
)
}

View File

@@ -1,7 +1,6 @@
"use client"
import React from "react"
import { useKey } from "react-use"
import { LinkForm } from "./partials/form/link-form"
import { motion, AnimatePresence } from "framer-motion"
import { parseAsBoolean, useQueryState } from "nuqs"
@@ -12,9 +11,6 @@ const LinkManage: React.FC<LinkManageProps> = () => {
const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean)
const handleFormClose = () => setCreateMode(false)
const handleFormFail = () => {}
useKey("Escape", handleFormClose)
return (
<AnimatePresence>
@@ -25,7 +21,7 @@ const LinkManage: React.FC<LinkManageProps> = () => {
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.1 }}
>
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} />
</motion.div>
)}
</AnimatePresence>

View File

@@ -19,6 +19,7 @@ import { FormField, FormItem, FormLabel } from "@/components/ui/form"
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector"
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
import { useOnClickOutside } from "@/hooks/use-on-click-outside"
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
@@ -78,26 +79,16 @@ export const LinkForm: React.FC<LinkFormProps> = ({
[exceptionsRefs, globalExceptionRefs]
)
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const isClickInsideForm = formRef.current && formRef.current.contains(event.target as Node)
const isClickInsideExceptions = allExceptionRefs.some((ref, index) => {
const isInside = ref.current && ref.current.contains(event.target as Node)
return isInside
})
if (!isClickInsideForm && !istopicSelectorOpen && !islearningStateSelectorOpen && !isClickInsideExceptions) {
onClose?.()
}
useOnClickOutside(formRef, event => {
if (
!istopicSelectorOpen &&
!islearningStateSelectorOpen &&
!allExceptionRefs.some(ref => ref.current?.contains(event.target as Node))
) {
console.log("clicking outside")
onClose?.()
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose])
})
React.useEffect(() => {
if (selectedLink) {
@@ -193,7 +184,15 @@ export const LinkForm: React.FC<LinkFormProps> = ({
const canSubmit = form.formState.isValid && !form.formState.isSubmitting
return (
<div className="p-3 transition-all">
<div
tabIndex={-1}
className="p-3 transition-all"
onKeyDown={e => {
if (e.key === "Escape") {
handleCancel()
}
}}
>
<div className={cn("bg-muted/30 relative rounded-md border", isFetching && "opacity-50")}>
<Form {...form}>
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1">
@@ -213,7 +212,6 @@ export const LinkForm: React.FC<LinkFormProps> = ({
<LearningStateSelector
value={field.value}
onChange={value => {
// toggle, if already selected set undefined
form.setValue("learningState", field.value === value ? undefined : value)
}}
showSearch={false}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from "react"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { useAtom } from "jotai"
@@ -15,41 +15,35 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { linkOpenPopoverForIdAtom } from "@/store/link"
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
personalLink: PersonalLink
disabled?: boolean
isEditing: boolean
setEditId: (id: string | null) => void
isDragging: boolean
editId: string | null
isActive: boolean
setActiveItemIndex: (index: number | null) => void
index: number
onItemSelected?: (personalLink: PersonalLink) => void
onFormClose?: () => void
}
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => {
export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
({ personalLink, disabled, editId, isActive, index, onItemSelected, onFormClose, ...props }, ref) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const style = useMemo(
const style = React.useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
transition
}),
[transform, transition, isDragging]
[transform, transition]
)
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
const selectedLearningState = useMemo(
const selectedLearningState = React.useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
[personalLink.learningState]
)
const handleLearningStateSelect = useCallback(
const handleLearningStateSelect = React.useCallback(
(value: string) => {
const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
@@ -58,14 +52,23 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
[personalLink, setOpenPopoverForId]
)
if (isEditing) {
return (
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
)
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLDivElement>) => {
if (ev.key === "Enter") {
ev.preventDefault()
ev.stopPropagation()
onItemSelected?.(personalLink)
}
},
[personalLink, onItemSelected]
)
if (editId === personalLink.id) {
return <LinkForm onClose={onFormClose} personalLink={personalLink} onSuccess={onFormClose} onFail={() => {}} />
}
return (
<li
<div
ref={node => {
setNodeRef(node)
if (typeof ref === "function") {
@@ -75,61 +78,68 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
}
}}
style={style as React.CSSProperties}
{...props}
{...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}
onDoubleClick={() => onItemSelected?.(personalLink)}
aria-disabled={disabled}
aria-selected={isActive}
data-disabled={disabled}
data-active={isActive}
className="w-full overflow-visible border-b-[0.5px] border-transparent outline-none data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
onKeyDown={handleKeyDown}
>
<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}
/>
<div
className={cn(
"w-full grow overflow-visible outline-none",
"flex items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2"
)}
<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>
>
<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"
onClick={e => e.stopPropagation()}
onDoubleClick={e => e.stopPropagation()}
>
{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">
<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.5 overflow-hidden md:flex-row md:items-center md:gap-x-2">
<div className="flex items-center gap-x-1">
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 shrink-0 rounded-full"
width={16}
height={16}
/>
)}
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium">{personalLink.title}</p>
</div>
{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" />
@@ -146,16 +156,20 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
</div>
)}
</div>
<div className="flex-1"></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>
<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>
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--link-border-after)]"></div>
</div>
)
}
)

View File

@@ -13,7 +13,7 @@ import { Editor } from "@tiptap/core"
import { generateUniqueSlug } from "@/lib/utils"
import { FocusClasses } from "@tiptap/extension-focus"
import { DetailPageHeader } from "./header"
import { useMedia } from "react-use"
import { useMedia } from "@/hooks/use-media"
import { TopicSelector } from "@/components/custom/topic-selector"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"

View File

@@ -1,4 +1,4 @@
import { useMedia } from "react-use"
import { useMedia } from "@/hooks/use-media"
export const useColumnStyles = () => {
const isTablet = useMedia("(max-width: 640px)")

View File

@@ -4,7 +4,7 @@ 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 { useMedia } from "react-use"
import { useMedia } from "@/hooks/use-media"
import { useColumnStyles } from "./hooks/use-column-styles"
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
import { useRouter } from "next/navigation"
@@ -89,7 +89,7 @@ interface PageListItemsProps {
}
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div

View File

@@ -3,7 +3,7 @@ import Link from "next/link"
import { cn } from "@/lib/utils"
import { PersonalPage } from "@/lib/schema"
import { Badge } from "@/components/ui/badge"
import { useMedia } from "react-use"
import { useMedia } from "@/hooks/use-media"
import { useColumnStyles } from "../hooks/use-column-styles"
import { format } from "date-fns"
import { Column } from "@/components/custom/column"

View File

@@ -1,9 +1,9 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react"
import * as React from "react"
import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
import { Command as CommandPrimitive } from "cmdk"
import { motion, AnimatePresence } from "framer-motion"
import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils"
import { useMountedState } from "react-use"
import { useIsMounted } from "@/hooks/use-is-mounted"
interface GraphNode {
name: string
@@ -18,16 +18,16 @@ interface AutocompleteProps {
}
export function Autocomplete({ topics = [], onSelect, onInputChange }: AutocompleteProps): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
const isMounted = useMountedState()
const [inputValue, setInputValue] = useState("")
const [hasInteracted, setHasInteracted] = useState(false)
const [showDropdown, setShowDropdown] = useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
const [, setOpen] = React.useState(false)
const isMounted = useIsMounted()
const [inputValue, setInputValue] = React.useState("")
const [hasInteracted, setHasInteracted] = React.useState(false)
const [showDropdown, setShowDropdown] = React.useState(false)
const initialShuffledTopics = useMemo(() => shuffleArray(topics).slice(0, 5), [topics])
const initialShuffledTopics = React.useMemo(() => shuffleArray(topics).slice(0, 5), [topics])
const filteredTopics = useMemo(() => {
const filteredTopics = React.useMemo(() => {
if (!inputValue) {
return initialShuffledTopics
}
@@ -44,7 +44,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
.slice(0, 10)
}, [inputValue, topics, initialShuffledTopics])
const handleSelect = useCallback(
const handleSelect = React.useCallback(
(topic: GraphNode) => {
setOpen(false)
onSelect(topic.name)
@@ -52,7 +52,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
[onSelect]
)
const handleInputChange = useCallback(
const handleInputChange = React.useCallback(
(value: string) => {
setInputValue(value)
setShowDropdown(true)
@@ -62,34 +62,27 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
[onInputChange]
)
const handleFocus = useCallback(() => {
const handleFocus = React.useCallback(() => {
setHasInteracted(true)
}, [])
const handleClick = useCallback(() => {
const handleClick = React.useCallback(() => {
setShowDropdown(true)
setHasInteracted(true)
}, [])
const commandKey = useMemo(() => {
const commandKey = React.useMemo(() => {
return filteredTopics
.map(topic => `${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`)
.join("__")
}, [filteredTopics])
useEffect(() => {
React.useEffect(() => {
if (inputRef.current && isMounted() && hasInteracted) {
inputRef.current.focus()
}
}, [commandKey, isMounted, hasInteracted])
const animationProps = {
initial: { opacity: 0, y: -10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
transition: { duration: 0.1 }
}
return (
<Command
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", {
@@ -109,7 +102,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
onClick={handleClick}
placeholder={filteredTopics[0]?.prettyName}
className={cn("placeholder:text-muted-foreground flex-1 bg-transparent px-2 outline-none")}
autoFocus // Add this line
autoFocus
/>
</div>
<div className="relative">

View File

@@ -8,6 +8,7 @@ import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { LearningStateValue } from "@/lib/constants"
import { useClerk } from "@clerk/nextjs"
import { usePathname } from "next/navigation"
import { useMedia } from "@/hooks/use-media"
interface TopicDetailHeaderProps {
topic: Topic
@@ -16,6 +17,7 @@ interface TopicDetailHeaderProps {
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
const clerk = useClerk()
const pathname = usePathname()
const isMobile = useMedia("(max-width: 770px)")
const { me } = useAccountOrGuest({
root: {
topicsWantToLearn: [],
@@ -90,20 +92,19 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }
return (
<ContentHeader className="px-6 py-5 max-lg:px-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</span>
<div className="flex min-h-0 min-w-0 flex-1 items-center">
<h1 className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</h1>
</div>
</div>
<div className="flex flex-auto"></div>
<LearningStateSelector
showSearch={false}
value={p?.learningState || ""}
onChange={handleAddToProfile}
defaultLabel="Add to my profile"
defaultLabel={isMobile ? "" : "Add to profile"}
defaultIcon="Circle"
/>
</ContentHeader>
)

View File

@@ -151,12 +151,7 @@ export const LinkItem = React.memo(
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."

View File

@@ -1,4 +1,4 @@
import { useMedia } from "react-use"
import { useMedia } from "@/hooks/use-media"
export const useColumnStyles = () => {
const isTablet = useMedia("(max-width: 640px)")

View File

@@ -4,7 +4,7 @@ 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 { useMedia } from "@/hooks/use-media"
import { useRouter } from "next/navigation"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { Column } from "@/components/custom/column"
@@ -132,7 +132,7 @@ interface TopicListItemsProps {
}
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div

View File

@@ -139,7 +139,6 @@ export const TopicItem = React.forwardRef<HTMLDivElement, TopicItemProps>(({ top
side="bottom"
align="end"
onClick={e => e.stopPropagation()}
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}