mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
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:
@@ -1,5 +1,5 @@
|
|||||||
import AuthHomeRoute from "@/components/routes/AuthHomeRoute"
|
import { LinkRoute } from "@/components/routes/link/LinkRoute"
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return <AuthHomeRoute />
|
return <LinkRoute />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DetailPageWrapper } from "@/components/routes/page/detail/wrapper"
|
import { PageDetailRoute } from "@/components/routes/page/detail/PageDetailRoute"
|
||||||
|
|
||||||
export default function DetailPage({ params }: { params: { id: string } }) {
|
export default function DetailPage({ params }: { params: { id: string } }) {
|
||||||
return <DetailPageWrapper pageId={params.id} />
|
return <PageDetailRoute pageId={params.id} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { Inter as FontSans } from "next/font/google"
|
import { Inter as FontSans } from "next/font/google"
|
||||||
import { Inter } from "next/font/google"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ThemeProvider } from "@/lib/providers/theme-provider"
|
import { ThemeProvider } from "@/lib/providers/theme-provider"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
@@ -32,7 +31,7 @@ export default function RootLayout({
|
|||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster expand={false} />
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { LaIcon } from "@/components/custom/la-icon"
|
|
||||||
|
|
||||||
export default function LinkOptions() {
|
|
||||||
const buttonClass =
|
|
||||||
"block w-full flex flex-row items-center px-4 py-2 rounded-lg text-left text-sm hover:bg-gray-700/20"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute bottom-full left-0 mb-2 w-48 rounded-md bg-neutral-800/40 text-white shadow-lg dark:bg-neutral-800">
|
|
||||||
<div>
|
|
||||||
<button className={buttonClass}>
|
|
||||||
<LaIcon name="Repeat2" className="mr-2" />
|
|
||||||
Repeat
|
|
||||||
</button>
|
|
||||||
<button className={buttonClass}>
|
|
||||||
<LaIcon name="Layers2" className="mr-2" />
|
|
||||||
Duplicate
|
|
||||||
</button>
|
|
||||||
<button className={buttonClass}>
|
|
||||||
<LaIcon name="Share" className="mr-2" />
|
|
||||||
Share
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
107
web/components/custom/learning-state-selector.tsx
Normal file
107
web/components/custom/learning-state-selector.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useMemo } from "react"
|
||||||
|
import { useAtom } from "jotai"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
|
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||||
|
import { linkLearningStateSelectorAtom } from "@/store/link"
|
||||||
|
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
|
interface LearningStateSelectorProps {
|
||||||
|
showSearch?: boolean
|
||||||
|
defaultLabel?: string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
value?: string
|
||||||
|
onChange: (value: LearningStateValue) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||||
|
showSearch = true,
|
||||||
|
defaultLabel = "Select state",
|
||||||
|
searchPlaceholder = "Search state...",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
||||||
|
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value])
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
onChange(selectedValue as LearningStateValue)
|
||||||
|
setIsLearningStateSelectorOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isLearningStateSelectorOpen} onOpenChange={setIsLearningStateSelectorOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
variant="secondary"
|
||||||
|
className={cn("gap-x-2 text-sm", className)}
|
||||||
|
>
|
||||||
|
{selectedLearningState?.icon && (
|
||||||
|
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||||
|
)}
|
||||||
|
<span className={cn("truncate", selectedLearningState?.className || "")}>
|
||||||
|
{selectedLearningState?.label || defaultLabel}
|
||||||
|
</span>
|
||||||
|
<LaIcon name="ChevronDown" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-52 rounded-lg p-0"
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
onCloseAutoFocus={e => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<LearningStateSelectorContent
|
||||||
|
showSearch={showSearch}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
value={value}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LearningStateSelectorContentProps {
|
||||||
|
showSearch: boolean
|
||||||
|
searchPlaceholder: string
|
||||||
|
value?: string
|
||||||
|
onSelect: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LearningStateSelectorContent: React.FC<LearningStateSelectorContentProps> = ({
|
||||||
|
showSearch,
|
||||||
|
searchPlaceholder,
|
||||||
|
value,
|
||||||
|
onSelect
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Command>
|
||||||
|
{showSearch && <CommandInput placeholder={searchPlaceholder} className="h-9" />}
|
||||||
|
<CommandList>
|
||||||
|
<ScrollArea>
|
||||||
|
<CommandGroup>
|
||||||
|
{LEARNING_STATES.map(ls => (
|
||||||
|
<CommandItem key={ls.value} value={ls.value} onSelect={onSelect}>
|
||||||
|
<LaIcon name={ls.icon} className={cn("mr-2", ls.className)} />
|
||||||
|
<span className={ls.className}>{ls.label}</span>
|
||||||
|
<LaIcon
|
||||||
|
name="Check"
|
||||||
|
className={cn("absolute right-3", ls.value === value ? "text-primary" : "text-transparent")}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,212 +1,253 @@
|
|||||||
import { z } from "zod"
|
import React from "react"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { useState } from "react"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
import { cn, generateUniqueSlug } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { atomWithStorage } from "jotai/utils"
|
import { atomWithStorage } from "jotai/utils"
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
||||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
|
|
||||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { LaIcon } from "../../la-icon"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { icons } from "lucide-react"
|
||||||
|
|
||||||
const pageSortAtom = atomWithStorage("pageSort", "title")
|
type SortOption = "title" | "recent"
|
||||||
const createPageSchema = z.object({
|
type ShowOption = 5 | 10 | 15 | 20 | 0
|
||||||
title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" })
|
|
||||||
})
|
|
||||||
|
|
||||||
type PageFormValues = z.infer<typeof createPageSchema>
|
interface Option<T> {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTS: Option<SortOption>[] = [
|
||||||
|
{ label: "Title", value: "title" },
|
||||||
|
{ label: "Last edited", value: "recent" }
|
||||||
|
]
|
||||||
|
|
||||||
|
const SHOWS: Option<ShowOption>[] = [
|
||||||
|
{ label: "5 items", value: 5 },
|
||||||
|
{ label: "10 items", value: 10 },
|
||||||
|
{ label: "15 items", value: 15 },
|
||||||
|
{ label: "20 items", value: 20 },
|
||||||
|
{ label: "All", value: 0 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const pageSortAtom = atomWithStorage<SortOption>("pageSort", "title")
|
||||||
|
const pageShowAtom = atomWithStorage<ShowOption>("pageShow", 5)
|
||||||
|
|
||||||
export const PageSection: React.FC = () => {
|
export const PageSection: React.FC = () => {
|
||||||
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
|
const { me } = useAccount({ root: { personalPages: [] } })
|
||||||
|
|
||||||
const { me } = useAccount({
|
|
||||||
root: { personalPages: [] }
|
|
||||||
})
|
|
||||||
|
|
||||||
const pageCount = me?.root.personalPages?.length || 0
|
const pageCount = me?.root.personalPages?.length || 0
|
||||||
|
|
||||||
const sortedPages = (filter: string) => {
|
|
||||||
setPagesSorted(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-px py-2">
|
<div className="group/pages flex flex-col gap-px py-2">
|
||||||
<div className="hover:bg-accent group/pages flex items-center gap-px rounded-md">
|
<PageSectionHeader pageCount={pageCount} />
|
||||||
<Button
|
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} />}
|
||||||
variant="ghost"
|
|
||||||
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus:outline-0 focus:ring-0"
|
|
||||||
>
|
|
||||||
<p className="flex items-center text-xs font-medium">
|
|
||||||
Pages <span className="text-muted-foreground ml-1">{pageCount}</span>
|
|
||||||
</p>
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/pages:opacity-100">
|
|
||||||
<ShowAllForm filteredPages={sortedPages} />
|
|
||||||
<CreatePageForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} sortBy={pagesSorted} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageList: React.FC<{ personalPages: PersonalPageLists; sortBy: string }> = ({ personalPages, sortBy }) => {
|
interface PageSectionHeaderProps {
|
||||||
const pathname = usePathname()
|
pageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
const sortedPages = [...personalPages]
|
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
|
||||||
.sort((a, b) => {
|
<div
|
||||||
if (sortBy === "title") {
|
className={cn("flex min-h-[30px] items-center gap-px rounded-md", "hover:bg-accent hover:text-accent-foreground")}
|
||||||
return (a?.title || "").localeCompare(b?.title || "")
|
>
|
||||||
} else if (sortBy === "latest") {
|
<Button
|
||||||
return ((b as any)?.createdAt?.getTime?.() ?? 0) - ((a as any)?.createdAt?.getTime?.() ?? 0)
|
variant="ghost"
|
||||||
}
|
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||||
return 0
|
>
|
||||||
})
|
<p className="flex items-center text-xs font-medium">
|
||||||
.slice(0, 6)
|
Pages
|
||||||
|
{pageCount && <span className="text-muted-foreground ml-1">{pageCount}</span>}
|
||||||
return (
|
</p>
|
||||||
<div className="flex flex-col gap-1">
|
</Button>
|
||||||
{sortedPages.map(
|
<div className={cn("flex items-center gap-px pr-2")}>
|
||||||
page =>
|
<ShowAllForm />
|
||||||
page?.id && (
|
<NewPageButton />
|
||||||
<div key={page.id} className="group/reorder-page relative">
|
|
||||||
<div className="group/sidebar-link relative flex min-w-0 flex-1">
|
|
||||||
<Link
|
|
||||||
href={`/pages/${page.id}`}
|
|
||||||
className={cn(
|
|
||||||
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
|
|
||||||
{ "bg-accent text-accent-foreground": pathname === `/pages/${page.id}` }
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
|
||||||
<LaIcon name="FileText" className="size-3 flex-shrink-0 opacity-60" />
|
|
||||||
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100">{page.title}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
|
||||||
interface ShowAllFormProps {
|
const NewPageButton: React.FC = () => {
|
||||||
filteredPages: (filter: string) => void
|
|
||||||
}
|
|
||||||
const ShowAllForm: React.FC<ShowAllFormProps> = ({ filteredPages }) => {
|
|
||||||
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
|
|
||||||
|
|
||||||
const handleSort = (newSort: string) => {
|
|
||||||
setPagesSorted(newSort.toLowerCase())
|
|
||||||
filteredPages(newSort.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs font-medium">
|
|
||||||
<LaIcon name="Ellipsis" className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-[100px]">
|
|
||||||
<DropdownMenuItem onClick={() => handleSort("title")}>
|
|
||||||
Title
|
|
||||||
{pagesSorted === "title" && <LaIcon name="Check" className="ml-auto h-4 w-4" />}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleSort("manual")}>
|
|
||||||
Manual
|
|
||||||
{pagesSorted === "manual" && <LaIcon name="Check" className="ml-auto h-4 w-4" />}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreatePageForm: React.FC = () => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const { me } = useAccount()
|
const { me } = useAccount()
|
||||||
const route = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const form = useForm<PageFormValues>({
|
const handleClick = () => {
|
||||||
resolver: zodResolver(createPageSchema),
|
|
||||||
defaultValues: {
|
|
||||||
title: ""
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = (values: PageFormValues) => {
|
|
||||||
try {
|
try {
|
||||||
const personalPages = me?.root?.personalPages?.toJSON() || []
|
|
||||||
const slug = generateUniqueSlug(personalPages, values.title)
|
|
||||||
|
|
||||||
const newPersonalPage = PersonalPage.create(
|
const newPersonalPage = PersonalPage.create(
|
||||||
{
|
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||||
title: values.title,
|
|
||||||
slug: slug,
|
|
||||||
content: ""
|
|
||||||
},
|
|
||||||
{ owner: me._owner }
|
{ owner: me._owner }
|
||||||
)
|
)
|
||||||
|
|
||||||
me.root?.personalPages?.push(newPersonalPage)
|
me.root?.personalPages?.push(newPersonalPage)
|
||||||
|
router.push(`/pages/${newPersonalPage.id}`)
|
||||||
form.reset()
|
|
||||||
setOpen(false)
|
|
||||||
|
|
||||||
route.push(`/pages/${newPersonalPage.id}`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
|
||||||
toast.error("Failed to create page")
|
toast.error("Failed to create page")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Button
|
||||||
<PopoverTrigger asChild>
|
type="button"
|
||||||
<button
|
variant="ghost"
|
||||||
type="button"
|
aria-label="New Page"
|
||||||
aria-label="New Page"
|
className={cn(
|
||||||
className={cn(
|
"flex size-5 items-center justify-center p-0.5 shadow-none focus-visible:outline-none focus-visible:ring-0",
|
||||||
"flex size-6 cursor-pointer items-center justify-center rounded-lg bg-inherit p-0.5 shadow-none focus:outline-0 focus:ring-0",
|
"hover:bg-accent-foreground/10",
|
||||||
'opacity-0 transition-opacity duration-200 group-hover/pages:opacity-100 data-[state="open"]:opacity-100'
|
"opacity-0 transition-opacity duration-200",
|
||||||
)}
|
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100 data-[state='open']:opacity-100"
|
||||||
>
|
)}
|
||||||
<LaIcon name="Plus" className="text-black dark:text-white" />
|
onClick={handleClick}
|
||||||
</button>
|
>
|
||||||
</PopoverTrigger>
|
<LaIcon name="Plus" />
|
||||||
<PopoverContent align="start">
|
</Button>
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="title"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>New page</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Enter a title" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" size="sm" className="w-full">
|
|
||||||
Create page
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PageListProps {
|
||||||
|
personalPages: PersonalPageLists
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageList: React.FC<PageListProps> = ({ personalPages }) => {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const [sortCriteria] = useAtom(pageSortAtom)
|
||||||
|
const [showCount] = useAtom(pageShowAtom)
|
||||||
|
|
||||||
|
const sortedPages = [...personalPages]
|
||||||
|
.sort((a, b) => {
|
||||||
|
switch (sortCriteria) {
|
||||||
|
case "title":
|
||||||
|
return (a?.title ?? "").localeCompare(b?.title ?? "")
|
||||||
|
case "recent":
|
||||||
|
return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.slice(0, showCount === 0 ? personalPages.length : showCount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
{sortedPages.map(
|
||||||
|
page => page?.id && <PageListItem key={page.id} page={page} isActive={pathname === `/pages/${page.id}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageListItemProps {
|
||||||
|
page: PersonalPage
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageListItem: React.FC<PageListItemProps> = ({ page, isActive }) => (
|
||||||
|
<div className="group/reorder-page relative">
|
||||||
|
<div className="group/sidebar-link relative flex min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/pages/${page.id}`}
|
||||||
|
className={cn(
|
||||||
|
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
|
||||||
|
{ "bg-accent text-accent-foreground": isActive }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||||
|
<LaIcon name="FileText" className="flex-shrink-0 opacity-60" />
|
||||||
|
<p className="truncate opacity-95 group-hover/sidebar-link:opacity-100">{page.title || "Untitled"}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SubMenuProps<T> {
|
||||||
|
icon: keyof typeof icons
|
||||||
|
label: string
|
||||||
|
options: Option<T>[]
|
||||||
|
currentValue: T
|
||||||
|
onSelect: (value: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubMenu = <T extends string | number>({ icon, label, options, currentValue, onSelect }: SubMenuProps<T>) => (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LaIcon name={icon} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{options.find(option => option.value === currentValue)?.label}
|
||||||
|
</span>
|
||||||
|
<LaIcon name="ChevronRight" />
|
||||||
|
</span>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{options.map(option => (
|
||||||
|
<DropdownMenuItem key={option.value} onClick={() => onSelect(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
{currentValue === option.value && <LaIcon name="Check" className="ml-auto" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ShowAllForm: React.FC = () => {
|
||||||
|
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
|
||||||
|
const [pagesShow, setPagesShow] = useAtom(pageShowAtom)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center p-0.5 shadow-none focus-visible:outline-none focus-visible:ring-0",
|
||||||
|
"hover:bg-accent-foreground/10",
|
||||||
|
"opacity-0 transition-opacity duration-200",
|
||||||
|
"group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100 data-[state='open']:opacity-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LaIcon name="Ellipsis" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<SubMenu<SortOption>
|
||||||
|
icon="ArrowUpDown"
|
||||||
|
label="Sort"
|
||||||
|
options={SORTS}
|
||||||
|
currentValue={pagesSorted}
|
||||||
|
onSelect={setPagesSorted}
|
||||||
|
/>
|
||||||
|
<SubMenu<ShowOption>
|
||||||
|
icon="Hash"
|
||||||
|
label="Show"
|
||||||
|
options={SHOWS}
|
||||||
|
currentValue={pagesShow}
|
||||||
|
onSelect={setPagesShow}
|
||||||
|
/>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageSection
|
||||||
|
|||||||
@@ -1,66 +1,136 @@
|
|||||||
import { useState, useRef } from "react"
|
import React from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { SidebarItem } from "../sidebar"
|
import { ListOfTopics } from "@/lib/schema"
|
||||||
|
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||||
|
|
||||||
// const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"]
|
export const TopicSection: React.FC = () => {
|
||||||
|
const { me } = useAccount({
|
||||||
export const TopicSection = () => {
|
root: {
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
topicsWantToLearn: [],
|
||||||
const sectionRef = useRef<HTMLDivElement>(null)
|
topicsLearning: [],
|
||||||
|
topicsLearned: []
|
||||||
const learningOptions = [
|
|
||||||
{
|
|
||||||
text: "To Learn",
|
|
||||||
icon: <LaIcon name="NotebookPen" className="size-3 flex-shrink-0" />,
|
|
||||||
color: "text-black dark:text-white"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Learning",
|
|
||||||
icon: <LaIcon name="GraduationCap" className="size-4 flex-shrink-0" />,
|
|
||||||
color: "text-[#D29752]"
|
|
||||||
},
|
|
||||||
{ text: "Learned", icon: <LaIcon name="Check" className="size-4 flex-shrink-0" />, color: "text-[#708F51]" }
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusSelect = (status: string) => {
|
|
||||||
setSelectedStatus(prevStatus => (prevStatus === status ? null : status))
|
|
||||||
}
|
|
||||||
|
|
||||||
const topicCounts = {
|
|
||||||
"To Learn": 2,
|
|
||||||
Learning: 5,
|
|
||||||
Learned: 3,
|
|
||||||
get total() {
|
|
||||||
return this["To Learn"] + this.Learning + this.Learned
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
const topicCount =
|
||||||
|
(me?.root.topicsWantToLearn?.length || 0) +
|
||||||
|
(me?.root.topicsLearning?.length || 0) +
|
||||||
|
(me?.root.topicsLearned?.length || 0)
|
||||||
|
|
||||||
|
if (!me) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
|
<div className="group/pages flex flex-col gap-px py-2">
|
||||||
<div className="text-foreground group/topics hover:bg-accent flex w-full items-center justify-between rounded-md px-2 py-2 text-xs font-medium">
|
<TopicSectionHeader topicCount={topicCount} />
|
||||||
<span className="text-black dark:text-white">Topics {topicCounts.total}</span>
|
<List
|
||||||
<button className="opacity-0 transition-opacity duration-200 group-hover/topics:opacity-100">
|
topicsWantToLearn={me.root.topicsWantToLearn}
|
||||||
<LaIcon name="Ellipsis" className="size-4 flex-shrink-0" />
|
topicsLearning={me.root.topicsLearning}
|
||||||
</button>
|
topicsLearned={me.root.topicsLearned}
|
||||||
</div>
|
/>
|
||||||
<div>
|
</div>
|
||||||
{learningOptions.map(option => (
|
)
|
||||||
<Button
|
}
|
||||||
key={option.text}
|
|
||||||
onClick={() => statusSelect(option.text)}
|
interface TopicSectionHeaderProps {
|
||||||
className={`flex w-full items-center justify-between rounded-md py-1 pl-1 text-sm font-medium hover:bg-neutral-100 dark:hover:bg-neutral-100/20 ${option.color} ${
|
topicCount: number
|
||||||
selectedStatus === option.text ? "bg-accent" : "bg-inherit"
|
}
|
||||||
} shadow-none`}
|
|
||||||
>
|
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({ topicCount }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
{option.icon && <span className={option.color}>{option.icon}</span>}
|
className={cn("flex min-h-[30px] items-center gap-px rounded-md", "hover:bg-accent hover:text-accent-foreground")}
|
||||||
<span>{option.text}</span>
|
>
|
||||||
</div>
|
<Button
|
||||||
<span className={`${option.color} mr-2`}>{topicCounts[option.text as keyof typeof topicCounts]}</span>
|
variant="ghost"
|
||||||
</Button>
|
className="size-6 flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||||
))}
|
>
|
||||||
|
<p className="flex items-center text-xs font-medium">
|
||||||
|
Topics
|
||||||
|
{topicCount && <span className="text-muted-foreground ml-1">{topicCount}</span>}
|
||||||
|
</p>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
topicsWantToLearn: ListOfTopics
|
||||||
|
topicsLearning: ListOfTopics
|
||||||
|
topicsLearned: ListOfTopics
|
||||||
|
}
|
||||||
|
|
||||||
|
const List: React.FC<ListProps> = ({ topicsWantToLearn, topicsLearning, topicsLearned }) => {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
<ListItem
|
||||||
|
key={topicsWantToLearn.id}
|
||||||
|
count={topicsWantToLearn.length}
|
||||||
|
label="To Learn"
|
||||||
|
value="wantToLearn"
|
||||||
|
href="/me/wantToLearn"
|
||||||
|
isActive={pathname === "/me/wantToLearn"}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
key={topicsLearning.id}
|
||||||
|
label="Learning"
|
||||||
|
value="learning"
|
||||||
|
count={topicsLearning.length}
|
||||||
|
href="/me/learning"
|
||||||
|
isActive={pathname === "/me/learning"}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
key={topicsLearned.id}
|
||||||
|
label="Learned"
|
||||||
|
value="learned"
|
||||||
|
count={topicsLearned.length}
|
||||||
|
href="/me/learned"
|
||||||
|
isActive={pathname === "/me/learned"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListItemProps {
|
||||||
|
label: string
|
||||||
|
value: LearningStateValue
|
||||||
|
href: string
|
||||||
|
count: number
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListItem: React.FC<ListItemProps> = ({ label, value, href, count, isActive }) => {
|
||||||
|
const le = LEARNING_STATES.find(l => l.value === value)
|
||||||
|
|
||||||
|
if (!le) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/reorder-page relative">
|
||||||
|
<div className="group/topic-link relative flex min-w-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"group-hover/topic-link:bg-accent relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium",
|
||||||
|
{ "bg-accent text-accent-foreground": isActive },
|
||||||
|
le.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||||
|
<LaIcon name={le.icon} className="flex-shrink-0 opacity-60" />
|
||||||
|
<p className={cn("truncate opacity-95 group-hover/topic-link:opacity-100", le.className)}>{label}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="absolute right-2 top-1/2 z-[1] -translate-y-1/2 rounded p-1 text-sm">{count}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default TopicSection
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface SidebarItemProps {
|
|||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarItem: React.FC<SidebarItemProps> = React.memo(({ label, url, icon, onClick, children }) => {
|
const SidebarItem: React.FC<SidebarItemProps> = React.memo(({ label, url, icon, onClick, children }) => {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const isActive = pathname === url
|
const isActive = pathname === url
|
||||||
|
|
||||||
@@ -70,6 +70,8 @@ export const SidebarItem: React.FC<SidebarItemProps> = React.memo(({ label, url,
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
SidebarItem.displayName = "SidebarItem"
|
||||||
|
|
||||||
const LogoAndSearch: React.FC = React.memo(() => {
|
const LogoAndSearch: React.FC = React.memo(() => {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
return (
|
return (
|
||||||
@@ -103,6 +105,8 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
LogoAndSearch.displayName = "LogoAndSearch"
|
||||||
|
|
||||||
const SidebarContent: React.FC = React.memo(() => {
|
const SidebarContent: React.FC = React.memo(() => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -121,7 +125,9 @@ const SidebarContent: React.FC = React.memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Sidebar: React.FC = () => {
|
SidebarContent.displayName = "SidebarContent"
|
||||||
|
|
||||||
|
const Sidebar: React.FC = () => {
|
||||||
const isTablet = useMedia("(max-width: 1024px)")
|
const isTablet = useMedia("(max-width: 1024px)")
|
||||||
const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet)
|
const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet)
|
||||||
|
|
||||||
@@ -175,4 +181,6 @@ export const Sidebar: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Sidebar
|
Sidebar.displayName = "Sidebar"
|
||||||
|
|
||||||
|
export { Sidebar, SidebarItem, SidebarContext }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)];
|
@apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.la-editor .ProseMirror.ProseMirror-focused > p.has-focus.is-empty::before {
|
.la-editor:not(.no-command) .ProseMirror.ProseMirror-focused > p.has-focus.is-empty::before {
|
||||||
content: "Type / for commands...";
|
content: "Type / for commands...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 default function AuthHomeRoute() {
|
|
||||||
const [editId] = useAtom(linkEditIdAtom)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative z-[1] flex h-full flex-auto flex-col overflow-hidden">
|
|
||||||
<LinkHeader />
|
|
||||||
<LinkManage />
|
|
||||||
<LinkList key={editId} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ export default function ForceGraph() {
|
|||||||
|
|
||||||
const graph = useMemo(() => {
|
const graph = useMemo(() => {
|
||||||
return globalGroup?.root.topicGraph?.map(
|
return globalGroup?.root.topicGraph?.map(
|
||||||
topic =>
|
(topic: { name: string; prettyName: string; connectedTopics: Array<{ name?: string }> }) =>
|
||||||
({
|
({
|
||||||
name: topic.name,
|
name: topic.name,
|
||||||
prettyName: topic.prettyName,
|
prettyName: topic.prettyName,
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
28
web/components/routes/link/LinkRoute.tsx
Normal file
28
web/components/routes/link/LinkRoute.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
198
web/components/routes/link/bottom-bar.tsx
Normal file
198
web/components/routes/link/bottom-bar.tsx
Normal 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
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./manage"
|
|
||||||
@@ -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} /> */
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,8 @@ const LearningTab = React.memo(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
LearningTab.displayName = "LearningTab"
|
||||||
|
|
||||||
const FilterAndSort = React.memo(() => {
|
const FilterAndSort = React.memo(() => {
|
||||||
const [sort, setSort] = useAtom(linkSortAtom)
|
const [sort, setSort] = useAtom(linkSortAtom)
|
||||||
const [sortOpen, setSortOpen] = React.useState(false)
|
const [sortOpen, setSortOpen] = React.useState(false)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,45 +7,54 @@ import {
|
|||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent
|
DragEndEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
UniqueIdentifier
|
||||||
} from "@dnd-kit/core"
|
} from "@dnd-kit/core"
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||||
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 { PersonalLink } from "@/lib/schema/personal-link"
|
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { linkEditIdAtom, linkSortAtom } from "@/store/link"
|
import { linkSortAtom } from "@/store/link"
|
||||||
import { useKey } from "react-use"
|
import { useKey } from "react-use"
|
||||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
import { LinkItem } from "./partials/link-item"
|
||||||
import { ListItem } from "./list-item"
|
import { useRef, useState, useCallback, useEffect, useMemo } from "react"
|
||||||
import { useRef, useState, useCallback, useEffect } from "react"
|
|
||||||
import { learningStateAtom } from "./header"
|
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 [activeLearningState] = useAtom(learningStateAtom)
|
||||||
const confirm = useConfirm()
|
|
||||||
const { me } = useAccount({
|
const { me } = useAccount({
|
||||||
root: { personalLinks: [] }
|
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 [sort] = useAtom(linkSortAtom)
|
||||||
const [focusedId, setFocusedId] = useState<string | null>(null)
|
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 linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({})
|
||||||
const [showDeleteIconForLinkId, setShowDeleteIconForLinkId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
let filteredLinks = personalLinks.filter(link => {
|
const filteredLinks = useMemo(
|
||||||
if (activeLearningState === "all") return true
|
() =>
|
||||||
if (!link?.learningState) return false
|
personalLinks.filter(link => {
|
||||||
return link.learningState === activeLearningState
|
if (activeLearningState === "all") return true
|
||||||
})
|
if (!link?.learningState) return false
|
||||||
let sortedLinks =
|
return link.learningState === activeLearningState
|
||||||
sort === "title" && filteredLinks
|
}),
|
||||||
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
[personalLinks, activeLearningState]
|
||||||
: filteredLinks
|
)
|
||||||
sortedLinks = sortedLinks || []
|
|
||||||
|
const sortedLinks = useMemo(
|
||||||
|
() =>
|
||||||
|
sort === "title"
|
||||||
|
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
||||||
|
: filteredLinks,
|
||||||
|
[filteredLinks, sort]
|
||||||
|
)
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -68,6 +77,14 @@ const LinkList = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateSequences = useCallback((links: PersonalLinkLists) => {
|
||||||
|
links.forEach((link, index) => {
|
||||||
|
if (link) {
|
||||||
|
link.sequence = index
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
|
if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
|
||||||
@@ -120,21 +137,16 @@ const LinkList = () => {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
return () => window.removeEventListener("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) => {
|
const handleDragStart = useCallback(
|
||||||
links.forEach((link, index) => {
|
(event: DragStartEvent) => {
|
||||||
if (link) {
|
if (sort !== "manual") return
|
||||||
link.sequence = index
|
const { active } = event
|
||||||
}
|
setDraggingId(active.id)
|
||||||
})
|
},
|
||||||
}
|
[sort]
|
||||||
|
)
|
||||||
const handleDragStart = (event: any) => {
|
|
||||||
if (sort !== "manual") return
|
|
||||||
const { active } = event
|
|
||||||
setDraggingId(active.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
@@ -181,20 +193,8 @@ const LinkList = () => {
|
|||||||
setDraggingId(null)
|
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 (
|
return (
|
||||||
<div className="relative z-20">
|
<div className="mb-14 flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -206,9 +206,8 @@ const LinkList = () => {
|
|||||||
{sortedLinks.map(
|
{sortedLinks.map(
|
||||||
linkItem =>
|
linkItem =>
|
||||||
linkItem && (
|
linkItem && (
|
||||||
<ListItem
|
<LinkItem
|
||||||
key={linkItem.id}
|
key={linkItem.id}
|
||||||
confirm={confirm}
|
|
||||||
isEditing={editId === linkItem.id}
|
isEditing={editId === linkItem.id}
|
||||||
setEditId={setEditId}
|
setEditId={setEditId}
|
||||||
personalLink={linkItem}
|
personalLink={linkItem}
|
||||||
@@ -217,9 +216,6 @@ const LinkList = () => {
|
|||||||
isDragging={draggingId === linkItem.id}
|
isDragging={draggingId === linkItem.id}
|
||||||
isFocused={focusedId === linkItem.id}
|
isFocused={focusedId === linkItem.id}
|
||||||
setFocusedId={setFocusedId}
|
setFocusedId={setFocusedId}
|
||||||
onDelete={handleDelete}
|
|
||||||
showDeleteIconForLinkId={showDeleteIconForLinkId}
|
|
||||||
setShowDeleteIconForLinkId={setShowDeleteIconForLinkId}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
38
web/components/routes/link/manage.tsx
Normal file
38
web/components/routes/link/manage.tsx
Normal 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 }
|
||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
|
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
|
||||||
import { TextareaAutosize } from "@/components/custom/textarea-autosize"
|
import { TextareaAutosize } from "@/components/custom/textarea-autosize"
|
||||||
import { LinkFormValues } from "../schema"
|
import { LinkFormValues } from "./schema"
|
||||||
|
|
||||||
interface DescriptionInputProps {}
|
interface DescriptionInputProps {}
|
||||||
|
|
||||||
@@ -8,17 +8,19 @@ import { createLinkSchema, LinkFormValues } from "./schema"
|
|||||||
import { cn, generateUniqueSlug } from "@/lib/utils"
|
import { cn, generateUniqueSlug } from "@/lib/utils"
|
||||||
import { Form } from "@/components/ui/form"
|
import { Form } from "@/components/ui/form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UrlInput } from "./partial/url-input"
|
import { UrlInput } from "./url-input"
|
||||||
import { UrlBadge } from "./partial/url-badge"
|
import { UrlBadge } from "./url-badge"
|
||||||
import { TitleInput } from "./partial/title-input"
|
import { TitleInput } from "./title-input"
|
||||||
import { NotesSection } from "./partial/notes-section"
|
import { NotesSection } from "./notes-section"
|
||||||
import { TopicSelector } from "./partial/topic-selector"
|
import { TopicSelector } from "./topic-selector"
|
||||||
import { DescriptionInput } from "./partial/description-input"
|
import { DescriptionInput } from "./description-input"
|
||||||
import { LearningStateSelector } from "./partial/learning-state-selector"
|
|
||||||
import { atom, useAtom } from "jotai"
|
import { atom, useAtom } from "jotai"
|
||||||
import { linkLearningStateSelectorAtom, linkTopicSelectorAtom } from "@/store/link"
|
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>[]>([])
|
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
|
||||||
|
|
||||||
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
|
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
@@ -34,7 +36,7 @@ const defaultValues: Partial<LinkFormValues> = {
|
|||||||
description: "",
|
description: "",
|
||||||
completed: false,
|
completed: false,
|
||||||
notes: "",
|
notes: "",
|
||||||
learningState: "wantToLearn",
|
learningState: undefined,
|
||||||
topic: null
|
topic: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
exceptionsRefs = []
|
exceptionsRefs = []
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedTopic, setSelectedTopic] = React.useState<Topic | null>(null)
|
const [selectedTopic, setSelectedTopic] = React.useState<Topic | undefined>()
|
||||||
const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
|
const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom)
|
||||||
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
||||||
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
|
const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom)
|
||||||
@@ -134,12 +136,19 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
|
|
||||||
const onSubmit = (values: LinkFormValues) => {
|
const onSubmit = (values: LinkFormValues) => {
|
||||||
if (isFetching) return
|
if (isFetching) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const personalLinks = me.root?.personalLinks?.toJSON() || []
|
const personalLinks = me.root?.personalLinks?.toJSON() || []
|
||||||
const slug = generateUniqueSlug(personalLinks, values.title)
|
const slug = generateUniqueSlug(personalLinks, values.title)
|
||||||
|
|
||||||
if (selectedLink) {
|
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 {
|
} else {
|
||||||
const newPersonalLink = PersonalLink.create(
|
const newPersonalLink = PersonalLink.create(
|
||||||
{
|
{
|
||||||
@@ -188,7 +197,23 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
{urlFetched && <TitleInput urlFetched={urlFetched} />}
|
{urlFetched && <TitleInput urlFetched={urlFetched} />}
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<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)} />
|
<TopicSelector onSelect={topic => setSelectedTopic(topic)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { LinkFormValues } from "../schema"
|
import { LinkFormValues } from "./schema"
|
||||||
|
|
||||||
export const NotesSection: React.FC = () => {
|
export const NotesSection: React.FC = () => {
|
||||||
const form = useFormContext<LinkFormValues>()
|
const form = useFormContext<LinkFormValues>()
|
||||||
@@ -18,7 +18,7 @@ export const NotesSection: React.FC = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<>
|
<>
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@@ -8,7 +8,7 @@ export const createLinkSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
completed: z.boolean().default(false),
|
completed: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
learningState: z.enum(["wantToLearn", "learning", "learned"]),
|
learningState: z.enum(["wantToLearn", "learning", "learned"]).optional(),
|
||||||
topic: z.string().nullable().optional()
|
topic: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
|
import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { LinkFormValues } from "../schema"
|
import { LinkFormValues } from "./schema"
|
||||||
|
|
||||||
interface TitleInputProps {
|
interface TitleInputProps {
|
||||||
urlFetched: string | null
|
urlFetched: string | null
|
||||||
@@ -7,7 +7,7 @@ import { useFormContext } from "react-hook-form"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { linkTopicSelectorAtom } from "@/store/link"
|
import { linkTopicSelectorAtom } from "@/store/link"
|
||||||
import { LinkFormValues } from "../schema"
|
import { LinkFormValues } from "./schema"
|
||||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||||
import { ID } from "jazz-tools"
|
import { ID } from "jazz-tools"
|
||||||
@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { LinkFormValues } from "../schema"
|
import { LinkFormValues } from "./schema"
|
||||||
|
|
||||||
interface UrlBadgeProps {
|
interface UrlBadgeProps {
|
||||||
urlFetched: string | null
|
urlFetched: string | null
|
||||||
@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form"
|
|||||||
import { FormField, FormItem, FormControl, FormLabel, FormMessage } from "@/components/ui/form"
|
import { FormField, FormItem, FormControl, FormLabel, FormMessage } from "@/components/ui/form"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LinkFormValues } from "../schema"
|
import { LinkFormValues } from "./schema"
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||||
import { TooltipArrow } from "@radix-ui/react-tooltip"
|
import { TooltipArrow } from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
182
web/components/routes/link/partials/link-item.tsx
Normal file
182
web/components/routes/link/partials/link-item.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,21 +10,19 @@ import { Content, EditorContent, useEditor } from "@tiptap/react"
|
|||||||
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
||||||
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
|
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
|
||||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { EditorView } from "@tiptap/pm/view"
|
import { EditorView } from "@tiptap/pm/view"
|
||||||
import { Editor } from "@tiptap/core"
|
import { Editor } from "@tiptap/core"
|
||||||
import { generateUniqueSlug } from "@/lib/utils"
|
import { generateUniqueSlug } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { pageTopicSelectorAtom } from "@/store/page"
|
import { pageTopicSelectorAtom } from "@/store/page"
|
||||||
import { TopicSelector } from "@/components/routes/link/form/partial/topic-selector"
|
import { TopicSelector } from "@/components/routes/link/partials/form/topic-selector"
|
||||||
|
import { FocusClasses } from "@tiptap/extension-focus"
|
||||||
import DeletePageModal from "@/components/custom/delete-modal"
|
import DeletePageModal from "@/components/custom/delete-modal"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
|
||||||
|
|
||||||
const TITLE_PLACEHOLDER = "Page title"
|
const TITLE_PLACEHOLDER = "Untitled"
|
||||||
|
|
||||||
export function DetailPageWrapper({ pageId }: { pageId: string }) {
|
export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||||
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
||||||
|
|
||||||
if (!page) return <div>Loading...</div>
|
if (!page) return <div>Loading...</div>
|
||||||
@@ -45,32 +43,52 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
const titleEditorRef = useRef<Editor | null>(null)
|
const titleEditorRef = useRef<Editor | null>(null)
|
||||||
const contentEditorRef = useRef<LAEditorRef>(null)
|
const contentEditorRef = useRef<LAEditorRef>(null)
|
||||||
const [, setTopicSelectorOpen] = useAtom(pageTopicSelectorAtom)
|
const [, setTopicSelectorOpen] = useAtom(pageTopicSelectorAtom)
|
||||||
const [selectedPageTopic, setSelectedPageTopic] = useState<Topic | null>(page.topic || null)
|
const [, setSelectedPageTopic] = useState<Topic | null>(page.topic || null)
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const isTitleInitialMount = useRef(true)
|
||||||
|
const isContentInitialMount = useRef(true)
|
||||||
|
|
||||||
const updatePageContent = (content: Content, model: PersonalPage) => {
|
const updatePageContent = (content: Content, model: PersonalPage) => {
|
||||||
model.content = content
|
if (isContentInitialMount.current) {
|
||||||
}
|
isContentInitialMount.current = false
|
||||||
|
|
||||||
const handleTitleBlur = (editor: Editor) => {
|
|
||||||
const newTitle = editor.getText().trim()
|
|
||||||
|
|
||||||
if (!newTitle) {
|
|
||||||
toast.error("Update failed", {
|
|
||||||
description: "Title must be longer than or equal to 1 character"
|
|
||||||
})
|
|
||||||
editor.commands.setContent(page.title)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTitle === page.title) return
|
console.log("Updating page content")
|
||||||
|
model.content = content
|
||||||
|
model.updatedAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateTitle = (editor: Editor) => {
|
||||||
|
if (isTitleInitialMount.current) {
|
||||||
|
isTitleInitialMount.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The logic changed, but we keep this commented code for reference
|
||||||
|
*/
|
||||||
|
// const newTitle = editor.getText().trim()
|
||||||
|
|
||||||
|
// if (!newTitle) {
|
||||||
|
// toast.error("Update failed", {
|
||||||
|
// description: "Title must be longer than or equal to 1 character"
|
||||||
|
// })
|
||||||
|
// editor.commands.setContent(page.title || "")
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (newTitle === page.title) return
|
||||||
|
|
||||||
|
console.log("Updating page title")
|
||||||
const personalPages = me.root?.personalPages?.toJSON() || []
|
const personalPages = me.root?.personalPages?.toJSON() || []
|
||||||
const slug = generateUniqueSlug(personalPages, page.slug)
|
const slug = generateUniqueSlug(personalPages, page.slug || "")
|
||||||
|
|
||||||
const trimmedTitle = editor.getText().trim()
|
const trimmedTitle = editor.getText().trim()
|
||||||
page.title = trimmedTitle
|
page.title = trimmedTitle
|
||||||
page.slug = slug
|
page.slug = slug
|
||||||
|
page.updatedAt = new Date()
|
||||||
|
|
||||||
editor.commands.setContent(trimmedTitle)
|
editor.commands.setContent(trimmedTitle)
|
||||||
}
|
}
|
||||||
@@ -108,7 +126,9 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
|
autofocus: true,
|
||||||
extensions: [
|
extensions: [
|
||||||
|
FocusClasses,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
bold: false,
|
bold: false,
|
||||||
@@ -137,10 +157,12 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
handleKeyDown: handleTitleKeyDown
|
handleKeyDown: handleTitleKeyDown
|
||||||
},
|
},
|
||||||
onCreate: ({ editor }) => {
|
onCreate: ({ editor }) => {
|
||||||
const capitalizedTitle = page.title.charAt(0).toUpperCase() + page.title.slice(1)
|
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
|
||||||
editor.commands.setContent(`<p>${capitalizedTitle}</p>`)
|
|
||||||
},
|
},
|
||||||
onBlur: ({ editor }) => handleTitleBlur(editor)
|
onBlur: ({ editor }) => handleUpdateTitle(editor),
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
handleUpdateTitle(editor)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,6 +171,11 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
}
|
}
|
||||||
}, [titleEditor])
|
}, [titleEditor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isTitleInitialMount.current = true
|
||||||
|
isContentInitialMount.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
|
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
|
||||||
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
|
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
|
||||||
@@ -156,7 +183,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
<div className="mb-2 mt-8 flex flex-row justify-between py-1.5">
|
<div className="mb-2 mt-8 flex flex-row justify-between py-1.5">
|
||||||
<EditorContent
|
<EditorContent
|
||||||
editor={titleEditor}
|
editor={titleEditor}
|
||||||
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
className="la-editor no-command grow cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||||
/>
|
/>
|
||||||
<div className="items-center space-x-4">
|
<div className="items-center space-x-4">
|
||||||
<TopicSelector
|
<TopicSelector
|
||||||
@@ -201,7 +228,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
confirmDelete(page)
|
confirmDelete(page)
|
||||||
}}
|
}}
|
||||||
title={page.title.charAt(0).toUpperCase() + page.title.slice(1)}
|
title={page.title || ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -2,20 +2,11 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||||
import {
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@/components/ui/breadcrumb"
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator
|
|
||||||
} from "@/components/ui/breadcrumb"
|
|
||||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
|
||||||
import { PersonalPage } from "@/lib/schema/personal-page"
|
import { PersonalPage } from "@/lib/schema/personal-page"
|
||||||
import { ID } from "jazz-tools"
|
import { ID } from "jazz-tools"
|
||||||
|
|
||||||
export const DetailPageHeader = ({ pageId }: { pageId: ID<PersonalPage> }) => {
|
export const DetailPageHeader = ({ pageId }: { pageId: ID<PersonalPage> }) => {
|
||||||
const page = useCoState(PersonalPage, pageId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentHeader>
|
<ContentHeader>
|
||||||
<div className="flex min-w-0 gap-2">
|
<div className="flex min-w-0 gap-2">
|
||||||
|
|||||||
@@ -1,32 +1,98 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||||
import { Topic } from "@/lib/schema"
|
import { ListOfTopics, Topic } from "@/lib/schema"
|
||||||
|
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||||
|
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
|
import { LearningStateValue } from "@/lib/constants"
|
||||||
|
|
||||||
interface TopicDetailHeaderProps {
|
interface TopicDetailHeaderProps {
|
||||||
topic: Topic
|
topic: Topic
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
|
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
|
||||||
|
const { me } = useAccount({
|
||||||
|
root: {
|
||||||
|
topicsWantToLearn: [],
|
||||||
|
topicsLearning: [],
|
||||||
|
topicsLearned: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let p: {
|
||||||
|
index: number
|
||||||
|
topic?: Topic | null
|
||||||
|
learningState: LearningStateValue
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1
|
||||||
|
if (wantToLearnIndex !== -1) {
|
||||||
|
p = {
|
||||||
|
index: wantToLearnIndex,
|
||||||
|
topic: me?.root.topicsWantToLearn[wantToLearnIndex],
|
||||||
|
learningState: "wantToLearn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1
|
||||||
|
if (learningIndex !== -1) {
|
||||||
|
p = {
|
||||||
|
index: learningIndex,
|
||||||
|
topic: me?.root.topicsLearning[learningIndex],
|
||||||
|
learningState: "learning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1
|
||||||
|
if (learnedIndex !== -1) {
|
||||||
|
p = {
|
||||||
|
index: learnedIndex,
|
||||||
|
topic: me?.root.topicsLearned[learnedIndex],
|
||||||
|
learningState: "learned"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToProfile = (learningState: LearningStateValue) => {
|
||||||
|
const topicLists: Record<LearningStateValue, (ListOfTopics | null) | undefined> = {
|
||||||
|
wantToLearn: me?.root.topicsWantToLearn,
|
||||||
|
learning: me?.root.topicsLearning,
|
||||||
|
learned: me?.root.topicsLearned
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromList = (state: LearningStateValue, index: number) => {
|
||||||
|
topicLists[state]?.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p) {
|
||||||
|
if (learningState === p.learningState) {
|
||||||
|
removeFromList(p.learningState, p.index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeFromList(p.learningState, p.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
topicLists[learningState]?.push(topic)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||||
<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 shrink-0 items-center gap-1.5">
|
<SidebarToggleButton />
|
||||||
<SidebarToggleButton />
|
<div className="flex min-h-0 items-center">
|
||||||
<div className="flex min-h-0 items-center">
|
<span className="truncate text-left text-xl font-bold">{topic.prettyName}</span>
|
||||||
<span className="truncate text-left text-xl font-bold">{topic.prettyName}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-auto"></div>
|
<div className="flex flex-auto"></div>
|
||||||
|
|
||||||
<Button variant="secondary" size="sm" className="gap-x-2 text-sm">
|
<LearningStateSelector
|
||||||
<span>Add to my profile</span>
|
showSearch={false}
|
||||||
</Button>
|
value={p?.learningState || ""}
|
||||||
</ContentHeader>
|
onChange={handleAddToProfile}
|
||||||
</>
|
defaultLabel="Add to my profile"
|
||||||
|
/>
|
||||||
|
</ContentHeader>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,108 +1,41 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import Link from "next/link"
|
|
||||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
|
||||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
|
||||||
import { ID } from "jazz-tools"
|
|
||||||
import { TopicDetailHeader } from "./Header"
|
import { TopicDetailHeader } from "./Header"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { TopicSections } from "./partials/topic-sections"
|
||||||
import { cn, ensureUrlProtocol } from "@/lib/utils"
|
import { useLinkNavigation } from "./use-link-navigation"
|
||||||
import { Section as SectionSchema, Link as LinkSchema } from "@/lib/schema"
|
import { useTopicData } from "@/hooks/use-topic-data"
|
||||||
|
import { atom } from "jotai"
|
||||||
|
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
|
|
||||||
interface TopicDetailRouteProps {
|
interface TopicDetailRouteProps {
|
||||||
topicName: string
|
topicName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||||
|
|
||||||
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
|
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
|
||||||
const topics = useCoState(PublicGlobalGroup, process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>, {
|
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||||
root: {
|
const { topic, allLinks } = useTopicData(topicName)
|
||||||
topics: []
|
const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const topic = topics?.root.topics.find(topic => topic?.name === topicName)
|
if (!topic || !me) {
|
||||||
|
|
||||||
if (!topic) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-auto flex-col">
|
<div className="flex h-full flex-auto flex-col">
|
||||||
<TopicDetailHeader topic={topic} />
|
<TopicDetailHeader topic={topic} />
|
||||||
<div className="flex w-full flex-1 flex-col gap-4 focus-visible:outline-none">
|
<TopicSections
|
||||||
<div tabIndex={-1} className="outline-none">
|
topic={topic}
|
||||||
<div className="flex flex-1 flex-col gap-4" role="listbox" aria-label="Topic sections">
|
sections={topic.latestGlobalGuide?.sections}
|
||||||
{topic.latestGlobalGuide?.sections?.map(
|
activeIndex={activeIndex}
|
||||||
(section, index) => section?.id && <Section key={index} section={section} />
|
setActiveIndex={setActiveIndex}
|
||||||
)}
|
linkRefs={linkRefs}
|
||||||
</div>
|
containerRef={containerRef}
|
||||||
</div>
|
me={me}
|
||||||
</div>
|
personalLinks={me.root.personalLinks}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionProps {
|
|
||||||
section: SectionSchema
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ section }: SectionProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center gap-4 px-4 py-2">
|
|
||||||
<p className="text-foreground text-sm font-medium">{section.title}</p>
|
|
||||||
<div className="border-b-secondary flex-1 border-b"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-px py-2">
|
|
||||||
{section.links?.map((link, index) => link?.url && <LinkItem key={index} link={link} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LinkItemProps {
|
|
||||||
link: LinkSchema
|
|
||||||
}
|
|
||||||
|
|
||||||
function LinkItem({ link }: LinkItemProps) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
tabIndex={0}
|
|
||||||
className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11")}
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<LaIcon name="GraduationCap" className="size-5" />
|
|
||||||
<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">
|
|
||||||
{link.title}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="group flex items-center gap-x-1">
|
|
||||||
<LaIcon
|
|
||||||
name="Link"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="text-muted-foreground group-hover:text-primary size-3 flex-none"
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
href={ensureUrlProtocol(link.url)}
|
|
||||||
passHref
|
|
||||||
prefetch={false}
|
|
||||||
target="_blank"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
className="text-muted-foreground hover:text-primary text-xs"
|
|
||||||
>
|
|
||||||
<span className="xl:truncate">{link.url}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-x-4"></div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
198
web/components/routes/topics/detail/partials/link-item.tsx
Normal file
198
web/components/routes/topics/detail/partials/link-item.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useAtom } from "jotai"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||||
|
|
||||||
|
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
|
||||||
|
import { LaAccount, Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
|
||||||
|
import { openPopoverForIdAtom } from "../TopicDetailRoute"
|
||||||
|
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||||
|
|
||||||
|
interface LinkItemProps {
|
||||||
|
topic: Topic
|
||||||
|
link: LinkSchema
|
||||||
|
isActive: boolean
|
||||||
|
index: number
|
||||||
|
setActiveIndex: (index: number) => void
|
||||||
|
me: {
|
||||||
|
root: {
|
||||||
|
personalLinks: PersonalLinkLists
|
||||||
|
} & UserRoot
|
||||||
|
} & LaAccount
|
||||||
|
personalLinks: PersonalLinkLists
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkItem = React.memo(
|
||||||
|
React.forwardRef<HTMLLIElement, LinkItemProps>(
|
||||||
|
({ topic, link, isActive, index, setActiveIndex, me, personalLinks }, ref) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||||
|
|
||||||
|
const personalLink = useMemo(() => {
|
||||||
|
return personalLinks.find(pl => pl?.link?.id === link.id)
|
||||||
|
}, [personalLinks, link.id])
|
||||||
|
|
||||||
|
const selectedLearningState = useMemo(() => {
|
||||||
|
return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
|
||||||
|
}, [personalLink?.learningState])
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIndex(index)
|
||||||
|
},
|
||||||
|
[index, setActiveIndex]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectLearningState = useCallback(
|
||||||
|
(learningState: LearningStateValue) => {
|
||||||
|
const defaultToast = {
|
||||||
|
duration: 5000,
|
||||||
|
position: "bottom-right" as const,
|
||||||
|
closeButton: true,
|
||||||
|
action: {
|
||||||
|
label: "Go to list",
|
||||||
|
onClick: () => router.push("/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personalLink) {
|
||||||
|
if (personalLink.learningState === learningState) {
|
||||||
|
personalLink.learningState = undefined
|
||||||
|
toast.error("Link learning state removed", defaultToast)
|
||||||
|
} else {
|
||||||
|
personalLink.learningState = learningState
|
||||||
|
toast.success("Link learning state updated", defaultToast)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const slug = generateUniqueSlug(personalLinks.toJSON(), link.title)
|
||||||
|
const newPersonalLink = PersonalLink.create(
|
||||||
|
{
|
||||||
|
url: link.url,
|
||||||
|
title: link.title,
|
||||||
|
slug,
|
||||||
|
link,
|
||||||
|
learningState,
|
||||||
|
sequence: personalLinks.length + 1,
|
||||||
|
completed: false,
|
||||||
|
topic,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{ owner: me }
|
||||||
|
)
|
||||||
|
|
||||||
|
personalLinks.push(newPersonalLink)
|
||||||
|
|
||||||
|
toast.success("Link added.", {
|
||||||
|
...defaultToast,
|
||||||
|
description: `${link.title} has been added to your personal link.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenPopoverForId(null)
|
||||||
|
setIsPopoverOpen(false)
|
||||||
|
},
|
||||||
|
[personalLink, personalLinks, me, link, router, setOpenPopoverForId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePopoverOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
setIsPopoverOpen(open)
|
||||||
|
setOpenPopoverForId(open ? link.id : null)
|
||||||
|
},
|
||||||
|
[link.id, setOpenPopoverForId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn("relative flex h-14 cursor-pointer items-center outline-none xl:h-11", {
|
||||||
|
"bg-muted-foreground/10": isActive,
|
||||||
|
"hover:bg-muted/50": !isActive
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-x-4">
|
||||||
|
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
variant="secondary"
|
||||||
|
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{selectedLearningState?.icon ? (
|
||||||
|
<LaIcon name={selectedLearningState.icon} className={selectedLearningState.className} />
|
||||||
|
) : (
|
||||||
|
<LaIcon name="Circle" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-52 rounded-lg p-0"
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
onCloseAutoFocus={e => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<LearningStateSelectorContent
|
||||||
|
showSearch={false}
|
||||||
|
searchPlaceholder="Search state..."
|
||||||
|
value={personalLink?.learningState}
|
||||||
|
onSelect={(value: string) => handleSelectLearningState(value as LearningStateValue)}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div className="w-full min-w-0 flex-auto">
|
||||||
|
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate",
|
||||||
|
isActive && "font-bold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="group flex items-center gap-x-1">
|
||||||
|
<LaIcon
|
||||||
|
name="Link"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-muted-foreground group-hover:text-primary flex-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={ensureUrlProtocol(link.url)}
|
||||||
|
passHref
|
||||||
|
prefetch={false}
|
||||||
|
target="_blank"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="text-muted-foreground hover:text-primary text-xs"
|
||||||
|
>
|
||||||
|
<span className="xl:truncate">{link.url}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-x-4"></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LinkItem.displayName = "LinkItem"
|
||||||
59
web/components/routes/topics/detail/partials/section.tsx
Normal file
59
web/components/routes/topics/detail/partials/section.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { LinkItem } from "./link-item"
|
||||||
|
import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema"
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
topic: Topic
|
||||||
|
section: SectionSchema
|
||||||
|
activeIndex: number
|
||||||
|
startIndex: number
|
||||||
|
linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
|
||||||
|
setActiveIndex: (index: number) => void
|
||||||
|
me: {
|
||||||
|
root: {
|
||||||
|
personalLinks: PersonalLinkLists
|
||||||
|
} & UserRoot
|
||||||
|
} & LaAccount
|
||||||
|
personalLinks: PersonalLinkLists
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({
|
||||||
|
topic,
|
||||||
|
section,
|
||||||
|
activeIndex,
|
||||||
|
setActiveIndex,
|
||||||
|
startIndex,
|
||||||
|
linkRefs,
|
||||||
|
me,
|
||||||
|
personalLinks
|
||||||
|
}: SectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
|
||||||
|
<p className="text-foreground text-sm font-medium">{section.title}</p>
|
||||||
|
<div className="flex-1 border-b"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-px py-2">
|
||||||
|
{section.links?.map(
|
||||||
|
(link, index) =>
|
||||||
|
link?.url && (
|
||||||
|
<LinkItem
|
||||||
|
key={index}
|
||||||
|
topic={topic}
|
||||||
|
link={link}
|
||||||
|
isActive={activeIndex === startIndex + index}
|
||||||
|
index={startIndex + index}
|
||||||
|
setActiveIndex={setActiveIndex}
|
||||||
|
ref={el => {
|
||||||
|
linkRefs.current[startIndex + index] = el
|
||||||
|
}}
|
||||||
|
me={me}
|
||||||
|
personalLinks={personalLinks}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Section } from "./section"
|
||||||
|
import { LaAccount, ListOfSections, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
|
||||||
|
|
||||||
|
interface TopicSectionsProps {
|
||||||
|
topic: Topic
|
||||||
|
sections: (ListOfSections | null) | undefined
|
||||||
|
activeIndex: number
|
||||||
|
setActiveIndex: (index: number) => void
|
||||||
|
linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>
|
||||||
|
me: {
|
||||||
|
root: {
|
||||||
|
personalLinks: PersonalLinkLists
|
||||||
|
} & UserRoot
|
||||||
|
} & LaAccount
|
||||||
|
personalLinks: PersonalLinkLists
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopicSections({
|
||||||
|
topic,
|
||||||
|
sections,
|
||||||
|
activeIndex,
|
||||||
|
setActiveIndex,
|
||||||
|
linkRefs,
|
||||||
|
containerRef,
|
||||||
|
me,
|
||||||
|
personalLinks
|
||||||
|
}: TopicSectionsProps) {
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
|
<div tabIndex={-1} className="outline-none">
|
||||||
|
<div className="flex flex-1 flex-col gap-4" role="listbox" aria-label="Topic sections">
|
||||||
|
{sections?.map(
|
||||||
|
(section, sectionIndex) =>
|
||||||
|
section?.id && (
|
||||||
|
<Section
|
||||||
|
key={sectionIndex}
|
||||||
|
topic={topic}
|
||||||
|
section={section}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
setActiveIndex={setActiveIndex}
|
||||||
|
startIndex={sections.slice(0, sectionIndex).reduce((acc, s) => acc + (s?.links?.length || 0), 0)}
|
||||||
|
linkRefs={linkRefs}
|
||||||
|
me={me}
|
||||||
|
personalLinks={personalLinks}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
web/components/routes/topics/detail/use-link-navigation.ts
Normal file
61
web/components/routes/topics/detail/use-link-navigation.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
import { Link as LinkSchema } from "@/lib/schema"
|
||||||
|
import { ensureUrlProtocol } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function useLinkNavigation(allLinks: (LinkSchema | null)[]) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const linkRefs = useRef<(HTMLLIElement | null)[]>(allLinks.map(() => null))
|
||||||
|
|
||||||
|
const scrollToLink = useCallback((index: number) => {
|
||||||
|
if (linkRefs.current[index] && containerRef.current) {
|
||||||
|
const linkElement = linkRefs.current[index]
|
||||||
|
const container = containerRef.current
|
||||||
|
|
||||||
|
const linkRect = linkElement?.getBoundingClientRect()
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (linkRect && containerRect) {
|
||||||
|
if (linkRect.bottom > containerRect.bottom) {
|
||||||
|
container.scrollTop += linkRect.bottom - containerRect.bottom
|
||||||
|
} else if (linkRect.top < containerRect.top) {
|
||||||
|
container.scrollTop -= containerRect.top - linkRect.top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
console.log("handleKeyDown")
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIndex(prevIndex => {
|
||||||
|
const newIndex = (prevIndex + 1) % allLinks.length
|
||||||
|
scrollToLink(newIndex)
|
||||||
|
return newIndex
|
||||||
|
})
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIndex(prevIndex => {
|
||||||
|
const newIndex = (prevIndex - 1 + allLinks.length) % allLinks.length
|
||||||
|
scrollToLink(newIndex)
|
||||||
|
return newIndex
|
||||||
|
})
|
||||||
|
} else if (e.key === "Enter" && activeIndex !== -1) {
|
||||||
|
const link = allLinks[activeIndex]
|
||||||
|
if (link) {
|
||||||
|
window.open(ensureUrlProtocol(link.url), "_blank")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeIndex, allLinks, scrollToLink]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [handleKeyDown])
|
||||||
|
|
||||||
|
return { activeIndex, setActiveIndex, containerRef, linkRefs }
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
))
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|||||||
29
web/hooks/use-topic-data.ts
Normal file
29
web/hooks/use-topic-data.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||||
|
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||||
|
import { ID } from "jazz-tools"
|
||||||
|
import { Link } from "@/lib/schema"
|
||||||
|
|
||||||
|
const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>
|
||||||
|
|
||||||
|
export function useTopicData(topicName: string) {
|
||||||
|
const group = useCoState(PublicGlobalGroup, GLOBAL_GROUP_ID, {
|
||||||
|
root: { topics: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// const topic = useCoState(Topic, "co_zS3TH4Lkj5MK9GEehinxhjjNTxB" as ID<Topic>, {})
|
||||||
|
const topic = useMemo(
|
||||||
|
() => group?.root.topics.find(topic => topic?.name === topicName),
|
||||||
|
[group?.root.topics, topicName]
|
||||||
|
)
|
||||||
|
|
||||||
|
const allLinks = useMemo(() => {
|
||||||
|
if (!topic?.latestGlobalGuide?.sections) return []
|
||||||
|
|
||||||
|
return topic.latestGlobalGuide.sections.flatMap(
|
||||||
|
section => section?.links?.filter((link): link is Link => !!link?.url) ?? []
|
||||||
|
)
|
||||||
|
}, [topic?.latestGlobalGuide?.sections])
|
||||||
|
|
||||||
|
return { topic, allLinks }
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { icons } from "lucide-react"
|
|||||||
export type LearningStateValue = "wantToLearn" | "learning" | "learned"
|
export type LearningStateValue = "wantToLearn" | "learning" | "learned"
|
||||||
export type LearningState = {
|
export type LearningState = {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: LearningStateValue
|
||||||
icon: keyof typeof icons
|
icon: keyof typeof icons
|
||||||
className: string
|
className: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export class UserRoot extends CoMap {
|
|||||||
personalLinks = co.ref(PersonalLinkLists)
|
personalLinks = co.ref(PersonalLinkLists)
|
||||||
personalPages = co.ref(PersonalPageLists)
|
personalPages = co.ref(PersonalPageLists)
|
||||||
|
|
||||||
// not implemented yet
|
|
||||||
topicsWantToLearn = co.ref(ListOfTopics)
|
topicsWantToLearn = co.ref(ListOfTopics)
|
||||||
topicsLearning = co.ref(ListOfTopics)
|
topicsLearning = co.ref(ListOfTopics)
|
||||||
topicsLearned = co.ref(ListOfTopics)
|
topicsLearned = co.ref(ListOfTopics)
|
||||||
@@ -53,7 +52,6 @@ export class LaAccount extends Account {
|
|||||||
personalLinks: PersonalLinkLists.create([], { owner: this }),
|
personalLinks: PersonalLinkLists.create([], { owner: this }),
|
||||||
personalPages: PersonalPageLists.create([], { owner: this }),
|
personalPages: PersonalPageLists.create([], { owner: this }),
|
||||||
|
|
||||||
// not implemented yet
|
|
||||||
topicsWantToLearn: ListOfTopics.create([], { owner: this }),
|
topicsWantToLearn: ListOfTopics.create([], { owner: this }),
|
||||||
topicsLearning: ListOfTopics.create([], { owner: this }),
|
topicsLearning: ListOfTopics.create([], { owner: this }),
|
||||||
topicsLearned: ListOfTopics.create([], { owner: this })
|
topicsLearned: ListOfTopics.create([], { owner: this })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { co, CoList, CoMap } from "jazz-tools"
|
import { co, CoList, CoMap } from "jazz-tools"
|
||||||
|
|
||||||
|
// TODO: this should be GlobalLink but it's not because lookup of 100k elements is slow
|
||||||
export class Link extends CoMap {
|
export class Link extends CoMap {
|
||||||
title = co.string
|
title = co.string
|
||||||
url = co.string
|
url = co.string
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { co, CoList, CoMap, Encoders, ID } from "jazz-tools"
|
import { co, CoList, CoMap, Encoders, ID } from "jazz-tools"
|
||||||
import { Topic } from "./master/topic"
|
import { Link, Topic } from "./master/topic"
|
||||||
|
|
||||||
class BaseModel extends CoMap {
|
class BaseModel extends CoMap {
|
||||||
createdAt = co.encoded(Encoders.Date)
|
createdAt = co.encoded(Encoders.Date)
|
||||||
@@ -9,6 +9,7 @@ class BaseModel extends CoMap {
|
|||||||
export class PersonalLink extends BaseModel {
|
export class PersonalLink extends BaseModel {
|
||||||
url = co.string
|
url = co.string
|
||||||
icon = co.optional.string // is an icon URL
|
icon = co.optional.string // is an icon URL
|
||||||
|
link = co.optional.ref(Link)
|
||||||
title = co.string
|
title = co.string
|
||||||
slug = co.string
|
slug = co.string
|
||||||
description = co.optional.string
|
description = co.optional.string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { co, CoList, CoMap } from "jazz-tools"
|
import { co, CoList, CoMap, Encoders } from "jazz-tools"
|
||||||
import { Topic } from "./master/topic"
|
import { Topic } from "./master/topic"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -8,10 +8,13 @@ import { Topic } from "./master/topic"
|
|||||||
* - if public, certain members (can do read/write access accordingly), personal (end to end encrypted, only accessed by user)
|
* - if public, certain members (can do read/write access accordingly), personal (end to end encrypted, only accessed by user)
|
||||||
*/
|
*/
|
||||||
export class PersonalPage extends CoMap {
|
export class PersonalPage extends CoMap {
|
||||||
title = co.string
|
title = co.optional.string
|
||||||
slug = co.string
|
slug = co.optional.string // is used only when `public: true` for sharing, `@user/page-slug`
|
||||||
|
public = co.boolean
|
||||||
content = co.optional.json()
|
content = co.optional.json()
|
||||||
topic = co.optional.ref(Topic)
|
topic = co.optional.ref(Topic)
|
||||||
|
createdAt = co.encoded(Encoders.Date)
|
||||||
|
updatedAt = co.encoded(Encoders.Date)
|
||||||
// backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently
|
// backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export const randomId = () => {
|
|||||||
|
|
||||||
export * from "./urls"
|
export * from "./urls"
|
||||||
export * from "./slug"
|
export * from "./slug"
|
||||||
|
export * from "./keyboard"
|
||||||
|
|||||||
88
web/lib/utils/keyboard.ts
Normal file
88
web/lib/utils/keyboard.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
let isMac: boolean | undefined
|
||||||
|
|
||||||
|
interface Navigator {
|
||||||
|
userAgentData?: {
|
||||||
|
brands: { brand: string; version: string }[]
|
||||||
|
mobile: boolean
|
||||||
|
platform: string
|
||||||
|
getHighEntropyValues: (hints: string[]) => Promise<{
|
||||||
|
platform: string
|
||||||
|
platformVersion: string
|
||||||
|
uaFullVersion: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatform(): string {
|
||||||
|
const nav = navigator as Navigator
|
||||||
|
|
||||||
|
if (nav.userAgentData) {
|
||||||
|
if (nav.userAgentData.platform) {
|
||||||
|
return nav.userAgentData.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.userAgentData.getHighEntropyValues(["platform"]).then(highEntropyValues => {
|
||||||
|
if (highEntropyValues.platform) {
|
||||||
|
return highEntropyValues.platform
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator.platform === "string") {
|
||||||
|
return navigator.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMacOS() {
|
||||||
|
if (isMac === undefined) {
|
||||||
|
isMac = getPlatform().toLowerCase().includes("mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMac
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutKeyResult {
|
||||||
|
symbol: string
|
||||||
|
readable: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutKey(key: string): ShortcutKeyResult {
|
||||||
|
const lowercaseKey = key.toLowerCase()
|
||||||
|
if (lowercaseKey === "mod") {
|
||||||
|
return isMacOS() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" }
|
||||||
|
} else if (lowercaseKey === "alt") {
|
||||||
|
return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
|
||||||
|
} else if (lowercaseKey === "shift") {
|
||||||
|
return { symbol: "⇧", readable: "Shift" }
|
||||||
|
} else if (lowercaseKey === "control") {
|
||||||
|
return { symbol: "⌃", readable: "Control" }
|
||||||
|
} else if (lowercaseKey === "windows" && !isMacOS()) {
|
||||||
|
return { symbol: "Win", readable: "Windows" }
|
||||||
|
} else {
|
||||||
|
return { symbol: key.toUpperCase(), readable: key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
|
||||||
|
return keys.map(key => getShortcutKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] {
|
||||||
|
if (shortcutName === "expandToolbar") {
|
||||||
|
return isMacOS()
|
||||||
|
? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")]
|
||||||
|
: [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string {
|
||||||
|
return shortcutKeys.map(key => key.symbol).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string {
|
||||||
|
return shortcutKeys.map(key => key.readable).join(" + ")
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"@types/node": "^22.5.0",
|
"@types/node": "^22.5.0",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { atomWithStorage } from "jotai/utils"
|
|||||||
|
|
||||||
export const linkSortAtom = atomWithStorage("sort", "manual")
|
export const linkSortAtom = atomWithStorage("sort", "manual")
|
||||||
export const linkShowCreateAtom = atom(false)
|
export const linkShowCreateAtom = atom(false)
|
||||||
export const linkEditIdAtom = atom<string | number | null>(null)
|
export const linkEditIdAtom = atom<string | null>(null)
|
||||||
export const linkLearningStateSelectorAtom = atom(false)
|
export const linkLearningStateSelectorAtom = atom(false)
|
||||||
export const linkTopicSelectorAtom = atom(false)
|
export const linkTopicSelectorAtom = atom(false)
|
||||||
|
export const linkOpenPopoverForIdAtom = atom<string | null>(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user