fix: detail topic (#117)

* feat: keyboard nav

* fix: link update

* feat: reusable learning state

* chore: use new learning state

* feat: add to my profile

* .

* .

* feat: on enter open the link

* fix: lint

* fix: use eslint v8 instead of v9

* fix: add to my profile

* chore: update personal link schema

* chore: update personal page schema

* fix: update detail wrapper

* fix: update page section

* removing option for learning status

* removing option for learning status for topic

* feat: add createdAt and updatedAt for personal Page

* chore: update page section component

* chore: remove chevron from sub menu

* fix: sidebar

* chore: add focus and disable toast

* feat: la editor add execption for no command class

* fix: la editor style and fix page detail

* fix: title

* fix: topic learning state

* chore: add showSearch for learning state

* fix: bunch stuff

* chore: link list and item handle learning state

* chore: set expand to false

* feat: personal link for topic detail

* chore: hook use topic data

* chore: go to list

* fix: link and topic

* feat(utils): new keyboard utils

* feat(store): add linkOpenPopoverForIdAtom for link

* chore: using memo for use topic data

* fix: remove duplicate component

* chore: performance for topic detail lint item

* refactor: remove LinkOptions component

* chore: improve performance for list

* feat: added LinkRoute copmonent

* chore: link manage

* feat: bottom bar

* fix: link

* fix: page wrapper

* fix: import thing

* chore: added a displayname

* refactor: page detail

* refactor: page detail

* fix: add topic to personal link form link

* fix: only show page count if more than zero

* fix: sidebar topic section

---------

Co-authored-by: Nikita <github@nikiv.dev>
Co-authored-by: marshennikovaolga <marshennikova@gmail.com>
This commit is contained in:
Aslam
2024-08-29 02:48:48 +07:00
committed by GitHub
parent 94a63bd79b
commit 9e89959dd4
50 changed files with 1667 additions and 1000 deletions

View File

@@ -1,19 +0,0 @@
"use client"
import { LinkHeader } from "@/components/routes/link/header"
import { LinkList } from "@/components/routes/link/list"
import { LinkManage } from "@/components/routes/link/form/manage"
import { useAtom } from "jotai"
import { linkEditIdAtom } from "@/store/link"
export function AuthHomeRoute() {
const [editId] = useAtom(linkEditIdAtom)
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<LinkHeader />
<LinkManage />
<LinkList key={editId} />
</div>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { LinkHeader } from "@/components/routes/link/header"
import { LinkList } from "@/components/routes/link/list"
import { LinkManage } from "@/components/routes/link/manage"
import { useQueryState } from "nuqs"
import { useEffect } from "react"
import { useAtom } from "jotai"
import { linkEditIdAtom } from "@/store/link"
import { LinkBottomBar } from "./bottom-bar"
export function LinkRoute() {
const [, setEditId] = useAtom(linkEditIdAtom)
const [nuqsEditId] = useQueryState("editId")
useEffect(() => {
setEditId(nuqsEditId)
}, [nuqsEditId, setEditId])
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<LinkHeader />
<LinkManage />
<LinkList />
<LinkBottomBar />
</div>
)
}

View File

@@ -0,0 +1,198 @@
import React, { useEffect, useRef } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { icons } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { useAtom } from "jotai"
import { linkShowCreateAtom } from "@/store/link"
import { useQueryState } from "nuqs"
import { useConfirm } from "@omit/react-confirm-dialog"
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
import { PersonalLink } from "@/lib/schema"
import { ID } from "jazz-tools"
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
import { toast } from "sonner"
interface ToolbarButtonProps {
icon: keyof typeof icons
onClick?: (e: React.MouseEvent) => void
tooltip?: string
}
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(({ icon, onClick, tooltip }, ref) => {
const button = (
<Button variant="ghost" className="h-8 min-w-14" onClick={onClick} ref={ref}>
<LaIcon name={icon} />
</Button>
)
if (tooltip) {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return button
})
ToolbarButton.displayName = "ToolbarButton"
export const LinkBottomBar: React.FC = () => {
const [editId, setEditId] = useQueryState("editId")
const [, setGlobalLinkFormExceptionRefsAtom] = useAtom(globalLinkFormExceptionRefsAtom)
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
const { me } = useAccount({ root: { personalLinks: [] } })
const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>)
const cancelBtnRef = useRef<HTMLButtonElement>(null)
const confirmBtnRef = useRef<HTMLButtonElement>(null)
const deleteBtnRef = useRef<HTMLButtonElement>(null)
const editMoreBtnRef = useRef<HTMLButtonElement>(null)
const plusBtnRef = useRef<HTMLButtonElement>(null)
const plusMoreBtnRef = useRef<HTMLButtonElement>(null)
const confirm = useConfirm()
useEffect(() => {
setGlobalLinkFormExceptionRefsAtom([
deleteBtnRef,
editMoreBtnRef,
cancelBtnRef,
confirmBtnRef,
plusBtnRef,
plusMoreBtnRef
])
}, [setGlobalLinkFormExceptionRefsAtom])
const handleDelete = async (e: React.MouseEvent) => {
if (!personalLink) return
const result = await confirm({
title: `Delete "${personalLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: {
className: "text-base"
},
customActions(onConfirm, onCancel) {
return (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onCancel} ref={cancelBtnRef}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} ref={confirmBtnRef}>
Delete
</Button>
</div>
)
}
})
if (result) {
if (!me?.root.personalLinks) return
const index = me.root.personalLinks.findIndex(item => item?.id === personalLink.id)
if (index === -1) {
console.error("Delete operation fail", { index, personalLink })
return
}
toast.success("Link deleted.", {
position: "bottom-right",
description: (
<span>
<strong>{personalLink.title}</strong> has been deleted.
</span>
)
})
me.root.personalLinks.splice(index, 1)
setEditId(null)
}
}
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isMacOS()) {
if (event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n") {
event.preventDefault()
setShowCreate(true)
}
} else {
// For Windows, we'll use Ctrl + Win + N
// Note: The Windows key is not directly detectable in most browsers
if (event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)) {
event.preventDefault()
setShowCreate(true)
}
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [setShowCreate])
const shortcutKeys = getSpecialShortcut("expandToolbar")
const shortcutText = formatShortcut(shortcutKeys)
return (
<motion.div
className="bg-background absolute bottom-0 left-0 right-0 border-t"
animate={{ y: 0 }}
initial={{ y: "100%" }}
>
<AnimatePresence mode="wait">
{editId && (
<motion.div
key="expanded"
className="flex items-center justify-center gap-1 px-2 py-1"
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={"Trash"} onClick={handleDelete} ref={deleteBtnRef} />
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} />
</motion.div>
)}
{!editId && (
<motion.div
key="collapsed"
className="flex items-center justify-center gap-1 px-2 py-1"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.1 }}
>
{showCreate && <ToolbarButton icon={"ArrowLeft"} onClick={() => setShowCreate(true)} />}
{!showCreate && (
<ToolbarButton
icon={"Plus"}
onClick={() => setShowCreate(true)}
tooltip={`New Link (${shortcutText})`}
ref={plusBtnRef}
/>
)}
<ToolbarButton icon={"Ellipsis"} ref={plusMoreBtnRef} />
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
LinkBottomBar.displayName = "LinkBottomBar"
export default LinkBottomBar

View File

@@ -1 +0,0 @@
export * from "./manage"

View File

@@ -1,100 +0,0 @@
"use client"
import { Button } from "@/components/ui/button"
import { linkEditIdAtom, linkShowCreateAtom } from "@/store/link"
import { useAtom } from "jotai"
import React, { useEffect, useRef, useState } from "react"
import { useKey } from "react-use"
import { globalLinkFormExceptionRefsAtom, LinkForm } from "./link-form"
import { LaIcon } from "@/components/custom/la-icon"
import LinkOptions from "@/components/LinkOptions"
// import { FloatingButton } from "./partial/floating-button"
const LinkManage: React.FC = () => {
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
const [editId, setEditId] = useAtom(linkEditIdAtom)
const [, setGlobalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
const [showOptions, setShowOptions] = useState(false)
const optionsRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const toggleForm = (event: React.MouseEvent) => {
event.stopPropagation()
if (showCreate) return
setShowCreate(prev => !prev)
}
const clickOptionsButton = (e: React.MouseEvent) => {
e.preventDefault()
setShowOptions(prev => !prev)
}
const handleFormClose = () => {
setShowCreate(false)
}
const handleFormFail = () => {}
// wipes the data from the form when the form is closed
React.useEffect(() => {
if (!showCreate) {
setEditId(null)
}
}, [showCreate, setEditId])
useKey("Escape", handleFormClose)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (optionsRef.current && !optionsRef.current.contains(event.target as Node)) {
setShowOptions(false)
}
}
if (showOptions) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [showOptions])
/*
* This code means that when link form is opened, these refs will be added as an exception to the click outside handler
*/
React.useEffect(() => {
setGlobalExceptionRefs([optionsRef, buttonRef])
}, [setGlobalExceptionRefs])
return (
<>
{showCreate && <LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />}
<div className="absolute bottom-0 m-0 flex w-full list-none bg-inherit p-2.5 text-center align-middle font-semibold leading-[13px] no-underline">
<div className="mx-auto flex flex-row items-center justify-center gap-2">
<Button
variant="ghost"
onClick={toggleForm}
className={editId || showCreate ? "text-red-500 hover:bg-red-500/50 hover:text-white" : ""}
>
<LaIcon name={showCreate ? "X" : editId ? "Trash" : "Plus"} />
</Button>
<div className="relative" ref={optionsRef}>
{showOptions && <LinkOptions />}
<Button ref={buttonRef} variant="ghost" onClick={clickOptionsButton}>
<LaIcon name="Ellipsis" />
</Button>
</div>
</div>
</div>
</>
)
}
LinkManage.displayName = "LinkManage"
export { LinkManage }
/* <FloatingButton ref={buttonRef} onClick={toggleForm} isOpen={showCreate} /> */

View File

@@ -1,28 +0,0 @@
import React, { forwardRef } from "react"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { Button, ButtonProps } from "@/components/ui/button"
interface FloatingButtonProps extends ButtonProps {
isOpen: boolean
}
export const FloatingButton = forwardRef<HTMLButtonElement, FloatingButtonProps>(
({ isOpen, className, ...props }, ref) => (
<Button
ref={ref}
className={cn(
"absolute bottom-4 right-4 h-12 w-12 rounded-full bg-[#274079] p-0 text-white transition-transform hover:bg-[#274079]/90",
{ "rotate-45 transform": isOpen },
className
)}
{...props}
>
<LaIcon name="Plus" className="h-6 w-6" />
</Button>
)
)
FloatingButton.displayName = "FloatingButton"
export default FloatingButton

View File

@@ -1,90 +0,0 @@
import { Button } from "@/components/ui/button"
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useFormContext } from "react-hook-form"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { useAtom } from "jotai"
import { linkLearningStateSelectorAtom } from "@/store/link"
import { useMemo } from "react"
import { LinkFormValues } from "../schema"
import { LEARNING_STATES } from "@/lib/constants"
export const LearningStateSelector: React.FC = () => {
const [islearningStateSelectorOpen, setIslearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
const form = useFormContext<LinkFormValues>()
const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === form.getValues("learningState")),
[form]
)
return (
<FormField
control={form.control}
name="learningState"
render={({ field }) => (
<FormItem className="space-y-0">
<FormLabel className="sr-only">Topic</FormLabel>
<Popover open={islearningStateSelectorOpen} onOpenChange={setIslearningStateSelectorOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button size="sm" type="button" role="combobox" variant="secondary" className="gap-x-2 text-sm">
{selectedLearningState?.icon && (
<LaIcon
name={selectedLearningState.icon}
className={cn("h-4 w-4", selectedLearningState.className)}
/>
)}
<span className={cn("truncate", selectedLearningState?.className || "")}>
{selectedLearningState?.label || "Select state"}
</span>
<LaIcon name="ChevronDown" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="end"
onCloseAutoFocus={e => e.preventDefault()}
>
<Command>
<CommandInput placeholder="Search state..." className="h-9" />
<CommandList>
<ScrollArea>
<CommandGroup>
{LEARNING_STATES.map(ls => (
<CommandItem
key={ls.value}
value={ls.value}
onSelect={value => {
field.onChange(value)
setIslearningStateSelectorOpen(false)
}}
>
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
<span className={ls.className}>{ls.label}</span>
<LaIcon
name="Check"
size={16}
className={cn(
"absolute right-3",
ls.value === field.value ? "text-primary" : "text-transparent"
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItem>
)}
/>
)
}

View File

@@ -88,6 +88,8 @@ const LearningTab = React.memo(() => {
)
})
LearningTab.displayName = "LearningTab"
const FilterAndSort = React.memo(() => {
const [sort, setSort] = useAtom(linkSortAtom)
const [sortOpen, setSortOpen] = React.useState(false)

View File

@@ -1,255 +0,0 @@
"use client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { PersonalLink } from "@/lib/schema/personal-link"
import { cn } from "@/lib/utils"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { ConfirmOptions } from "@omit/react-confirm-dialog"
import { LinkIcon, Trash2Icon } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import * as React from "react"
import { LinkForm } from "./form/link-form"
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { LaIcon } from "@/components/custom/la-icon"
import { LEARNING_STATES } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
interface ListItemProps {
confirm: (options: ConfirmOptions) => Promise<boolean>
personalLink: PersonalLink
disabled?: boolean
isEditing: boolean
setEditId: (id: string | null) => void
isDragging: boolean
isFocused: boolean
setFocusedId: (id: string | null) => void
registerRef: (id: string, ref: HTMLLIElement | null) => void
onDelete?: (personalLink: PersonalLink) => void
showDeleteIconForLinkId: string | null
setShowDeleteIconForLinkId: (id: string | null) => void
}
export const ListItem: React.FC<ListItemProps> = ({
confirm,
isEditing,
setEditId,
personalLink,
disabled = false,
isDragging,
isFocused,
setFocusedId,
registerRef,
onDelete,
showDeleteIconForLinkId,
setShowDeleteIconForLinkId
}) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const style = {
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
}
const refCallback = React.useCallback(
(node: HTMLLIElement | null) => {
setNodeRef(node)
registerRef(personalLink.id, node)
},
[setNodeRef, registerRef, personalLink.id]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
setEditId(personalLink.id)
}
}
const handleSuccess = () => {
setEditId(null)
}
const handleOnClose = () => {
setEditId(null)
}
const handleOnFail = () => {}
// const handleRowClick = () => {
// setShowDeleteIconForLinkId(personalLink.id)
// }
const handleRowDoubleClick = () => {
setEditId(personalLink.id)
}
const handleDelete = async (e: React.MouseEvent, personalLink: PersonalLink) => {
e.stopPropagation()
const result = await confirm({
title: `Delete "${personalLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: {
className: "text-base"
},
customActions: (onConfirm, onCancel) => (
<>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</>
)
})
if (result) {
onDelete?.(personalLink)
}
}
const selectedLearningState = LEARNING_STATES.find(ls => ls.value === personalLink.learningState)
if (isEditing) {
return (
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={handleOnFail} />
)
}
return (
<li
ref={refCallback}
style={style as React.CSSProperties}
{...attributes}
{...listeners}
tabIndex={0}
onFocus={() => setFocusedId(personalLink.id)}
onBlur={() => {
setFocusedId(null)
}}
onKeyDown={handleKeyDown}
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11", {
"bg-muted/50": isFocused
})}
// onClick={handleRowClick}
onDoubleClick={handleRowDoubleClick}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
{/* <Checkbox
checked={personalLink.completed}
onClick={e => e.stopPropagation()}
onCheckedChange={() => {
personalLink.completed = !personalLink.completed
}}
className="border-muted-foreground border"
/> */}
<Popover>
<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)} />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<Command>
<CommandInput placeholder="Search state..." className="h-9" />
<CommandList>
<ScrollArea>
<CommandGroup>
{LEARNING_STATES.map(ls => (
<CommandItem
key={ls.value}
value={ls.value}
onSelect={value => {
personalLink.learningState = value as "wantToLearn" | "learning" | "learned" | undefined
}}
>
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
<span className={ls.className}>{ls.label}</span>
<LaIcon
name="Check"
size={16}
className={cn(
"absolute right-3",
ls.value === personalLink.learningState ? "text-primary" : "text-transparent"
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 rounded-full"
width={16}
height={16}
/>
)}
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
{personalLink.title}
</p>
{personalLink.url && (
<div className="group flex items-center gap-x-1">
<LinkIcon
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
/>
<Link
href={personalLink.url}
passHref
prefetch={false}
target="_blank"
onClick={e => {
e.stopPropagation()
}}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="xl:truncate">{personalLink.url}</span>
</Link>
</div>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-x-4">
{personalLink.topic && <Badge variant="secondary">{personalLink.topic.prettyName}</Badge>}
{showDeleteIconForLinkId === personalLink.id && (
<Button
size="icon"
className="text-destructive h-auto w-auto bg-transparent hover:bg-transparent hover:text-red-500"
onClick={e => {
e.stopPropagation()
handleDelete(e, personalLink)
}}
>
<Trash2Icon size={16} />
</Button>
)}
</div>
</div>
</li>
)
}

View File

@@ -7,45 +7,54 @@ import {
PointerSensor,
useSensor,
useSensors,
DragEndEvent
DragEndEvent,
DragStartEvent,
UniqueIdentifier
} from "@dnd-kit/core"
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { useAccount } from "@/lib/providers/jazz-provider"
import { PersonalLinkLists } from "@/lib/schema/personal-link"
import { PersonalLink } from "@/lib/schema/personal-link"
import { useAtom } from "jotai"
import { linkEditIdAtom, linkSortAtom } from "@/store/link"
import { linkSortAtom } from "@/store/link"
import { useKey } from "react-use"
import { useConfirm } from "@omit/react-confirm-dialog"
import { ListItem } from "./list-item"
import { useRef, useState, useCallback, useEffect } from "react"
import { LinkItem } from "./partials/link-item"
import { useRef, useState, useCallback, useEffect, useMemo } from "react"
import { learningStateAtom } from "./header"
import { useQueryState } from "nuqs"
const LinkList = () => {
interface LinkListProps {}
const LinkList: React.FC<LinkListProps> = () => {
const [editId, setEditId] = useQueryState("editId")
const [activeLearningState] = useAtom(learningStateAtom)
const confirm = useConfirm()
const { me } = useAccount({
root: { personalLinks: [] }
})
const personalLinks = me?.root?.personalLinks || []
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
const [editId, setEditId] = useAtom(linkEditIdAtom)
const [sort] = useAtom(linkSortAtom)
const [focusedId, setFocusedId] = useState<string | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const [draggingId, setDraggingId] = useState<UniqueIdentifier | null>(null)
const linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({})
const [showDeleteIconForLinkId, setShowDeleteIconForLinkId] = useState<string | null>(null)
let filteredLinks = personalLinks.filter(link => {
if (activeLearningState === "all") return true
if (!link?.learningState) return false
return link.learningState === activeLearningState
})
let sortedLinks =
sort === "title" && filteredLinks
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
: filteredLinks
sortedLinks = sortedLinks || []
const filteredLinks = useMemo(
() =>
personalLinks.filter(link => {
if (activeLearningState === "all") return true
if (!link?.learningState) return false
return link.learningState === activeLearningState
}),
[personalLinks, activeLearningState]
)
const sortedLinks = useMemo(
() =>
sort === "title"
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
: filteredLinks,
[filteredLinks, sort]
)
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -68,6 +77,14 @@ const LinkList = () => {
}
})
const updateSequences = useCallback((links: PersonalLinkLists) => {
links.forEach((link, index) => {
if (link) {
link.sequence = index
}
})
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
@@ -120,21 +137,16 @@ const LinkList = () => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort])
}, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort, updateSequences])
const updateSequences = (links: PersonalLinkLists) => {
links.forEach((link, index) => {
if (link) {
link.sequence = index
}
})
}
const handleDragStart = (event: any) => {
if (sort !== "manual") return
const { active } = event
setDraggingId(active.id)
}
const handleDragStart = useCallback(
(event: DragStartEvent) => {
if (sort !== "manual") return
const { active } = event
setDraggingId(active.id)
},
[sort]
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
@@ -181,20 +193,8 @@ const LinkList = () => {
setDraggingId(null)
}
const handleDelete = (linkItem: PersonalLink) => {
if (!me?.root?.personalLinks) return
const index = me.root.personalLinks.findIndex(item => item?.id === linkItem.id)
if (index === -1) {
console.error("Delete operation fail", { index, linkItem })
return
}
me.root.personalLinks.splice(index, 1)
}
return (
<div className="relative z-20">
<div className="mb-14 flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -206,9 +206,8 @@ const LinkList = () => {
{sortedLinks.map(
linkItem =>
linkItem && (
<ListItem
<LinkItem
key={linkItem.id}
confirm={confirm}
isEditing={editId === linkItem.id}
setEditId={setEditId}
personalLink={linkItem}
@@ -217,9 +216,6 @@ const LinkList = () => {
isDragging={draggingId === linkItem.id}
isFocused={focusedId === linkItem.id}
setFocusedId={setFocusedId}
onDelete={handleDelete}
showDeleteIconForLinkId={showDeleteIconForLinkId}
setShowDeleteIconForLinkId={setShowDeleteIconForLinkId}
/>
)
)}

View File

@@ -0,0 +1,38 @@
"use client"
import React from "react"
import { linkShowCreateAtom } from "@/store/link"
import { useAtom } from "jotai"
import { useKey } from "react-use"
import { LinkForm } from "./partials/form/link-form"
import { motion, AnimatePresence } from "framer-motion"
interface LinkManageProps {}
const LinkManage: React.FC<LinkManageProps> = () => {
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
const handleFormClose = () => setShowCreate(false)
const handleFormFail = () => {}
useKey("Escape", handleFormClose)
return (
<AnimatePresence>
{showCreate && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.1 }}
>
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />
</motion.div>
)}
</AnimatePresence>
)
}
LinkManage.displayName = "LinkManage"
export { LinkManage }

