mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
fix(link): Keybind, scroll behaviour, restrict drag to vertical (#176)
* chore: expose scrollActiveElementIntoView * feat(utils): editable element * fix: memoize exceptionRefs, use animation frame and check editable element * fix: improve btn on mobile * chore(drps): bump framer motion version * fix(link): big fix * chore: remove comment code * feat: touch device
This commit is contained in:
11
web/app/custom.css
Normal file
11
web/app/custom.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
:root {
|
||||||
|
--link-background-muted: hsl(0, 0%, 97.3%);
|
||||||
|
--link-border-after: hsl(0, 0%, 91%);
|
||||||
|
--link-shadow: hsl(240, 5.6%, 82.5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--link-background-muted: hsl(220, 6.7%, 8.8%);
|
||||||
|
--link-border-after: hsl(230, 10%, 11.8%);
|
||||||
|
--link-shadow: hsl(234.9, 27.1%, 25.3%);
|
||||||
|
}
|
||||||
@@ -73,3 +73,4 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@import "./command-palette.css";
|
@import "./command-palette.css";
|
||||||
|
@import "./custom.css";
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback, useRef } from "react"
|
import React, { useState } from "react"
|
||||||
import { LinkHeader } from "@/components/routes/link/header"
|
import { LinkHeader } from "@/components/routes/link/header"
|
||||||
import { LinkList } from "@/components/routes/link/list"
|
import { LinkList } from "@/components/routes/link/list"
|
||||||
import { LinkManage } from "@/components/routes/link/manage"
|
import { LinkManage } from "@/components/routes/link/manage"
|
||||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
import { useQueryState } from "nuqs"
|
||||||
import { atom, useAtom } from "jotai"
|
import { atom } from "jotai"
|
||||||
import { LinkBottomBar } from "./bottom-bar"
|
import { LinkBottomBar } from "./bottom-bar"
|
||||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
|
||||||
import { useKey } from "react-use"
|
import { useKey } from "react-use"
|
||||||
|
|
||||||
export const isDeleteConfirmShownAtom = atom(false)
|
export const isDeleteConfirmShownAtom = atom(false)
|
||||||
@@ -15,44 +14,9 @@ export const isDeleteConfirmShownAtom = atom(false)
|
|||||||
export function LinkRoute(): React.ReactElement {
|
export function LinkRoute(): React.ReactElement {
|
||||||
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
|
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
|
||||||
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
||||||
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
|
const [keyboardActiveIndex, setKeyboardActiveIndex] = useState<number | null>(null)
|
||||||
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", () => {
|
useKey("Escape", () => {
|
||||||
setDisableEnterKey(false)
|
|
||||||
setNuqsEditId(null)
|
setNuqsEditId(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,7 +28,8 @@ export function LinkRoute(): React.ReactElement {
|
|||||||
key={nuqsEditId}
|
key={nuqsEditId}
|
||||||
activeItemIndex={activeItemIndex}
|
activeItemIndex={activeItemIndex}
|
||||||
setActiveItemIndex={setActiveItemIndex}
|
setActiveItemIndex={setActiveItemIndex}
|
||||||
disableEnterKey={disableEnterKey}
|
keyboardActiveIndex={keyboardActiveIndex}
|
||||||
|
setKeyboardActiveIndex={setKeyboardActiveIndex}
|
||||||
/>
|
/>
|
||||||
<LinkBottomBar />
|
<LinkBottomBar />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef } from "react"
|
import React, { useCallback, useEffect, useMemo, useRef } from "react"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import type { icons } from "lucide-react"
|
import type { icons } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
import { cn, getShortcutKeys, isEditableElement } from "@/lib/utils"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||||
@@ -70,13 +70,13 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
|
|
||||||
const handleCreateMode = useCallback(() => {
|
const handleCreateMode = useCallback(() => {
|
||||||
setEditId(null)
|
setEditId(null)
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
setCreateMode(prev => !prev)
|
setCreateMode(prev => !prev)
|
||||||
}, 100)
|
})
|
||||||
}, [setEditId, setCreateMode])
|
}, [setEditId, setCreateMode])
|
||||||
|
|
||||||
useEffect(() => {
|
const exceptionRefs = useMemo(
|
||||||
setGlobalLinkFormExceptionRefsAtom([
|
() => [
|
||||||
overlayRef,
|
overlayRef,
|
||||||
contentRef,
|
contentRef,
|
||||||
deleteBtnRef,
|
deleteBtnRef,
|
||||||
@@ -85,8 +85,13 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
confirmBtnRef,
|
confirmBtnRef,
|
||||||
plusBtnRef,
|
plusBtnRef,
|
||||||
plusMoreBtnRef
|
plusMoreBtnRef
|
||||||
])
|
],
|
||||||
}, [setGlobalLinkFormExceptionRefsAtom])
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
|
||||||
|
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent) => {
|
const handleDelete = async (e: React.MouseEvent) => {
|
||||||
if (!personalLink || !me) return
|
if (!personalLink || !me) return
|
||||||
@@ -122,8 +127,9 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
const handleKeydown = useCallback(
|
const handleKeydown = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
const isCreateShortcut = event.key === "c"
|
const isCreateShortcut = event.key === "c"
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
|
||||||
if (isCreateShortcut) {
|
if (isCreateShortcut && !isEditableElement(target)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleCreateMode()
|
handleCreateMode()
|
||||||
}
|
}
|
||||||
@@ -136,29 +142,26 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
const shortcutText = getShortcutKeys(["c"])
|
const shortcutText = getShortcutKeys(["c"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="bg-background min-h-11 border-t">
|
||||||
className="bg-background absolute bottom-0 left-0 right-0 h-11 border-t"
|
|
||||||
animate={{ y: 0 }}
|
|
||||||
initial={{ y: "100%" }}
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{editId && (
|
{editId && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="expanded"
|
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 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 20 }}
|
exit={{ opacity: 0, y: 20 }}
|
||||||
transition={{ duration: 0.1 }}
|
transition={{ duration: 0.1 }}
|
||||||
>
|
>
|
||||||
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} />
|
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} aria-label="Go back" />
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={"Trash"}
|
icon={"Trash"}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
ref={deleteBtnRef}
|
ref={deleteBtnRef}
|
||||||
|
aria-label="Delete link"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} />
|
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} aria-label="More options" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -171,19 +174,20 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
transition={{ duration: 0.1 }}
|
transition={{ duration: 0.1 }}
|
||||||
>
|
>
|
||||||
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} />}
|
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} aria-label="Go back" />}
|
||||||
{!createMode && (
|
{!createMode && (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={"Plus"}
|
icon={"Plus"}
|
||||||
onClick={handleCreateMode}
|
onClick={handleCreateMode}
|
||||||
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
|
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
|
||||||
ref={plusBtnRef}
|
ref={plusBtnRef}
|
||||||
|
aria-label="New link"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => {
|
|||||||
</ContentHeader>
|
</ContentHeader>
|
||||||
|
|
||||||
{isTablet && (
|
{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 />
|
<LearningTab />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -115,7 +115,7 @@ const FilterAndSort = React.memo(() => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm">
|
<Button size="sm" type="button" variant="secondary" className="min-w-8 gap-x-2 text-sm max-sm:p-0">
|
||||||
<ListFilterIcon size={16} className="text-primary/60" />
|
<ListFilterIcon size={16} className="text-primary/60" />
|
||||||
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,18 +9,20 @@ export const useLinkActions = () => {
|
|||||||
try {
|
try {
|
||||||
const index = me.root.personalLinks.findIndex(item => item?.id === link.id)
|
const index = me.root.personalLinks.findIndex(item => item?.id === link.id)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
console.error("Delete operation fail", { index, link })
|
throw new Error(`Link with id ${link.id} not found`)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
me.root.personalLinks.splice(index, 1)
|
||||||
|
|
||||||
toast.success("Link deleted.", {
|
toast.success("Link deleted.", {
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
description: `${link.title} has been deleted.`
|
description: `${link.title} has been deleted.`
|
||||||
})
|
})
|
||||||
|
|
||||||
me.root.personalLinks.splice(index, 1)
|
|
||||||
} catch (error) {
|
} 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"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo } from "react"
|
import React, { useCallback, useMemo } from "react"
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -8,17 +8,20 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
UniqueIdentifier
|
UniqueIdentifier,
|
||||||
|
MeasuringStrategy,
|
||||||
|
TouchSensor
|
||||||
} from "@dnd-kit/core"
|
} from "@dnd-kit/core"
|
||||||
import { Primitive } from "@radix-ui/react-primitive"
|
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
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 { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { linkSortAtom } from "@/store/link"
|
import { linkSortAtom } from "@/store/link"
|
||||||
import { useKey } from "react-use"
|
import { useKey } from "react-use"
|
||||||
import { LinkItem } from "./partials/link-item"
|
import { LinkItem } from "./partials/link-item"
|
||||||
import { useQueryState } from "nuqs"
|
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||||
import { learningStateAtom } from "./header"
|
import { learningStateAtom } from "./header"
|
||||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||||
@@ -27,30 +30,43 @@ import { isDeleteConfirmShownAtom } from "./LinkRoute"
|
|||||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||||
|
import { useTouchSensor } from "@/hooks/use-touch-sensor"
|
||||||
|
|
||||||
interface LinkListProps {
|
interface LinkListProps {
|
||||||
activeItemIndex: number | null
|
activeItemIndex: number | null
|
||||||
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
|
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
|
||||||
disableEnterKey: boolean
|
keyboardActiveIndex: number | null
|
||||||
|
setKeyboardActiveIndex: React.Dispatch<React.SetStateAction<number | null>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => {
|
const measuring: MeasuringConfiguration = {
|
||||||
const [isCommandPalettePpen] = useAtom(commandPaletteOpenAtom)
|
droppable: {
|
||||||
|
strategy: MeasuringStrategy.Always
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkList: React.FC<LinkListProps> = ({
|
||||||
|
activeItemIndex,
|
||||||
|
setActiveItemIndex,
|
||||||
|
keyboardActiveIndex,
|
||||||
|
setKeyboardActiveIndex
|
||||||
|
}) => {
|
||||||
|
const isTouchDevice = useTouchSensor()
|
||||||
|
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||||
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
|
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
|
||||||
const [editId, setEditId] = useQueryState("editId")
|
const [editId, setEditId] = useQueryState("editId")
|
||||||
|
const [createMode] = useQueryState("create", parseAsBoolean)
|
||||||
const [activeLearningState] = useAtom(learningStateAtom)
|
const [activeLearningState] = useAtom(learningStateAtom)
|
||||||
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null)
|
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null)
|
||||||
|
const [sort] = useAtom(linkSortAtom)
|
||||||
|
|
||||||
const { deleteLink } = useLinkActions()
|
const { deleteLink } = useLinkActions()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||||
|
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
|
||||||
|
|
||||||
const { me } = useAccount({
|
|
||||||
root: { personalLinks: [] }
|
|
||||||
})
|
|
||||||
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
||||||
|
|
||||||
const [sort] = useAtom(linkSortAtom)
|
|
||||||
|
|
||||||
const filteredLinks = useMemo(
|
const filteredLinks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
personalLinks.filter(link => {
|
personalLinks.filter(link => {
|
||||||
@@ -70,9 +86,9 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8
|
distance: 5
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -80,51 +96,6 @@ 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 = useCallback((links: PersonalLinkLists) => {
|
||||||
links.forEach((link, index) => {
|
links.forEach((link, index) => {
|
||||||
if (link) {
|
if (link) {
|
||||||
@@ -133,62 +104,105 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
|
const handleDeleteLink = useCallback(async () => {
|
||||||
|
if (activeItemIndex === null) return
|
||||||
|
setIsDeleteConfirmShown(true)
|
||||||
|
const activeLink = sortedLinks[activeItemIndex]
|
||||||
|
if (!activeLink || !me) 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 (result) {
|
||||||
|
deleteLink(me, activeLink)
|
||||||
|
}
|
||||||
|
setIsDeleteConfirmShown(false)
|
||||||
|
}, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown])
|
||||||
|
|
||||||
|
useKey(event => (event.metaKey || event.ctrlKey) && event.key === "Backspace", handleDeleteLink, { event: "keydown" })
|
||||||
|
|
||||||
useKeydownListener((e: KeyboardEvent) => {
|
useKeydownListener((e: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
isKeyboardDisabled ||
|
isKeyboardDisabled ||
|
||||||
isCommandPalettePpen ||
|
isCommandPaletteOpen ||
|
||||||
!me?.root?.personalLinks ||
|
!me?.root?.personalLinks ||
|
||||||
sortedLinks.length === 0 ||
|
sortedLinks.length === 0 ||
|
||||||
editId !== null
|
editId !== null ||
|
||||||
|
e.defaultPrevented
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
switch (e.key) {
|
||||||
e.preventDefault()
|
case "ArrowUp":
|
||||||
setActiveItemIndex(prevIndex => {
|
case "ArrowDown":
|
||||||
if (prevIndex === null) return 0
|
e.preventDefault()
|
||||||
const newIndex =
|
setActiveItemIndex(prevIndex => {
|
||||||
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
|
if (prevIndex === null) return 0
|
||||||
|
|
||||||
if (e.metaKey && sort === "manual") {
|
const newIndex =
|
||||||
const linksArray = [...me.root.personalLinks]
|
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
|
||||||
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
|
|
||||||
|
|
||||||
while (me.root.personalLinks.length > 0) {
|
if (e.metaKey && sort === "manual") {
|
||||||
me.root.personalLinks.pop()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
newLinks.forEach(link => {
|
setKeyboardActiveIndex(newIndex)
|
||||||
if (link) {
|
|
||||||
me.root.personalLinks.push(link)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateSequences(me.root.personalLinks)
|
return newIndex
|
||||||
}
|
})
|
||||||
|
break
|
||||||
return newIndex
|
case "Home":
|
||||||
})
|
e.preventDefault()
|
||||||
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
|
setActiveItemIndex(0)
|
||||||
e.preventDefault()
|
break
|
||||||
const activeLink = sortedLinks[activeItemIndex]
|
case "End":
|
||||||
if (activeLink) {
|
e.preventDefault()
|
||||||
setEditId(activeLink.id)
|
setActiveItemIndex(sortedLinks.length - 1)
|
||||||
}
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
if (sort !== "manual") return
|
if (sort !== "manual") return
|
||||||
|
if (!me) return
|
||||||
|
|
||||||
const { active } = event
|
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)
|
setDraggingId(active.id)
|
||||||
},
|
},
|
||||||
[sort]
|
[sort, me, setActiveItemIndex]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleDragCancel = useCallback(() => {
|
||||||
|
setDraggingId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
|
|
||||||
@@ -226,51 +240,64 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
})
|
})
|
||||||
|
|
||||||
updateSequences(me.root.personalLinks)
|
updateSequences(me.root.personalLinks)
|
||||||
setActiveItemIndex(newIndex)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during link reordering:", error)
|
console.error("Error during link reordering:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActiveItemIndex(null)
|
||||||
setDraggingId(null)
|
setDraggingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
|
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: keyboardActiveIndex })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Primitive.div
|
<DndContext
|
||||||
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
sensors={sensors}
|
||||||
tabIndex={0}
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
measuring={measuring}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
>
|
>
|
||||||
<DndContext
|
<div className="relative flex h-full grow items-stretch overflow-hidden">
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
||||||
<ul role="list" className="divide-primary/5 divide-y">
|
<div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
|
||||||
{sortedLinks.map(
|
<div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
|
||||||
(linkItem, index) =>
|
<div className="relative overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
|
||||||
linkItem && (
|
{sortedLinks.map(
|
||||||
<LinkItem
|
(linkItem, index) =>
|
||||||
key={linkItem.id}
|
linkItem && (
|
||||||
isEditing={editId === linkItem.id}
|
<LinkItem
|
||||||
setEditId={setEditId}
|
key={linkItem.id}
|
||||||
personalLink={linkItem}
|
isActive={activeItemIndex === index}
|
||||||
disabled={sort !== "manual" || editId !== null}
|
personalLink={linkItem}
|
||||||
isDragging={draggingId === linkItem.id}
|
editId={editId}
|
||||||
isActive={activeItemIndex === index}
|
setEditId={setEditId}
|
||||||
setActiveItemIndex={setActiveItemIndex}
|
disabled={sort !== "manual" || editId !== null}
|
||||||
index={index}
|
setActiveItemIndex={setActiveItemIndex}
|
||||||
ref={el => setElementRef(el, index)}
|
onPointerMove={() => {
|
||||||
/>
|
if (editId !== null || draggingId !== null || createMode) {
|
||||||
)
|
return undefined
|
||||||
)}
|
}
|
||||||
</ul>
|
|
||||||
|
setKeyboardActiveIndex(null)
|
||||||
|
setActiveItemIndex(index)
|
||||||
|
}}
|
||||||
|
index={index}
|
||||||
|
onItemSelected={link => setEditId(link.id)}
|
||||||
|
data-keyboard-active={keyboardActiveIndex === index}
|
||||||
|
ref={el => setElementRef(el, index)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</div>
|
||||||
</Primitive.div>
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,34 +15,35 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
|
|||||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||||
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
||||||
|
|
||||||
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
personalLink: PersonalLink
|
personalLink: PersonalLink
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
isEditing: boolean
|
editId: string | null
|
||||||
setEditId: (id: string | null) => void
|
setEditId: (id: string | null) => void
|
||||||
isDragging: boolean
|
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
setActiveItemIndex: (index: number | null) => void
|
setActiveItemIndex: (index: number | null) => void
|
||||||
index: number
|
index: number
|
||||||
|
onItemSelected?: (personalLink: PersonalLink) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
|
export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||||
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => {
|
(
|
||||||
|
{ personalLink, disabled, editId, setEditId, isActive, setActiveItemIndex, index, onItemSelected, ...props },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||||
|
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition
|
||||||
pointerEvents: isDragging ? "none" : "auto"
|
|
||||||
}),
|
}),
|
||||||
[transform, transition, isDragging]
|
[transform, transition]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||||
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
|
||||||
|
|
||||||
const selectedLearningState = useMemo(
|
const selectedLearningState = useMemo(
|
||||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||||
@@ -58,14 +59,14 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
|
|||||||
[personalLink, setOpenPopoverForId]
|
[personalLink, setOpenPopoverForId]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (editId === personalLink.id) {
|
||||||
return (
|
return (
|
||||||
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<div
|
||||||
ref={node => {
|
ref={node => {
|
||||||
setNodeRef(node)
|
setNodeRef(node)
|
||||||
if (typeof ref === "function") {
|
if (typeof ref === "function") {
|
||||||
@@ -75,61 +76,73 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={style as React.CSSProperties}
|
style={style as React.CSSProperties}
|
||||||
|
{...props}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onFocus={() => setActiveItemIndex(index)}
|
onDoubleClick={() => onItemSelected?.(personalLink)}
|
||||||
onBlur={() => setActiveItemIndex(null)}
|
aria-disabled={disabled}
|
||||||
className={cn(
|
aria-selected={isActive}
|
||||||
"relative cursor-default outline-none",
|
data-disabled={disabled}
|
||||||
"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
|
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]"
|
||||||
"bg-muted-foreground/5": isActive,
|
onKeyDown={e => {
|
||||||
"hover:bg-muted/50": !isActive
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
onItemSelected?.(personalLink)
|
||||||
}
|
}
|
||||||
)}
|
}}
|
||||||
onDoubleClick={handleRowDoubleClick}
|
|
||||||
>
|
>
|
||||||
<Popover
|
<div
|
||||||
open={openPopoverForId === personalLink.id}
|
className={cn(
|
||||||
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
|
"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"
|
||||||
<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="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 && (
|
{personalLink.url && (
|
||||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
<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" />
|
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
|
||||||
@@ -146,16 +159,20 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-end">
|
<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>
|
||||||
{personalLink.topic && (
|
</div>
|
||||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
|
||||||
{personalLink.topic.prettyName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ interface PageListItemsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
|
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
|
||||||
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
|
const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Primitive.div
|
<Primitive.div
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ interface TopicListItemsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
|
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
|
||||||
const setElementRef = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
|
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Primitive.div
|
<Primitive.div
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemSc
|
|||||||
|
|
||||||
const scrollActiveElementIntoView = useCallback((index: number) => {
|
const scrollActiveElementIntoView = useCallback((index: number) => {
|
||||||
const activeElement = elementRefs.current[index]
|
const activeElement = elementRefs.current[index]
|
||||||
activeElement?.scrollIntoView({ block: "nearest" })
|
activeElement?.focus()
|
||||||
|
// activeElement?.scrollIntoView({ block: "nearest" })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,5 +27,5 @@ export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemSc
|
|||||||
elementRefs.current[index] = element
|
elementRefs.current[index] = element
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return setElementRef
|
return { setElementRef, scrollActiveElementIntoView }
|
||||||
}
|
}
|
||||||
|
|||||||
20
web/hooks/use-touch-sensor.ts
Normal file
20
web/hooks/use-touch-sensor.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
export function useTouchSensor() {
|
||||||
|
const [isTouchDevice, setIsTouchDevice] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const detectTouch = () => {
|
||||||
|
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
detectTouch()
|
||||||
|
window.addEventListener("touchstart", detectTouch, false)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("touchstart", detectTouch)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isTouchDevice
|
||||||
|
}
|
||||||
@@ -34,6 +34,24 @@ export function shuffleArray<T>(array: T[]): T[] {
|
|||||||
return shuffled
|
return shuffled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isEditableElement = (element: HTMLElement): boolean => {
|
||||||
|
if (element.isContentEditable) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagName = element.tagName.toLowerCase()
|
||||||
|
const editableTags = ["input", "textarea", "select", "option"]
|
||||||
|
|
||||||
|
if (editableTags.includes(tagName)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = element.getAttribute("role")
|
||||||
|
const editableRoles = ["textbox", "combobox", "listbox"]
|
||||||
|
|
||||||
|
return role ? editableRoles.includes(role) : false
|
||||||
|
}
|
||||||
|
|
||||||
export * from "./urls"
|
export * from "./urls"
|
||||||
export * from "./slug"
|
export * from "./slug"
|
||||||
export * from "./keyboard"
|
export * from "./keyboard"
|
||||||
|
|||||||
251
web/package.json
251
web/package.json
@@ -1,127 +1,128 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^5.6.0",
|
"@clerk/nextjs": "^5.6.0",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@nothing-but/force-graph": "^0.9.5",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@nothing-but/utils": "^0.16.0",
|
"@nothing-but/force-graph": "^0.9.5",
|
||||||
"@omit/react-confirm-dialog": "^1.1.5",
|
"@nothing-but/utils": "^0.16.0",
|
||||||
"@omit/react-fancy-switch": "^0.1.3",
|
"@omit/react-confirm-dialog": "^1.1.5",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
"@omit/react-fancy-switch": "^0.1.3",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-checkbox": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-context-menu": "^2.2.1",
|
"@radix-ui/react-checkbox": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-context-menu": "^2.2.1",
|
||||||
"@radix-ui/react-dismissable-layer": "^1.1.0",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dismissable-layer": "^1.1.0",
|
||||||
"@radix-ui/react-focus-scope": "^1.1.0",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-focus-scope": "^1.1.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-popover": "^1.1.1",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-toggle": "^1.1.0",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
"@radix-ui/react-toggle": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||||
"@sentry/nextjs": "^8.30.0",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@sentry/nextjs": "^8.30.0",
|
||||||
"@tiptap/core": "^2.7.2",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"@tiptap/extension-blockquote": "^2.7.2",
|
"@tiptap/core": "^2.7.2",
|
||||||
"@tiptap/extension-bold": "^2.7.2",
|
"@tiptap/extension-blockquote": "^2.7.2",
|
||||||
"@tiptap/extension-bullet-list": "^2.7.2",
|
"@tiptap/extension-bold": "^2.7.2",
|
||||||
"@tiptap/extension-code": "^2.7.2",
|
"@tiptap/extension-bullet-list": "^2.7.2",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.7.2",
|
"@tiptap/extension-code": "^2.7.2",
|
||||||
"@tiptap/extension-color": "^2.7.2",
|
"@tiptap/extension-code-block-lowlight": "^2.7.2",
|
||||||
"@tiptap/extension-document": "^2.7.2",
|
"@tiptap/extension-color": "^2.7.2",
|
||||||
"@tiptap/extension-dropcursor": "^2.7.2",
|
"@tiptap/extension-document": "^2.7.2",
|
||||||
"@tiptap/extension-focus": "^2.7.2",
|
"@tiptap/extension-dropcursor": "^2.7.2",
|
||||||
"@tiptap/extension-gapcursor": "^2.7.2",
|
"@tiptap/extension-focus": "^2.7.2",
|
||||||
"@tiptap/extension-hard-break": "^2.7.2",
|
"@tiptap/extension-gapcursor": "^2.7.2",
|
||||||
"@tiptap/extension-heading": "^2.7.2",
|
"@tiptap/extension-hard-break": "^2.7.2",
|
||||||
"@tiptap/extension-history": "^2.7.2",
|
"@tiptap/extension-heading": "^2.7.2",
|
||||||
"@tiptap/extension-horizontal-rule": "^2.7.2",
|
"@tiptap/extension-history": "^2.7.2",
|
||||||
"@tiptap/extension-image": "^2.7.2",
|
"@tiptap/extension-horizontal-rule": "^2.7.2",
|
||||||
"@tiptap/extension-italic": "^2.7.2",
|
"@tiptap/extension-image": "^2.7.2",
|
||||||
"@tiptap/extension-link": "^2.7.2",
|
"@tiptap/extension-italic": "^2.7.2",
|
||||||
"@tiptap/extension-list-item": "^2.7.2",
|
"@tiptap/extension-link": "^2.7.2",
|
||||||
"@tiptap/extension-ordered-list": "^2.7.2",
|
"@tiptap/extension-list-item": "^2.7.2",
|
||||||
"@tiptap/extension-paragraph": "^2.7.2",
|
"@tiptap/extension-ordered-list": "^2.7.2",
|
||||||
"@tiptap/extension-placeholder": "^2.7.2",
|
"@tiptap/extension-paragraph": "^2.7.2",
|
||||||
"@tiptap/extension-strike": "^2.7.2",
|
"@tiptap/extension-placeholder": "^2.7.2",
|
||||||
"@tiptap/extension-task-item": "^2.7.2",
|
"@tiptap/extension-strike": "^2.7.2",
|
||||||
"@tiptap/extension-task-list": "^2.7.2",
|
"@tiptap/extension-task-item": "^2.7.2",
|
||||||
"@tiptap/extension-text": "^2.7.2",
|
"@tiptap/extension-task-list": "^2.7.2",
|
||||||
"@tiptap/extension-typography": "^2.7.2",
|
"@tiptap/extension-text": "^2.7.2",
|
||||||
"@tiptap/pm": "^2.7.2",
|
"@tiptap/extension-typography": "^2.7.2",
|
||||||
"@tiptap/react": "^2.7.2",
|
"@tiptap/pm": "^2.7.2",
|
||||||
"@tiptap/starter-kit": "^2.7.2",
|
"@tiptap/react": "^2.7.2",
|
||||||
"@tiptap/suggestion": "^2.7.2",
|
"@tiptap/starter-kit": "^2.7.2",
|
||||||
"axios": "^1.7.7",
|
"@tiptap/suggestion": "^2.7.2",
|
||||||
"cheerio": "1.0.0",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.0",
|
"cheerio": "1.0.0",
|
||||||
"clsx": "^2.1.1",
|
"class-variance-authority": "^0.7.0",
|
||||||
"cmdk": "^1.0.0",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"cmdk": "^1.0.0",
|
||||||
"framer-motion": "^11.5.5",
|
"date-fns": "^3.6.0",
|
||||||
"geist": "^1.3.1",
|
"framer-motion": "^11.5.6",
|
||||||
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
|
"geist": "^1.3.1",
|
||||||
"jazz-react": "0.7.35-guest-auth.5",
|
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
|
||||||
"jazz-react-auth-clerk": "0.7.35-guest-auth.5",
|
"jazz-react": "0.7.35-guest-auth.5",
|
||||||
"jazz-tools": "0.7.35-guest-auth.5",
|
"jazz-react-auth-clerk": "0.7.35-guest-auth.5",
|
||||||
"jotai": "^2.9.3",
|
"jazz-tools": "0.7.35-guest-auth.5",
|
||||||
"lowlight": "^3.1.0",
|
"jotai": "^2.10.0",
|
||||||
"lucide-react": "^0.429.0",
|
"lowlight": "^3.1.0",
|
||||||
"next": "14.2.10",
|
"lucide-react": "^0.429.0",
|
||||||
"next-themes": "^0.3.0",
|
"next": "14.2.10",
|
||||||
"nuqs": "^1.19.1",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"nuqs": "^1.19.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-use": "^17.5.1",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"ronin": "^4.3.1",
|
"react-use": "^17.5.1",
|
||||||
"slugify": "^1.6.6",
|
"ronin": "^4.3.1",
|
||||||
"sonner": "^1.5.0",
|
"slugify": "^1.6.6",
|
||||||
"streaming-markdown": "^0.0.14",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"streaming-markdown": "^0.0.14",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwind-merge": "^2.5.2",
|
||||||
"vaul": "^0.9.4",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.23.8",
|
"vaul": "^0.9.4",
|
||||||
"zsa": "^0.6.0",
|
"zod": "^3.23.8",
|
||||||
"zsa-react": "^0.2.2"
|
"zsa": "^0.6.0",
|
||||||
},
|
"zsa-react": "^0.2.3"
|
||||||
"devDependencies": {
|
},
|
||||||
"@ronin/learn-anything": "^0.0.0-3452357373461",
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@ronin/learn-anything": "0.0.0-3452357373461",
|
||||||
"@testing-library/react": "^16.0.1",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"@types/jest": "^29.5.13",
|
"@testing-library/react": "^16.0.1",
|
||||||
"@types/node": "^22.5.5",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/react": "^18.3.7",
|
"@types/node": "^22.5.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^18.3.8",
|
||||||
"dotenv": "^16.4.5",
|
"@types/react-dom": "^18.3.0",
|
||||||
"eslint": "^8.57.1",
|
"dotenv": "^16.4.5",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint": "^8.57.1",
|
||||||
"jest": "^29.7.0",
|
"eslint-config-next": "14.2.5",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"postcss": "^8.4.47",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.12",
|
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||||
"ts-jest": "^29.2.5",
|
"tailwindcss": "^3.4.12",
|
||||||
"ts-node": "^10.9.2",
|
"ts-jest": "^29.2.5",
|
||||||
"typescript": "^5.6.2"
|
"ts-node": "^10.9.2",
|
||||||
}
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user