mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-17 22:19:47 +02:00
fix: link (#115)
* start * . * seeding connections * . * wip * wip: learning state * wip: notes section * wip: many * topics * chore: update schema * update package * update sidebar * update page section * feat: profile * fix: remove z index * fix: wrong type * add avatar * add avatar * wip * . * store page section key * remove atom page section * fix rerender * fix rerender * fix rerender * fix rerender * fix link * search light/dark mode * bubble menu ui * . * fix: remove unecessary code * chore: mark as old for old schema * chore: adapt new schema * fix: add topic schema but null for now * fix: add icon on personal link * fix: list item * fix: set url fetched when editing * fix: remove image * feat: add icon to link * feat: custom url zod validation * fix: metadata test * chore: update utils * fix: link * fix: url fetcher * . * . * fix: add link, section * chore: seeder * . * . * . * . * fix: change checkbox to learning state * fix: click outside editing form * feat: constant * chore: move to master folder * chore: adapt new schema * chore: cli for new schema * fix: new schema for dev seed * fix: seeding * update package * chore: forcegraph seed * bottombar * if isEdit delete icon * showCreate X button * . * options * chore: implement topic from public global group * chore: update learning state * fix: change implementation for outside click * chore: implement new form param * chore: update env example * feat: link form refs exception * new page button layout, link topic search fixed * chore: enable topic * chore: update seed * profile * chore: move framer motion package from root to web and add nuqs * chore: add LearningStateValue * chore: implement active state * profile * chore: use fancy switch and update const * feat: filter implementation * dropdown menu * . * sidebar topics * topic selected color * feat: topic detail * fix: collapsible page * pages - sorted by, layout, visible mode * . * . * . * topic status sidebar * topic button and count * fix: topic * page delete/topic buttons * search ui * selected topic for page * selected topic status sidebar * removed footer * update package * . --------- Co-authored-by: Nikita <github@nikiv.dev> Co-authored-by: marshennikovaolga <marshennikova@gmail.com> Co-authored-by: Kisuyo <ig.intr3st@gmail.com>
This commit is contained in:
@@ -71,14 +71,14 @@ const AiSearch: React.FC<AiSearchProps> = (props: { searchQuery: string }) => {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col items-center">
|
||||
<div className="w-full rounded-lg bg-inherit p-6 text-white">
|
||||
<div className="mb-6 rounded-lg bg-blue-700 p-4">
|
||||
<div className="w-full rounded-lg bg-inherit p-6 text-black dark:text-white">
|
||||
<div className="mb-6 rounded-lg bg-blue-700 p-4 text-white">
|
||||
<h2 className="text-lg font-medium">✨ This is what I have found:</h2>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#121212] p-4" ref={root_el}></div>
|
||||
<div className="rounded-xl bg-neutral-100 p-4 dark:bg-[#121212]" ref={root_el}></div>
|
||||
</div>
|
||||
<p className="text-md pb-5 font-semibold opacity-50">{error}</p>
|
||||
<button className="text-md rounded-2xl bg-neutral-800 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-700/50 transition-colors hover:bg-neutral-700">
|
||||
<button className="text-md rounded-2xl bg-neutral-300 px-6 py-3 font-semibold text-opacity-50 shadow-inner shadow-neutral-400/50 transition-colors hover:bg-neutral-700 dark:bg-neutral-800 dark:shadow-neutral-700/50">
|
||||
Ask Community
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-20 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:pl-4 max-lg:pr-5",
|
||||
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:pl-4 max-lg:pr-5",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -50,7 +50,7 @@ export const SidebarToggleButton: React.FC = () => {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Menu"
|
||||
className="text-primary/60 z-50"
|
||||
className="text-primary/60"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PanelLeftIcon size={16} />
|
||||
|
||||
37
web/components/custom/delete-modal.tsx
Normal file
37
web/components/custom/delete-modal.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
interface DeleteModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
}
|
||||
|
||||
export default function DeletePageModal({ isOpen, onClose, onConfirm, title }: DeleteModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete "{title}"?</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" className="bg-red-700" onClick={onConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
22
web/components/custom/la-icon.tsx
Normal file
22
web/components/custom/la-icon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
export type IconProps = {
|
||||
name: keyof typeof icons
|
||||
className?: string
|
||||
strokeWidth?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const LaIcon = React.memo(({ name, className, size, strokeWidth, ...props }: IconProps) => {
|
||||
const IconComponent = icons[name]
|
||||
|
||||
if (!IconComponent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <IconComponent className={cn(!size ? "size-4" : size, className)} strokeWidth={strokeWidth || 2} {...props} />
|
||||
})
|
||||
|
||||
LaIcon.displayName = "LaIcon"
|
||||
14
web/components/custom/page-loader.tsx
Normal file
14
web/components/custom/page-loader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export const PageLoader = () => {
|
||||
return (
|
||||
<div className="relative top-[-60px] flex h-full flex-col justify-center">
|
||||
<div className="mx-auto w-full max-w-[220px] py-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-base font-medium">Preparing application</div>
|
||||
<div className="bg-muted relative flex h-1 w-full appearance-none overflow-hidden rounded leading-3">
|
||||
<div className="progress-bar-indeterminate bg-primary flex h-full flex-col justify-center overflow-hidden whitespace-nowrap text-center text-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { SidebarItem } from "../sidebar"
|
||||
import { z } from "zod"
|
||||
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 { Input } from "@/components/ui/input"
|
||||
import { cn, generateUniqueSlug } 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 { PlusIcon } from "lucide-react"
|
||||
import { generateUniqueSlug } from "@/lib/utils"
|
||||
import { PersonalPage } from "@/lib/schema/personal-page"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LaIcon } from "../../la-icon"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
|
||||
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" })
|
||||
})
|
||||
@@ -21,47 +25,114 @@ const createPageSchema = z.object({
|
||||
type PageFormValues = z.infer<typeof createPageSchema>
|
||||
|
||||
export const PageSection: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const [personalPages, setPersonalPages] = useState<PersonalPage[]>([])
|
||||
const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if (me.root?.personalPages) {
|
||||
setPersonalPages(prevPages => {
|
||||
const newPages = Array.from(me.root?.personalPages ?? []).filter((page): page is PersonalPage => page !== null)
|
||||
return [...prevPages, ...newPages.filter(newPage => !prevPages.some(prevPage => prevPage.id === newPage.id))]
|
||||
})
|
||||
}
|
||||
}, [me.root?.personalPages])
|
||||
const { me } = useAccount({
|
||||
root: { personalPages: [] }
|
||||
})
|
||||
|
||||
const onPageCreated = useCallback((newPage: PersonalPage) => {
|
||||
setPersonalPages(prevPages => [...prevPages, newPage])
|
||||
}, [])
|
||||
const pageCount = me?.root.personalPages?.length || 0
|
||||
|
||||
const sortedPages = (filter: string) => {
|
||||
setPagesSorted(filter)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-ml-2">
|
||||
<div className="group mb-0.5 ml-2 mt-2 flex flex-row items-center justify-between rounded-md">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="text-muted-foreground hover:bg-muted/50 flex h-6 grow cursor-default items-center justify-between gap-x-0.5 self-start rounded-md px-1 text-xs font-medium"
|
||||
<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"
|
||||
>
|
||||
<span className="group-hover:text-muted-foreground">Pages</span>
|
||||
<CreatePageForm onPageCreated={onPageCreated} />
|
||||
<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>
|
||||
|
||||
<div className="relative shrink-0">
|
||||
<div aria-hidden="false" className="ml-2 flex shrink-0 flex-col space-y-1 pb-2">
|
||||
{personalPages.map(page => (
|
||||
<SidebarItem key={page.id} url={`/pages/${page.id}`} label={page.title} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{me?.root.personalPages && <PageList personalPages={me.root.personalPages} sortBy={pagesSorted} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }> = ({ onPageCreated }) => {
|
||||
const PageList: React.FC<{ personalPages: PersonalPageLists; sortBy: string }> = ({ personalPages, sortBy }) => {
|
||||
const pathname = usePathname()
|
||||
|
||||
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>
|
||||
)
|
||||
)}
|
||||
</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 { me } = useAccount()
|
||||
const route = useRouter()
|
||||
@@ -88,7 +159,6 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }>
|
||||
)
|
||||
|
||||
me.root?.personalPages?.push(newPersonalPage)
|
||||
onPageCreated(newPersonalPage)
|
||||
|
||||
form.reset()
|
||||
setOpen(false)
|
||||
@@ -103,9 +173,16 @@ const CreatePageForm: React.FC<{ onPageCreated: (page: PersonalPage) => void }>
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" size="icon" variant="ghost" aria-label="New Page" className="size-6">
|
||||
<PlusIcon size={16} />
|
||||
</Button>
|
||||
<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}>
|
||||
|
||||
108
web/components/custom/sidebar/partial/profile-section.tsx
Normal file
108
web/components/custom/sidebar/partial/profile-section.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { LaIcon } from "../../la-icon"
|
||||
import { useState } from "react"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import Link from "next/link"
|
||||
|
||||
const MenuItem = ({
|
||||
icon,
|
||||
text,
|
||||
href,
|
||||
onClick,
|
||||
onClose
|
||||
}: {
|
||||
icon: string
|
||||
text: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
onClose()
|
||||
if (onClick) {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 items-center gap-2">
|
||||
<LaIcon name={icon as any} />
|
||||
{href ? (
|
||||
<Link href={href} onClick={onClose}>
|
||||
<span className="line-clamp-1 flex-1">{text}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="line-clamp-1 flex-1" onClick={handleClick}>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { me, logOut } = useAccount({
|
||||
profile: true
|
||||
})
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const closeMenu = () => setMenuOpen(false)
|
||||
|
||||
return (
|
||||
<div className="visible absolute inset-x-0 bottom-0 z-10 flex gap-8 p-2.5">
|
||||
<div className="flex h-10 min-w-full items-center">
|
||||
<div className="flex min-w-0">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex items-center gap-1.5 truncate rounded pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
{/* <AvatarFallback>CN</AvatarFallback> */}
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">{me?.profile?.name}</span>
|
||||
<LaIcon
|
||||
name="ChevronDown"
|
||||
className={`size-4 shrink-0 transition-transform duration-300 ${menuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="CircleUser" text="My profile" href="/profile" onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="Settings" text="Settings" href="/settings" onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<MenuItem icon="LogOut" text="Log out" onClick={logOut} onClose={closeMenu} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/* <div className="flex min-w-2 grow flex-row"></div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Button size="icon" variant="ghost" aria-label="Settings" className="size-7 p-0">
|
||||
<LaIcon name="Settings" />
|
||||
</Button>
|
||||
<Link href="/">
|
||||
<Button size="icon" variant="ghost" aria-label="Settings" className="size-7 p-0">
|
||||
<LaIcon name="House" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +1,66 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useState, useRef } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronDown, BookOpen, Bookmark, GraduationCap, Check } from "lucide-react"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { SidebarItem } from "../sidebar"
|
||||
|
||||
const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"]
|
||||
// const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"]
|
||||
|
||||
export const TopicSection = () => {
|
||||
const [showOptions, setShowOptions] = useState(false)
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
||||
const sectionRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const learningOptions = [
|
||||
{ text: "To Learn", icon: <Bookmark size={16} />, color: "text-white/70" },
|
||||
{
|
||||
text: "Learning",
|
||||
icon: <GraduationCap size={16} />,
|
||||
color: "text-[#D29752]"
|
||||
text: "To Learn",
|
||||
icon: <LaIcon name="NotebookPen" className="size-3 flex-shrink-0" />,
|
||||
color: "text-black dark:text-white"
|
||||
},
|
||||
{
|
||||
text: "Learned",
|
||||
icon: <Check size={16} />,
|
||||
color: "text-[#708F51]"
|
||||
}
|
||||
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(status === "Show All" ? null : status)
|
||||
setShowOptions(false)
|
||||
setSelectedStatus(prevStatus => (prevStatus === status ? null : status))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const overlayClick = (event: MouseEvent) => {
|
||||
if (sectionRef.current && !sectionRef.current.contains(event.target as Node)) {
|
||||
setShowOptions(false)
|
||||
}
|
||||
const topicCounts = {
|
||||
"To Learn": 2,
|
||||
Learning: 5,
|
||||
Learned: 3,
|
||||
get total() {
|
||||
return this["To Learn"] + this.Learning + this.Learned
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", overlayClick)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", overlayClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const availableOptions = selectedStatus
|
||||
? [
|
||||
{
|
||||
text: "Show All",
|
||||
icon: <BookOpen size={16} />,
|
||||
color: "text-white"
|
||||
},
|
||||
...learningOptions.filter(option => option.text !== selectedStatus)
|
||||
]
|
||||
: learningOptions
|
||||
|
||||
// const topicClick = (topic: string) => {
|
||||
// router.push(`/${topic.toLowerCase()}`)
|
||||
// }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 overflow-hidden" ref={sectionRef}>
|
||||
<Button
|
||||
onClick={() => setShowOptions(!showOptions)}
|
||||
className="bg-accent text-foreground hover:bg-accent/50 flex w-full items-center justify-between rounded-md px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
<span>{selectedStatus ? `Topics: ${selectedStatus}` : "Topics"}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transform transition-transform duration-200 ease-in-out ${
|
||||
showOptions ? "rotate-0" : "rotate-[-90deg]"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showOptions && (
|
||||
<div className="rounded-md bg-neutral-800">
|
||||
{availableOptions.map(option => (
|
||||
<Button
|
||||
key={option.text}
|
||||
onClick={() => statusSelect(option.text)}
|
||||
className={`flex w-full items-center justify-start space-x-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-neutral-700 ${option.color} bg-inherit`}
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="scrollbar-hide space-y-1 overflow-y-auto" style={{ maxHeight: "calc(100vh - 200px)" }}>
|
||||
{TOPICS.map(topic => (
|
||||
<SidebarItem key={topic} label={topic} url={`/${topic}`} />
|
||||
</div>
|
||||
<span className={`${option.color} mr-2`}>{topicCounts[option.text as keyof typeof topicCounts]}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopicSection
|
||||
|
||||
@@ -5,14 +5,14 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useMedia } from "react-use"
|
||||
import { useAtom } from "jotai"
|
||||
import { LinkIcon, SearchIcon } from "lucide-react"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { Logo } from "@/components/custom/logo"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isCollapseAtom } from "@/store/sidebar"
|
||||
|
||||
import { PageSection } from "./partial/page-section"
|
||||
import { TopicSection } from "./partial/topic-section"
|
||||
import { ProfileSection } from "./partial/profile-section"
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
@@ -73,14 +73,14 @@ export const SidebarItem: React.FC<SidebarItemProps> = React.memo(({ label, url,
|
||||
const LogoAndSearch: React.FC = React.memo(() => {
|
||||
const pathname = usePathname()
|
||||
return (
|
||||
<div className="px-3.5">
|
||||
<div className="mb-1 mt-2 flex h-10 max-w-full items-center">
|
||||
<Link href="/links" className="px-2">
|
||||
<div className="px-3">
|
||||
<div className="mt-2 flex h-10 max-w-full items-center">
|
||||
<Link href="/" className="px-2">
|
||||
<Logo className="size-7" />
|
||||
</Link>
|
||||
<div className="flex min-w-2 grow flex-row" />
|
||||
{pathname === "/search" ? (
|
||||
<Link href="/links">
|
||||
<Link href="/">
|
||||
<Button size="sm" variant="secondary" type="button" className="text-md text-primary/60 font-medium">
|
||||
← Back
|
||||
</Button>
|
||||
@@ -104,21 +104,20 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
||||
})
|
||||
|
||||
const SidebarContent: React.FC = React.memo(() => {
|
||||
const { isCollapsed } = React.useContext(SidebarContext)
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
|
||||
return (
|
||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||
<div className={cn({ "pt-12": !isCollapsed && isTablet })}>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3.5">
|
||||
<SidebarItem url="/links" label="Links" icon={<LinkIcon size={16} />} />
|
||||
<div className="h-2 shrink-0" />
|
||||
<PageSection />
|
||||
<TopicSection />
|
||||
</div>
|
||||
</nav>
|
||||
<>
|
||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||
<div>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3">
|
||||
<div className="h-2 shrink-0" />
|
||||
<PageSection />
|
||||
<TopicSection />
|
||||
</div>
|
||||
</nav>
|
||||
<ProfileSection />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -132,7 +131,7 @@ export const Sidebar: React.FC = () => {
|
||||
)
|
||||
|
||||
const sidebarInnerClasses = cn(
|
||||
"h-full w-auto min-w-56 transition-transform duration-300 ease-in-out",
|
||||
"h-full w-56 min-w-56 transition-transform duration-300 ease-in-out",
|
||||
isCollapsed ? "-translate-x-full" : "translate-x-0"
|
||||
)
|
||||
|
||||
|
||||
23
web/components/custom/textarea-autosize.tsx
Normal file
23
web/components/custom/textarea-autosize.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import BaseTextareaAutosize from "react-textarea-autosize"
|
||||
import { TextareaAutosizeProps as BaseTextareaAutosizeProps } from "react-textarea-autosize"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends Omit<BaseTextareaAutosizeProps, "ref"> {}
|
||||
|
||||
const TextareaAutosize = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, style, ...props }, ref) => {
|
||||
return (
|
||||
<BaseTextareaAutosize
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
TextareaAutosize.displayName = "TextareaAutosize"
|
||||
|
||||
export { TextareaAutosize }
|
||||
Reference in New Issue
Block a user