View File

@@ -2,7 +2,7 @@ import * as React from "react"
import { useFormContext } from "react-hook-form"
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
import { TextareaAutosize } from "@/components/custom/textarea-autosize"
import { LinkFormValues } from "../schema"
import { LinkFormValues } from "./schema"
interface DescriptionInputProps {}

View File

@@ -8,17 +8,19 @@ import { createLinkSchema, LinkFormValues } from "./schema"
import { cn, generateUniqueSlug } from "@/lib/utils"
import { Form } from "@/components/ui/form"
import { Button } from "@/components/ui/button"
import { UrlInput } from "./partial/url-input"
import { UrlBadge } from "./partial/url-badge"
import { TitleInput } from "./partial/title-input"
import { NotesSection } from "./partial/notes-section"
import { TopicSelector } from "./partial/topic-selector"
import { DescriptionInput } from "./partial/description-input"
import { LearningStateSelector } from "./partial/learning-state-selector"
import { UrlInput } from "./url-input"
import { UrlBadge } from "./url-badge"
import { TitleInput } from "./title-input"
import { NotesSection } from "./notes-section"
import { TopicSelector } from "./topic-selector"
import { DescriptionInput } from "./description-input"
import { atom, useAtom } from "jotai"
import { linkLearningStateSelectorAtom, linkTopicSelectorAtom } from "@/store/link"
import { FormField, FormItem, FormLabel } from "@/components/ui/form"
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
onClose?: () => void
onSuccess?: () => void
@@ -34,7 +36,7 @@ const defaultValues: Partial<LinkFormValues> = {
description: "",
completed: false,
notes: "",
learningState: "wantToLearn",
learningState: undefined,
topic: null
}
@@ -45,7 +47,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
onClose,
exceptionsRefs = []
}) => {
const [selectedTopic, setSelectedTopic] = React.useState<Topic | null>(null)
const [selectedTopic, setSelectedTopic] = React.useState<Topic | undefined>()
const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
@@ -134,12 +136,19 @@ export const LinkForm: React.FC<LinkFormProps> = ({
const onSubmit = (values: LinkFormValues) => {
if (isFetching) return
try {
const personalLinks = me.root?.personalLinks?.toJSON() || []
const slug = generateUniqueSlug(personalLinks, values.title)
if (selectedLink) {
selectedLink.applyDiff({ ...values, slug, topic: selectedTopic })
const { topic, ...diffValues } = values
if (!selectedTopic) {
selectedLink.applyDiff({ ...diffValues, slug, updatedAt: new Date() })
} else {
selectedLink.applyDiff({ ...values, slug, topic: selectedTopic })
}
} else {
const newPersonalLink = PersonalLink.create(
{
@@ -188,7 +197,23 @@ export const LinkForm: React.FC<LinkFormProps> = ({
{urlFetched && <TitleInput urlFetched={urlFetched} />}
<div className="flex flex-row items-center gap-2">
<LearningStateSelector />
<FormField
control={form.control}
name="learningState"
render={({ field }) => (
<FormItem className="space-y-0">
<FormLabel className="sr-only">Topic</FormLabel>
<LearningStateSelector
value={field.value}
onChange={value => {
// toggle, if already selected set undefined
form.setValue("learningState", field.value === value ? undefined : value)
}}
showSearch={false}
/>
</FormItem>
)}
/>
<TopicSelector onSelect={topic => setSelectedTopic(topic)} />
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { LinkFormValues } from "../schema"
import { LinkFormValues } from "./schema"
export const NotesSection: React.FC = () => {
const form = useFormContext<LinkFormValues>()
@@ -18,7 +18,7 @@ export const NotesSection: React.FC = () => {
<FormControl>
<>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<LaIcon name="Pencil" aria-hidden="true" className="text-muted-foreground/70 size-3" />
<LaIcon name="Pencil" aria-hidden="true" className="text-muted-foreground/70" />
</div>
<Input

View File

@@ -8,7 +8,7 @@ export const createLinkSchema = z.object({
description: z.string().optional(),
completed: z.boolean().default(false),
notes: z.string().optional(),
learningState: z.enum(["wantToLearn", "learning", "learned"]),
learningState: z.enum(["wantToLearn", "learning", "learned"]).optional(),
topic: z.string().nullable().optional()
})

View File

@@ -2,7 +2,7 @@ import * as React from "react"
import { useFormContext } from "react-hook-form"
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LinkFormValues } from "../schema"
import { LinkFormValues } from "./schema"
interface TitleInputProps {
urlFetched: string | null

View File

@@ -7,7 +7,7 @@ import { useFormContext } from "react-hook-form"
import { cn } from "@/lib/utils"
import { useAtom } from "jotai"
import { linkTopicSelectorAtom } from "@/store/link"
import { LinkFormValues } from "../schema"
import { LinkFormValues } from "./schema"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
import { ID } from "jazz-tools"

View File

@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"
import { LinkFormValues } from "../schema"
import { LinkFormValues } from "./schema"
interface UrlBadgeProps {
urlFetched: string | null

View File

@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form"
import { FormField, FormItem, FormControl, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { LinkFormValues } from "../schema"
import { LinkFormValues } from "./schema"
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { TooltipArrow } from "@radix-ui/react-tooltip"

View File

@@ -0,0 +1,182 @@
"use client"
import React, { useCallback, useMemo } from "react"
import Image from "next/image"
import Link from "next/link"
import { useAtom } from "jotai"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
import { PersonalLink } from "@/lib/schema/personal-link"
import { LinkForm } from "./form/link-form"
import { cn } from "@/lib/utils"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { linkOpenPopoverForIdAtom, linkShowCreateAtom } from "@/store/link"
interface LinkItemProps {
personalLink: PersonalLink
disabled?: boolean
isEditing: boolean
setEditId: (id: string | null) => void
isDragging: boolean
isFocused: boolean
setFocusedId: (id: string | null) => void
registerRef: (id: string, ref: HTMLLIElement | null) => void
}
export const LinkItem: React.FC<LinkItemProps> = ({
isEditing,
setEditId,
personalLink,
disabled = false,
isDragging,
isFocused,
setFocusedId,
registerRef
}) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
}),
[transform, transition, isDragging]
)
const refCallback = useCallback(
(node: HTMLLIElement | null) => {
setNodeRef(node)
registerRef(personalLink.id, node)
},
[setNodeRef, registerRef, personalLink.id]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
setEditId(personalLink.id)
}
},
[setEditId, personalLink.id]
)
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
[personalLink.learningState]
)
const handleLearningStateSelect = useCallback(
(value: string) => {
const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
setOpenPopoverForId(null)
},
[personalLink, setOpenPopoverForId]
)
if (isEditing) {
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
}
return (
<li
ref={refCallback}
style={style as React.CSSProperties}
{...attributes}
{...listeners}
tabIndex={0}
onFocus={() => setFocusedId(personalLink.id)}
onBlur={() => setFocusedId(null)}
onKeyDown={handleKeyDown}
className={cn("relative flex h-14 cursor-default items-center outline-none xl:h-11", {
"bg-muted-foreground/10": isFocused,
"hover:bg-muted/50": !isFocused
})}
onDoubleClick={handleRowDoubleClick}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Popover
open={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>
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 rounded-full"
width={16}
height={16}
/>
)}
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
{personalLink.title}
</p>
{personalLink.url && (
<div className="group flex items-center gap-x-1">
<LaIcon
name="Link"
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary flex-none"
/>
<Link
href={personalLink.url}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="xl:truncate">{personalLink.url}</span>
</Link>
</div>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-x-4">
{personalLink.topic && <Badge variant="secondary">{personalLink.topic.prettyName}</Badge>}
</div>
</div>
</li>
)
}