mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
158
web/app/components/sidebar/partials/feedback.tsx
Normal file
158
web/app/components/sidebar/partials/feedback.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogPrimitive,
|
||||
} from "@/components/ui/dialog"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { MinimalTiptapEditor } from "@shared/minimal-tiptap"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { Spinner } from "@/components/custom/spinner"
|
||||
import { Editor } from "@tiptap/react"
|
||||
import { sendFeedbackFn } from "~/actions"
|
||||
|
||||
const formSchema = z.object({
|
||||
content: z.string().min(1, {
|
||||
message: "Feedback cannot be empty",
|
||||
}),
|
||||
})
|
||||
|
||||
export function Feedback() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
content: "",
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = React.useCallback(
|
||||
({ editor }: { editor: Editor }) => {
|
||||
if (form.getValues("content") && editor.isEmpty) {
|
||||
editor.commands.setContent(form.getValues("content"))
|
||||
}
|
||||
editorRef.current = editor
|
||||
},
|
||||
[form],
|
||||
)
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
setIsPending(true)
|
||||
await sendFeedbackFn(values)
|
||||
|
||||
form.reset({ content: "" })
|
||||
editorRef.current?.commands.clearContent()
|
||||
|
||||
setOpen(false)
|
||||
toast.success("Feedback sent")
|
||||
} catch (error) {
|
||||
toast.error("Failed to send feedback")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="icon" className="shrink-0" variant="ghost">
|
||||
<LaIcon name="CircleHelp" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"flex flex-col p-4 sm:max-w-2xl",
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader className="mb-5">
|
||||
<DialogTitle>Share feedback</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Your feedback helps us improve. Please share your thoughts,
|
||||
ideas, and suggestions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">Content</FormLabel>
|
||||
<FormControl>
|
||||
<MinimalTiptapEditor
|
||||
{...field}
|
||||
throttleDelay={500}
|
||||
className={cn(
|
||||
"border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg",
|
||||
{
|
||||
"border-destructive focus-within:border-destructive":
|
||||
form.formState.errors.content,
|
||||
},
|
||||
)}
|
||||
editorContentClassName="p-4 overflow-auto flex grow"
|
||||
output="html"
|
||||
placeholder="Your feedback helps us improve. Please share your thoughts, ideas, and suggestions."
|
||||
autofocus={true}
|
||||
onCreate={handleCreate}
|
||||
editorClassName="focus:outline-none"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogPrimitive.Close
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
Cancel
|
||||
</DialogPrimitive.Close>
|
||||
<Button type="submit">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2" />
|
||||
<span>Sending feedback...</span>
|
||||
</>
|
||||
) : (
|
||||
"Send feedback"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
120
web/app/components/sidebar/partials/journal-section.tsx
Normal file
120
web/app/components/sidebar/partials/journal-section.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useAuth, useUser } from "@clerk/tanstack-start"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { getFeatureFlag } from "~/actions"
|
||||
|
||||
export const JournalSection: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const journalEntries = me?.root?.journalEntries
|
||||
|
||||
const [, setIsFetching] = useState(false)
|
||||
const [isFeatureActive, setIsFeatureActive] = useState(false)
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFeatureFlag() {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const response = await getFeatureFlag({ name: "JOURNAL" })
|
||||
|
||||
if (
|
||||
user?.emailAddresses.some((email) =>
|
||||
response?.emails.includes(email.emailAddress),
|
||||
)
|
||||
) {
|
||||
setIsFeatureActive(true)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFeatureFlag()
|
||||
}, [isLoaded, isSignedIn, user])
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return <div className="py-2 text-center text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
if (!me) return null
|
||||
|
||||
if (!isFeatureActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/journal flex flex-col gap-px py-2">
|
||||
<JournalSectionHeader entriesCount={journalEntries?.length || 0} />
|
||||
{journalEntries && journalEntries.length > 0 && (
|
||||
<JournalEntryList entries={journalEntries} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalHeaderProps {
|
||||
entriesCount: number
|
||||
}
|
||||
|
||||
const JournalSectionHeader: React.FC<JournalHeaderProps> = ({
|
||||
entriesCount,
|
||||
}) => (
|
||||
<Link
|
||||
to="/journals"
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs">
|
||||
Journal
|
||||
{entriesCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">({entriesCount})</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
|
||||
interface JournalEntryListProps {
|
||||
entries: any[]
|
||||
}
|
||||
|
||||
const JournalEntryList: React.FC<JournalEntryListProps> = ({ entries }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{entries.map((entry, index) => (
|
||||
<JournalEntryItem key={index} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JournalEntryItemProps {
|
||||
entry: any
|
||||
}
|
||||
|
||||
const JournalEntryItem: React.FC<JournalEntryItemProps> = ({ entry }) => (
|
||||
<Link
|
||||
href={`/journal/${entry.id}`}
|
||||
className="group/journal-entry relative flex min-w-0 flex-1"
|
||||
>
|
||||
<div className="relative flex h-8 w-full items-center gap-2 rounded-md p-1.5 font-medium">
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<LaIcon name="FileText" className="opacity-60" />
|
||||
<p
|
||||
className={cn(
|
||||
"truncate opacity-95 group-hover/journal-entry:opacity-100",
|
||||
)}
|
||||
>
|
||||
{entry.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
115
web/app/components/sidebar/partials/link-section.tsx
Normal file
115
web/app/components/sidebar/partials/link-section.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { LearningStateValue } from "~/lib/constants"
|
||||
|
||||
export const LinkSection: React.FC = () => {
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const linkCount = me.root.personalLinks?.length || 0
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<LinkSectionHeader linkCount={linkCount} />
|
||||
<LinkList personalLinks={me.root.personalLinks} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkSectionHeaderProps {
|
||||
linkCount: number
|
||||
}
|
||||
|
||||
const LinkSectionHeader: React.FC<LinkSectionHeaderProps> = ({ linkCount }) => (
|
||||
<Link
|
||||
to="/links"
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
Links
|
||||
{linkCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{linkCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
|
||||
interface LinkListProps {
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
const LinkList: React.FC<LinkListProps> = ({ personalLinks }) => {
|
||||
const linkStates: LearningStateValue[] = [
|
||||
"wantToLearn",
|
||||
"learning",
|
||||
"learned",
|
||||
]
|
||||
const linkLabels: Record<LearningStateValue, string> = {
|
||||
wantToLearn: "To Learn",
|
||||
learning: "Learning",
|
||||
learned: "Learned",
|
||||
}
|
||||
|
||||
const linkCounts = linkStates.reduce(
|
||||
(acc, state) => ({
|
||||
...acc,
|
||||
[state]: personalLinks.filter((link) => link?.learningState === state)
|
||||
.length,
|
||||
}),
|
||||
{} as Record<LearningStateValue, number>,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{linkStates.map((state) => (
|
||||
<LinkListItem
|
||||
key={state}
|
||||
label={linkLabels[state]}
|
||||
state={state}
|
||||
count={linkCounts[state]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkListItemProps {
|
||||
label: string
|
||||
state: LearningStateValue
|
||||
count: number
|
||||
}
|
||||
|
||||
const LinkListItem: React.FC<LinkListItemProps> = ({ label, state, count }) => (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/topic-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/links"
|
||||
search={{ state }}
|
||||
className={cn(
|
||||
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium hover:bg-accent hover:text-accent-foreground sm:h-8",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<p className="truncate opacity-95 group-hover/topic-link:opacity-100">
|
||||
{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>
|
||||
)
|
||||
284
web/app/components/sidebar/partials/page-section.tsx
Normal file
284
web/app/components/sidebar/partials/page-section.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { usePageActions } from "~/hooks/actions/use-page-actions"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
type SortOption = "title" | "recent"
|
||||
type ShowOption = 5 | 10 | 15 | 20 | 0
|
||||
|
||||
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 { me } = useAccount({
|
||||
root: {
|
||||
personalPages: [],
|
||||
},
|
||||
})
|
||||
const [sort] = useAtom(pageSortAtom)
|
||||
const [show] = useAtom(pageShowAtom)
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const pageCount = me.root.personalPages?.length || 0
|
||||
|
||||
return (
|
||||
<div className="group/pages flex flex-col gap-px py-2">
|
||||
<PageSectionHeader pageCount={pageCount} />
|
||||
<PageList personalPages={me.root.personalPages} sort={sort} show={show} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageSectionHeaderProps {
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount }) => (
|
||||
<Link
|
||||
to="/pages"
|
||||
className={cn(
|
||||
"flex h-9 flex-1 items-center justify-start gap-px rounded-md px-2 py-1",
|
||||
"hover:bg-accent hover:text-accent-foreground sm:h-[30px]",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<p className="text-sm sm:text-xs">
|
||||
Pages
|
||||
{pageCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{pageCount}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-px">
|
||||
<ShowAllForm />
|
||||
<NewPageButton />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
const NewPageButton: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const navigate = useNavigate()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const page = newPage(me)
|
||||
|
||||
if (page.id) {
|
||||
navigate({
|
||||
to: "/pages/$pageId",
|
||||
params: { pageId: page.id },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="New Page"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center p-0.5 shadow-none",
|
||||
"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 focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<LaIcon name="Plus" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageListProps {
|
||||
personalPages: PersonalPageLists
|
||||
sort: SortOption
|
||||
show: ShowOption
|
||||
}
|
||||
|
||||
const PageList: React.FC<PageListProps> = ({ personalPages, sort, show }) => {
|
||||
const sortedPages = React.useMemo(() => {
|
||||
return [...personalPages]
|
||||
.sort((a, b) => {
|
||||
if (sort === "title") {
|
||||
return (a?.title ?? "").localeCompare(b?.title ?? "")
|
||||
}
|
||||
return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0)
|
||||
})
|
||||
.slice(0, show === 0 ? personalPages.length : show)
|
||||
}, [personalPages, sort, show])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{sortedPages.map(
|
||||
(page) => page?.id && <PageListItem key={page.id} page={page} />,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageListItemProps {
|
||||
page: PersonalPage
|
||||
}
|
||||
|
||||
const PageListItem: React.FC<PageListItemProps> = ({ page }) => {
|
||||
return (
|
||||
<div className="group/reorder-page relative">
|
||||
<div className="group/sidebar-link relative flex min-w-0 flex-1">
|
||||
<Link
|
||||
to="/pages/$pageId"
|
||||
params={{ pageId: page.id }}
|
||||
className={cn(
|
||||
"relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
"group-hover/sidebar-link:bg-accent group-hover/sidebar-link:text-accent-foreground",
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<div className="flex max-w-[calc(100%-1rem)] 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",
|
||||
"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 focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
>
|
||||
<LaIcon name="Ellipsis" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<SubMenu
|
||||
icon="ArrowUpDown"
|
||||
label="Sort"
|
||||
options={SORTS}
|
||||
currentValue={pagesSorted}
|
||||
onSelect={setPagesSorted}
|
||||
/>
|
||||
<SubMenu
|
||||
icon="Hash"
|
||||
label="Show"
|
||||
options={SHOWS}
|
||||
currentValue={pagesShow}
|
||||
onSelect={setPagesShow}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
186
web/app/components/sidebar/partials/profile-section.tsx
Normal file
186
web/app/components/sidebar/partials/profile-section.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { icons } from "lucide-react"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { DiscordIcon } from "@/components/icons/discord-icon"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { showShortcutAtom } from "@/components/shortcut/shortcut"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
import { SignInButton, useAuth, useUser } from "@clerk/tanstack-start"
|
||||
import { Link, useLocation } from "@tanstack/react-router"
|
||||
import { ShortcutKey } from "@shared/minimal-tiptap/components/shortcut-key"
|
||||
import { Feedback } from "./feedback"
|
||||
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { user, isSignedIn } = useUser()
|
||||
const { signOut } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
||||
const { pathname } = useLocation()
|
||||
const [, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("profileSection")
|
||||
|
||||
React.useEffect(() => {
|
||||
disableKeydown(menuOpen)
|
||||
}, [menuOpen, disableKeydown])
|
||||
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
||||
<SignInButton mode="modal" forceRedirectUrl={pathname}>
|
||||
<Button variant="outline" className="flex w-full items-center gap-2">
|
||||
<LaIcon name="LogIn" />
|
||||
Sign in
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
||||
<div className="flex h-10 min-w-full items-center">
|
||||
<ProfileDropdown
|
||||
user={user}
|
||||
menuOpen={menuOpen}
|
||||
setMenuOpen={setMenuOpen}
|
||||
signOut={signOut}
|
||||
setShowShortcut={setShowShortcut}
|
||||
/>
|
||||
<Feedback />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileDropdownProps {
|
||||
user: any
|
||||
menuOpen: boolean
|
||||
setMenuOpen: (open: boolean) => void
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const ProfileDropdown: React.FC<ProfileDropdownProps> = ({
|
||||
user,
|
||||
menuOpen,
|
||||
setMenuOpen,
|
||||
signOut,
|
||||
setShowShortcut,
|
||||
}) => (
|
||||
<div className="flex min-w-0">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">
|
||||
{user.fullName}
|
||||
</span>
|
||||
<LaIcon
|
||||
name="ChevronDown"
|
||||
className={cn("size-4 shrink-0 transition-transform duration-300", {
|
||||
"rotate-180": menuOpen,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuItems
|
||||
signOut={signOut}
|
||||
setShowShortcut={setShowShortcut}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface DropdownMenuItemsProps {
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownMenuItems: React.FC<DropdownMenuItemsProps> = ({
|
||||
signOut,
|
||||
setShowShortcut,
|
||||
}) => (
|
||||
<>
|
||||
<MenuLink href="/profile" icon="CircleUser" text="My profile" />
|
||||
<DropdownMenuItem className="gap-2" onClick={() => setShowShortcut(true)}>
|
||||
<LaIcon name="Keyboard" />
|
||||
<span>Shortcut</span>
|
||||
</DropdownMenuItem>
|
||||
<MenuLink href="/onboarding" icon="LayoutList" text="Onboarding" />
|
||||
<DropdownMenuSeparator />
|
||||
<MenuLink
|
||||
href="https://docs.learn-anything.xyz/"
|
||||
icon="Sticker"
|
||||
text="Docs"
|
||||
/>
|
||||
<MenuLink
|
||||
href="https://github.com/learn-anything/learn-anything"
|
||||
icon="Github"
|
||||
text="GitHub"
|
||||
/>
|
||||
<MenuLink
|
||||
href="https://discord.com/invite/bxtD8x6aNF"
|
||||
icon={DiscordIcon}
|
||||
text="Discord"
|
||||
iconClass="-ml-1"
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={signOut}>
|
||||
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
|
||||
<LaIcon name="LogOut" />
|
||||
<span>Log out</span>
|
||||
<div className="absolute right-0">
|
||||
<ShortcutKey keys={["alt", "shift", "q"]} />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
|
||||
interface MenuLinkProps {
|
||||
href: string
|
||||
icon: keyof typeof icons | React.FC
|
||||
text: string
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
const MenuLink: React.FC<MenuLinkProps> = ({
|
||||
href,
|
||||
icon,
|
||||
text,
|
||||
iconClass = "",
|
||||
}) => {
|
||||
const IconComponent = typeof icon === "string" ? icons[icon] : icon
|
||||
return (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" to={href}>
|
||||
<div
|
||||
className={cn("relative flex flex-1 items-center gap-2", iconClass)}
|
||||
>
|
||||
<IconComponent className="size-4" />
|
||||
<span className="line-clamp-1 flex-1">{text}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileSection
|
||||
110
web/app/components/sidebar/partials/task-section.tsx
Normal file
110
web/app/components/sidebar/partials/task-section.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useState } from "react"
|
||||
import { isToday, isFuture } from "date-fns"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAuth, useUser } from "@clerk/tanstack-start"
|
||||
import { getFeatureFlag } from "~/actions"
|
||||
import { LaIcon } from "~/components/custom/la-icon"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
export const TaskSection: React.FC = () => {
|
||||
const { me } = useAccount({ root: { tasks: [] } })
|
||||
|
||||
const taskCount = me?.root?.tasks?.length || 0
|
||||
const todayTasks =
|
||||
me?.root?.tasks?.filter(
|
||||
(task) =>
|
||||
task?.status !== "done" && task?.dueDate && isToday(task.dueDate),
|
||||
) || []
|
||||
const upcomingTasks =
|
||||
me?.root?.tasks?.filter(
|
||||
(task) =>
|
||||
task?.status !== "done" && task?.dueDate && isFuture(task.dueDate),
|
||||
) || []
|
||||
|
||||
const [, setIsFetching] = useState(false)
|
||||
const [isFeatureActive, setIsFeatureActive] = useState(false)
|
||||
const { isLoaded, isSignedIn } = useAuth()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
async function checkFeatureFlag() {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const response = await getFeatureFlag({ name: "TASK" })
|
||||
|
||||
if (
|
||||
user?.emailAddresses.some((email) =>
|
||||
response?.emails.includes(email.emailAddress),
|
||||
)
|
||||
) {
|
||||
setIsFeatureActive(true)
|
||||
}
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkFeatureFlag()
|
||||
}, [isLoaded, isSignedIn, user])
|
||||
|
||||
if (!isLoaded || !isSignedIn) {
|
||||
return <div className="py-2 text-center text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
if (!me) return null
|
||||
|
||||
if (!isFeatureActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/tasks flex flex-col gap-px py-2">
|
||||
<TaskSectionHeader title="Tasks" count={taskCount} />
|
||||
<TaskSectionHeader
|
||||
title="Today"
|
||||
iconName="BookOpenCheck"
|
||||
filter="today"
|
||||
count={todayTasks.length}
|
||||
/>
|
||||
<TaskSectionHeader
|
||||
title="Upcoming"
|
||||
iconName="History"
|
||||
filter="upcoming"
|
||||
count={upcomingTasks.length}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskSectionHeaderProps {
|
||||
title: string
|
||||
filter?: "today" | "upcoming"
|
||||
count: number
|
||||
iconName?: "BookOpenCheck" | "History"
|
||||
}
|
||||
|
||||
const TaskSectionHeader: React.FC<TaskSectionHeaderProps> = ({
|
||||
title,
|
||||
filter,
|
||||
count,
|
||||
iconName,
|
||||
}) => (
|
||||
<Link
|
||||
to="/tasks"
|
||||
className={cn(
|
||||
"flex flex-1 min-h-[30px] gap-px items-center justify-start hover:bg-accent hover:text-accent-foreground rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0",
|
||||
)}
|
||||
search={{ filter }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
{iconName && <LaIcon className="size-13 shrink-0 pr-2" name={iconName} />}
|
||||
|
||||
<p className="text-sm">
|
||||
{title}
|
||||
{count > 0 && <span className="text-muted-foreground ml-1">{count}</span>}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
142
web/app/components/sidebar/partials/topic-section.tsx
Normal file
142
web/app/components/sidebar/partials/topic-section.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { ListOfTopics } from "@/lib/schema"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
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="group/topics 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,
|
||||
}) => (
|
||||
<Link
|
||||
to="/topics"
|
||||
className="flex h-9 items-center gap-px rounded-md px-2 py-1 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-0 sm:h-[30px] sm:text-xs"
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm sm:text-xs">
|
||||
Topics
|
||||
{topicCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">{topicCount}</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
|
||||
interface ListProps {
|
||||
topicsWantToLearn: ListOfTopics
|
||||
topicsLearning: ListOfTopics
|
||||
topicsLearned: ListOfTopics
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({
|
||||
topicsWantToLearn,
|
||||
topicsLearning,
|
||||
topicsLearned,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<ListItem
|
||||
key={topicsWantToLearn.id}
|
||||
count={topicsWantToLearn.length}
|
||||
label="To Learn"
|
||||
value="wantToLearn"
|
||||
/>
|
||||
<ListItem
|
||||
key={topicsLearning.id}
|
||||
label="Learning"
|
||||
value="learning"
|
||||
count={topicsLearning.length}
|
||||
/>
|
||||
<ListItem
|
||||
key={topicsLearned.id}
|
||||
label="Learned"
|
||||
value="learned"
|
||||
count={topicsLearned.length}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListItemProps {
|
||||
label: string
|
||||
value: LearningStateValue
|
||||
count: number
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ label, value, count }) => {
|
||||
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
|
||||
to="/topics"
|
||||
search={{ learningState: value }}
|
||||
className={cn(
|
||||
"group-hover/topic-link:bg-accent relative flex h-9 w-full items-center gap-2 rounded-md p-1.5 font-medium sm:h-8",
|
||||
le.className,
|
||||
)}
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{
|
||||
className: "bg-accent text-accent-foreground",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
211
web/app/components/sidebar/sidebar.tsx
Normal file
211
web/app/components/sidebar/sidebar.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import * as React from "react"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useAtom } from "jotai"
|
||||
import { LogoIcon } from "@/components/icons/logo-icon"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isCollapseAtom } from "@/store/sidebar"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Link, useLocation } from "@tanstack/react-router"
|
||||
|
||||
import { LinkSection } from "./partials/link-section"
|
||||
import { PageSection } from "./partials/page-section"
|
||||
import { TopicSection } from "./partials/topic-section"
|
||||
import { ProfileSection } from "./partials/profile-section"
|
||||
import { JournalSection } from "./partials/journal-section"
|
||||
import { TaskSection } from "./partials/task-section"
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextType>({
|
||||
isCollapsed: false,
|
||||
setIsCollapsed: () => {},
|
||||
})
|
||||
|
||||
const useSidebarCollapse = (
|
||||
isTablet: boolean,
|
||||
): [boolean, React.Dispatch<React.SetStateAction<boolean>>] => {
|
||||
const [isCollapsed, setIsCollapsed] = useAtom(isCollapseAtom)
|
||||
const location = useLocation()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTablet) setIsCollapsed(true)
|
||||
}, [location.pathname, setIsCollapsed, isTablet])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsCollapsed(isTablet)
|
||||
}, [isTablet, setIsCollapsed])
|
||||
|
||||
return [isCollapsed, setIsCollapsed]
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
label: string
|
||||
url: string
|
||||
icon?: React.ReactNode
|
||||
onClick?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = React.memo(
|
||||
({ label, url, icon, onClick, children }) => {
|
||||
const { pathname } = useLocation()
|
||||
const isActive = pathname === url
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative my-0.5 rounded-md",
|
||||
isActive ? "bg-secondary/80" : "hover:bg-secondary/40",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className="text-secondary-foreground flex h-8 grow items-center truncate rounded-md pl-1.5 pr-1 text-sm font-medium"
|
||||
to={url}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-primary/60 group-hover:text-primary mr-2 size-4",
|
||||
{ "text-primary": isActive },
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SidebarItem.displayName = "SidebarItem"
|
||||
|
||||
const LogoAndSearch: React.FC = React.memo(() => {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className="mt-2 flex h-10 max-w-full items-center">
|
||||
<Link to="/" className="px-2">
|
||||
<LogoIcon className="size-7" />
|
||||
</Link>
|
||||
<div className="flex min-w-2 grow flex-row" />
|
||||
<Link
|
||||
to={pathname === "/search" ? "/" : "/search"}
|
||||
className={cn(
|
||||
buttonVariants({ size: "sm", variant: "secondary" }),
|
||||
"text-primary/60 flex w-20 items-center justify-start py-4 pl-2",
|
||||
)}
|
||||
activeProps={{
|
||||
className: "text-md font-medium",
|
||||
}}
|
||||
aria-label="Search"
|
||||
>
|
||||
{pathname === "/search" ? (
|
||||
"← Back"
|
||||
) : (
|
||||
<LaIcon name="Search" className="size-4" />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LogoAndSearch.displayName = "LogoAndSearch"
|
||||
|
||||
const SidebarContent: React.FC = React.memo(() => {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
return (
|
||||
<nav className="bg-background relative flex h-full w-full shrink-0 flex-col">
|
||||
<div>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3 outline-none">
|
||||
<div className="h-2 shrink-0" />
|
||||
{me._type === "Account" && <LinkSection />}
|
||||
{me._type === "Account" && <TopicSection />}
|
||||
{me._type === "Account" && <JournalSection />}
|
||||
{me._type === "Account" && <TaskSection />}
|
||||
{me._type === "Account" && <PageSection />}
|
||||
</div>
|
||||
|
||||
<ProfileSection />
|
||||
</nav>
|
||||
)
|
||||
})
|
||||
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const isTablet = useMedia("(max-width: 1024px)")
|
||||
const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet)
|
||||
|
||||
const sidebarClasses = cn(
|
||||
"h-full overflow-hidden transition-all duration-300 ease-in-out",
|
||||
isCollapsed ? "w-0" : "w-auto min-w-56",
|
||||
)
|
||||
|
||||
const sidebarInnerClasses = cn(
|
||||
"h-full w-56 min-w-56 transition-transform duration-300 ease-in-out",
|
||||
isCollapsed ? "-translate-x-full" : "translate-x-0",
|
||||
)
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ isCollapsed, setIsCollapsed }),
|
||||
[isCollapsed, setIsCollapsed],
|
||||
)
|
||||
|
||||
if (isTablet) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-30 bg-black/40 transition-opacity duration-300",
|
||||
isCollapsed ? "pointer-events-none opacity-0" : "opacity-100",
|
||||
)}
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-40 h-full",
|
||||
sidebarClasses,
|
||||
!isCollapsed &&
|
||||
"shadow-[4px_0px_16px_rgba(0,0,0,0.1)] transition-all",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(sidebarInnerClasses, "border-r-primary/5 border-r")}
|
||||
>
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<SidebarContent />
|
||||
</SidebarContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={sidebarClasses}>
|
||||
<div className={sidebarInnerClasses}>
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<SidebarContent />
|
||||
</SidebarContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
export { Sidebar, SidebarItem, SidebarContext }
|
||||
Reference in New Issue
Block a user