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:
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 { 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
|
||||
|
||||
@@ -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 { 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 = () => {
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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
|
||||
export const TopicSection: React.FC = () => {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
topicsWantToLearn: [],
|
||||
topicsLearning: [],
|
||||
topicsLearned: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const topicCount =
|
||||
(me?.root.topicsWantToLearn?.length || 0) +
|
||||
(me?.root.topicsLearning?.length || 0) +
|
||||
(me?.root.topicsLearned?.length || 0)
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
|
||||
<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">
|
||||
<span className="text-black dark:text-white">Topics {topicCounts.total}</span>
|
||||
<button className="opacity-0 transition-opacity duration-200 group-hover/topics:opacity-100">
|
||||
<LaIcon name="Ellipsis" className="size-4 flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{learningOptions.map(option => (
|
||||
<Button
|
||||
key={option.text}
|
||||
onClick={() => statusSelect(option.text)}
|
||||
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} ${
|
||||
selectedStatus === option.text ? "bg-accent" : "bg-inherit"
|
||||
} shadow-none`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon && <span className={option.color}>{option.icon}</span>}
|
||||
<span>{option.text}</span>
|
||||
</div>
|
||||
<span className={`${option.color} mr-2`}>{topicCounts[option.text as keyof typeof topicCounts]}</span>
|
||||
</Button>
|
||||
))}
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<TopicSectionHeader topicCount={topicCount} />
|
||||
<List
|
||||
topicsWantToLearn={me.root.topicsWantToLearn}
|
||||
topicsLearning={me.root.topicsLearning}
|
||||
topicsLearned={me.root.topicsLearned}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TopicSectionHeaderProps {
|
||||
topicCount: number
|
||||
}
|
||||
|
||||
const TopicSectionHeader: React.FC<TopicSectionHeaderProps> = ({ topicCount }) => (
|
||||
<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">
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicSection
|
||||
|
||||
@@ -47,7 +47,7 @@ interface SidebarItemProps {
|
||||
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 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 pathname = usePathname()
|
||||
return (
|
||||
@@ -103,6 +105,8 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
||||
)
|
||||
})
|
||||
|
||||
LogoAndSearch.displayName = "LogoAndSearch"
|
||||
|
||||
const SidebarContent: React.FC = React.memo(() => {
|
||||
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 [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet)
|
||||
|
||||
@@ -175,4 +181,6 @@ export const Sidebar: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
export { Sidebar, SidebarItem, SidebarContext }
|
||||
|
||||
Reference in New Issue
Block a user