mirror of
https://github.com/linsa-io/linsa.git
synced 2026-05-26 00:29:15 +02:00
Move to TanStack Start from Next.js (#184)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import * as React from "react"
|
||||
import { createFileRoute, useParams } from "@tanstack/react-router"
|
||||
import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { GraphData, JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import { atom } from "jotai"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { TopicDetailHeader } from "./-header"
|
||||
import { TopicDetailList } from "./-list"
|
||||
|
||||
export const Route = createFileRoute("/_layout/_pages/(topic)/$")({
|
||||
component: TopicDetailComponent,
|
||||
})
|
||||
|
||||
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export function TopicDetailComponent() {
|
||||
console.log("TopicDetailComponent")
|
||||
const params = useParams({ from: "/_layout/_pages/$" })
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
|
||||
const topicID = React.useMemo(
|
||||
() =>
|
||||
me &&
|
||||
Topic.findUnique({ topicName: params._splat }, JAZZ_GLOBAL_GROUP_ID, me),
|
||||
[params._splat, me],
|
||||
)
|
||||
const topic = useCoState(Topic, topicID, {
|
||||
latestGlobalGuide: { sections: [] },
|
||||
})
|
||||
const [activeIndex, setActiveIndex] = React.useState(-1)
|
||||
|
||||
const topicExists = GraphData.find((node) => {
|
||||
return node.name === params._splat
|
||||
})
|
||||
|
||||
if (!topicExists) {
|
||||
return <NotFoundPlaceholder />
|
||||
}
|
||||
|
||||
const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap(
|
||||
(section) => [
|
||||
{ type: "section" as const, data: section },
|
||||
...(section?.links?.map((link) => ({
|
||||
type: "link" as const,
|
||||
data: link,
|
||||
})) || []),
|
||||
],
|
||||
)
|
||||
|
||||
if (!topic || !me || !flattenedItems) {
|
||||
return <TopicDetailSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopicDetailHeader topic={topic} />
|
||||
<TopicDetailList
|
||||
items={flattenedItems}
|
||||
topic={topic}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={setActiveIndex}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NotFoundPlaceholder() {
|
||||
return (
|
||||
<div className="flex h-full grow flex-col items-center justify-center gap-3">
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
<LaIcon name="CircleAlert" />
|
||||
<span className="text-left font-medium">Topic not found</span>
|
||||
</div>
|
||||
<span className="max-w-sm text-left text-sm">
|
||||
There is no topic with the given identifier.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TopicDetailSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-6 py-5 max-lg:px-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-36" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-lg:px-4">
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<div key={index} className="flex items-center space-x-4">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ContentHeader,
|
||||
SidebarToggleButton,
|
||||
} from "@/components/custom/content-header"
|
||||
import { ListOfTopics, Topic } from "@/lib/schema"
|
||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LearningStateValue } from "@/lib/constants"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useClerk } from "@clerk/tanstack-start"
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
|
||||
interface TopicDetailHeaderProps {
|
||||
topic: Topic
|
||||
}
|
||||
|
||||
export const TopicDetailHeader = React.memo(function TopicDetailHeader({
|
||||
topic,
|
||||
}: TopicDetailHeaderProps) {
|
||||
const clerk = useClerk()
|
||||
const { pathname } = useLocation()
|
||||
const isMobile = useMedia("(max-width: 770px)")
|
||||
const { me } = useAccountOrGuest({
|
||||
root: {
|
||||
topicsWantToLearn: [],
|
||||
topicsLearning: [],
|
||||
topicsLearned: [],
|
||||
},
|
||||
})
|
||||
|
||||
let p: {
|
||||
index: number
|
||||
topic?: Topic | null
|
||||
learningState: LearningStateValue
|
||||
} | null = null
|
||||
|
||||
const wantToLearnIndex =
|
||||
me?._type === "Anonymous"
|
||||
? -1
|
||||
: (me?.root.topicsWantToLearn.findIndex((t) => t?.id === topic.id) ?? -1)
|
||||
if (wantToLearnIndex !== -1) {
|
||||
p = {
|
||||
index: wantToLearnIndex,
|
||||
topic:
|
||||
me && me._type !== "Anonymous"
|
||||
? me.root.topicsWantToLearn[wantToLearnIndex]
|
||||
: undefined,
|
||||
learningState: "wantToLearn",
|
||||
}
|
||||
}
|
||||
|
||||
const learningIndex =
|
||||
me?._type === "Anonymous"
|
||||
? -1
|
||||
: (me?.root.topicsLearning.findIndex((t) => t?.id === topic.id) ?? -1)
|
||||
if (learningIndex !== -1) {
|
||||
p = {
|
||||
index: learningIndex,
|
||||
topic:
|
||||
me && me._type !== "Anonymous"
|
||||
? me?.root.topicsLearning[learningIndex]
|
||||
: undefined,
|
||||
learningState: "learning",
|
||||
}
|
||||
}
|
||||
|
||||
const learnedIndex =
|
||||
me?._type === "Anonymous"
|
||||
? -1
|
||||
: (me?.root.topicsLearned.findIndex((t) => t?.id === topic.id) ?? -1)
|
||||
if (learnedIndex !== -1) {
|
||||
p = {
|
||||
index: learnedIndex,
|
||||
topic:
|
||||
me && me._type !== "Anonymous"
|
||||
? me?.root.topicsLearned[learnedIndex]
|
||||
: undefined,
|
||||
learningState: "learned",
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToProfile = (learningState: LearningStateValue) => {
|
||||
if (me?._type === "Anonymous") {
|
||||
return clerk.redirectToSignIn({
|
||||
redirectUrl: pathname,
|
||||
})
|
||||
}
|
||||
|
||||
const topicLists: Record<
|
||||
LearningStateValue,
|
||||
(ListOfTopics | null) | undefined
|
||||
> = {
|
||||
wantToLearn: me?.root.topicsWantToLearn,
|
||||
learning: me?.root.topicsLearning,
|
||||
learned: me?.root.topicsLearned,
|
||||
}
|
||||
|
||||
const removeFromList = (state: LearningStateValue, index: number) => {
|
||||
topicLists[state]?.splice(index, 1)
|
||||
}
|
||||
|
||||
if (p) {
|
||||
if (learningState === p.learningState) {
|
||||
removeFromList(p.learningState, p.index)
|
||||
return
|
||||
}
|
||||
removeFromList(p.learningState, p.index)
|
||||
}
|
||||
|
||||
topicLists[learningState]?.push(topic)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center">
|
||||
<h1 className="truncate text-left font-bold lg:text-xl">
|
||||
{topic.prettyName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
{/* <GuideCommunityToggle topicName={topic.name} /> */}
|
||||
|
||||
<LearningStateSelector
|
||||
showSearch={false}
|
||||
value={p?.learningState || ""}
|
||||
onChange={handleAddToProfile}
|
||||
defaultLabel={isMobile ? "" : "Add to profile"}
|
||||
defaultIcon="Circle"
|
||||
/>
|
||||
</ContentHeader>
|
||||
)
|
||||
})
|
||||
|
||||
TopicDetailHeader.displayName = "TopicDetailHeader"
|
||||
@@ -0,0 +1,251 @@
|
||||
import * as React from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
|
||||
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
|
||||
import {
|
||||
Link as LinkSchema,
|
||||
PersonalLink,
|
||||
PersonalLinkLists,
|
||||
Topic,
|
||||
} from "@/lib/schema"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useClerk } from "@clerk/tanstack-start"
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router"
|
||||
import { openPopoverForIdAtom } from "./$"
|
||||
|
||||
interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
topic: Topic
|
||||
link: LinkSchema
|
||||
isActive: boolean
|
||||
index: number
|
||||
setActiveIndex: (index: number) => void
|
||||
personalLinks?: PersonalLinkLists
|
||||
}
|
||||
|
||||
export const LinkItem = React.memo(
|
||||
React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
(
|
||||
{
|
||||
topic,
|
||||
link,
|
||||
isActive,
|
||||
index,
|
||||
setActiveIndex,
|
||||
className,
|
||||
personalLinks,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const clerk = useClerk()
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false)
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
const personalLink = React.useMemo(() => {
|
||||
return personalLinks?.find((pl) => pl?.link?.id === link.id)
|
||||
}, [personalLinks, link.id])
|
||||
|
||||
const selectedLearningState = React.useMemo(() => {
|
||||
return LEARNING_STATES.find(
|
||||
(ls) => ls.value === personalLink?.learningState,
|
||||
)
|
||||
}, [personalLink?.learningState])
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setActiveIndex(index)
|
||||
},
|
||||
[index, setActiveIndex],
|
||||
)
|
||||
|
||||
const handleSelectLearningState = React.useCallback(
|
||||
(learningState: LearningStateValue) => {
|
||||
if (!personalLinks || !me || me?._type === "Anonymous") {
|
||||
return clerk.redirectToSignIn({
|
||||
redirectUrl: pathname,
|
||||
})
|
||||
}
|
||||
|
||||
const defaultToast = {
|
||||
duration: 5000,
|
||||
position: "bottom-right" as const,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: "Go to list",
|
||||
onClick: () =>
|
||||
navigate({
|
||||
to: "/links",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
if (personalLink) {
|
||||
if (personalLink.learningState === learningState) {
|
||||
personalLink.learningState = undefined
|
||||
toast.error("Link learning state removed", defaultToast)
|
||||
} else {
|
||||
personalLink.learningState = learningState
|
||||
toast.success("Link learning state updated", defaultToast)
|
||||
}
|
||||
} else {
|
||||
const slug = generateUniqueSlug(link.title)
|
||||
const newPersonalLink = PersonalLink.create(
|
||||
{
|
||||
url: link.url,
|
||||
title: link.title,
|
||||
slug,
|
||||
link,
|
||||
learningState,
|
||||
sequence: personalLinks.length + 1,
|
||||
completed: false,
|
||||
topic,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{ owner: me },
|
||||
)
|
||||
|
||||
personalLinks.push(newPersonalLink)
|
||||
|
||||
toast.success("Link added.", {
|
||||
...defaultToast,
|
||||
description: `${link.title} has been added to your personal link.`,
|
||||
})
|
||||
}
|
||||
|
||||
setOpenPopoverForId(null)
|
||||
setIsPopoverOpen(false)
|
||||
},
|
||||
[
|
||||
personalLink,
|
||||
personalLinks,
|
||||
me,
|
||||
link,
|
||||
navigate,
|
||||
topic,
|
||||
setOpenPopoverForId,
|
||||
clerk,
|
||||
pathname,
|
||||
],
|
||||
)
|
||||
|
||||
const handlePopoverOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
setIsPopoverOpen(open)
|
||||
setOpenPopoverForId(open ? link.id : null)
|
||||
},
|
||||
[link.id, setOpenPopoverForId],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"relative flex h-14 cursor-pointer items-center outline-none xl:h-11",
|
||||
{
|
||||
"bg-muted-foreground/10": isActive,
|
||||
"hover:bg-muted/50": !isActive,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
||||
<div className="flex min-w-0 items-center gap-x-4">
|
||||
<Popover
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={handlePopoverOpenChange}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"size-7 shrink-0 p-0",
|
||||
"hover:bg-accent-foreground/10",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon
|
||||
name={selectedLearningState.icon}
|
||||
className={selectedLearningState.className}
|
||||
/>
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={personalLink?.learningState}
|
||||
onSelect={(value: string) =>
|
||||
handleSelectLearningState(value as LearningStateValue)
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="w-full min-w-0 flex-auto">
|
||||
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
|
||||
<p
|
||||
className={cn(
|
||||
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
|
||||
isActive && "font-bold",
|
||||
)}
|
||||
>
|
||||
{link.title}
|
||||
</p>
|
||||
|
||||
<div className="group flex items-center gap-x-1">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
|
||||
/>
|
||||
|
||||
<Link
|
||||
to={ensureUrlProtocol(link.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="line-clamp-1">{link.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
LinkItem.displayName = "LinkItem"
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from "react"
|
||||
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
|
||||
import {
|
||||
Link as LinkSchema,
|
||||
Section as SectionSchema,
|
||||
Topic,
|
||||
} from "@/lib/schema"
|
||||
import { LinkItem } from "./-item"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
|
||||
export type FlattenedItem =
|
||||
| { type: "link"; data: LinkSchema | null }
|
||||
| { type: "section"; data: SectionSchema | null }
|
||||
|
||||
interface TopicDetailListProps {
|
||||
items: FlattenedItem[]
|
||||
topic: Topic
|
||||
activeIndex: number
|
||||
setActiveIndex: (index: number) => void
|
||||
}
|
||||
|
||||
export function TopicDetailList({
|
||||
items,
|
||||
topic,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
}: TopicDetailListProps) {
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
const personalLinks =
|
||||
!me || me._type === "Anonymous" ? undefined : me.root.personalLinks
|
||||
|
||||
const parentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 44,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(virtualRow: VirtualItem) => {
|
||||
const item = items[virtualRow.index]
|
||||
|
||||
if (item.type === "section") {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{item.data?.title}
|
||||
</p>
|
||||
<div className="flex-1 border-b" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.data?.id) {
|
||||
return (
|
||||
<LinkItem
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
topic={topic}
|
||||
link={item.data as LinkSchema}
|
||||
isActive={activeIndex === virtualRow.index}
|
||||
index={virtualRow.index}
|
||||
setActiveIndex={setActiveIndex}
|
||||
personalLinks={personalLinks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks],
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="flex-1 overflow-auto">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user