fix: detail topic (#117)

* feat: keyboard nav

* fix: link update

* feat: reusable learning state

* chore: use new learning state

* feat: add to my profile

* .

* .

* feat: on enter open the link

* fix: lint

* fix: use eslint v8 instead of v9

* fix: add to my profile

* chore: update personal link schema

* chore: update personal page schema

* fix: update detail wrapper

* fix: update page section

* removing option for learning status

* removing option for learning status for topic

* feat: add createdAt and updatedAt for personal Page

* chore: update page section component

* chore: remove chevron from sub menu

* fix: sidebar

* chore: add focus and disable toast

* feat: la editor add execption for no command class

* fix: la editor style and fix page detail

* fix: title

* fix: topic learning state

* chore: add showSearch for learning state

* fix: bunch stuff

* chore: link list and item handle learning state

* chore: set expand to false

* feat: personal link for topic detail

* chore: hook use topic data

* chore: go to list

* fix: link and topic

* feat(utils): new keyboard utils

* feat(store): add linkOpenPopoverForIdAtom for link

* chore: using memo for use topic data

* fix: remove duplicate component

* chore: performance for topic detail lint item

* refactor: remove LinkOptions component

* chore: improve performance for list

* feat: added LinkRoute copmonent

* chore: link manage

* feat: bottom bar

* fix: link

* fix: page wrapper

* fix: import thing

* chore: added a displayname

* refactor: page detail

* refactor: page detail

* fix: add topic to personal link form link

* fix: only show page count if more than zero

* fix: sidebar topic section

---------

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

View File

@@ -1,212 +1,253 @@
import { z } from "zod"
import React from "react"
import { useAtom } from "jotai"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { usePathname, useRouter } from "next/navigation"
import { useAccount } from "@/lib/providers/jazz-provider"
import { cn, generateUniqueSlug } from "@/lib/utils"
import { cn } from "@/lib/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 { zodResolver } from "@hookform/resolvers/zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { LaIcon } from "../../la-icon"
import { LaIcon } from "@/components/custom/la-icon"
import { toast } from "sonner"
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")
const createPageSchema = z.object({
title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" })
})
type SortOption = "title" | "recent"
type ShowOption = 5 | 10 | 15 | 20 | 0
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 = () => {
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
const { me } = useAccount({
root: { personalPages: [] }
})
const { me } = useAccount({ root: { personalPages: [] } })
const pageCount = me?.root.personalPages?.length || 0
const sortedPages = (filter: string) => {
setPagesSorted(filter)
}
return (
<div className="flex flex-col gap-px py-2">
<div className="hover:bg-accent group/pages flex items-center gap-px rounded-md">
<Button
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 className="group/pages flex flex-col gap-px py-2">
<PageSectionHeader pageCount={pageCount} />
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} />}
</div>
)
}
const PageList: React.FC<{ personalPages: PersonalPageLists; sortBy: string }> = ({ personalPages, sortBy }) => {
const pathname = usePathname()
interface PageSectionHeaderProps {
pageCount: number
}
const sortedPages = [...personalPages]
.sort((a, b) => {
if (sortBy === "title") {
return (a?.title || "").localeCompare(b?.title || "")
} else if (sortBy === "latest") {
return ((b as any)?.createdAt?.getTime?.() ?? 0) - ((a as any)?.createdAt?.getTime?.() ?? 0)
}
return 0
})
.slice(0, 6)
return (
<div className="flex flex-col gap-1">
{sortedPages.map(
page =>
page?.id && (
<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>
)
)}
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
<div
className={cn("flex min-h-[30px] items-center gap-px rounded-md", "hover:bg-accent hover:text-accent-foreground")}
>
<Button
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"
>
<p className="flex items-center text-xs font-medium">
Pages
{pageCount && <span className="text-muted-foreground ml-1">{pageCount}</span>}
</p>
</Button>
<div className={cn("flex items-center gap-px pr-2")}>
<ShowAllForm />
<NewPageButton />
</div>
)
}
</div>
)
interface ShowAllFormProps {
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 NewPageButton: React.FC = () => {
const { me } = useAccount()
const route = useRouter()
const router = useRouter()
const form = useForm<PageFormValues>({
resolver: zodResolver(createPageSchema),
defaultValues: {
title: ""
}
})
const onSubmit = (values: PageFormValues) => {
const handleClick = () => {
try {
const personalPages = me?.root?.personalPages?.toJSON() || []
const slug = generateUniqueSlug(personalPages, values.title)
const newPersonalPage = PersonalPage.create(
{
title: values.title,
slug: slug,
content: ""
},
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
form.reset()
setOpen(false)
route.push(`/pages/${newPersonalPage.id}`)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
console.error(error)
toast.error("Failed to create page")
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="New Page"
className={cn(
"flex size-6 cursor-pointer items-center justify-center rounded-lg bg-inherit p-0.5 shadow-none focus:outline-0 focus:ring-0",
'opacity-0 transition-opacity duration-200 group-hover/pages:opacity-100 data-[state="open"]:opacity-100'
)}
>
<LaIcon name="Plus" className="text-black dark:text-white" />
</button>
</PopoverTrigger>
<PopoverContent align="start">
<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>
<Button
type="button"
variant="ghost"
aria-label="New Page"
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"
)}
onClick={handleClick}
>
<LaIcon name="Plus" />
</Button>
)
}
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