mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
fix: conflict
This commit is contained in:
141
web/components/routes/OnboardingRoute.tsx
Normal file
141
web/components/routes/OnboardingRoute.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import React, { useEffect } from "react"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import { LaIcon } from "../custom/la-icon"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAtom } from "jotai"
|
||||
|
||||
const isCreateLinkDoneAtom = atomWithStorage("isCreateLinkDone", false)
|
||||
const isCreatePageDoneAtom = atomWithStorage("isCreatePageDone", false)
|
||||
const isStartTrackingDoneAtom = atomWithStorage("isStartTrackingDone", false)
|
||||
const isAddLinkDoneAtom = atomWithStorage("isAddLinkDone", false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Create Link",
|
||||
description:
|
||||
"Links are essentially bookmarks of things from internet. You can create a link by pressing Links button in left sidebar. Then pressing + button on the bottom.",
|
||||
task: "create any Link with any title or description (for example, you can add https://learn-anything.xyz as link)"
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Create Page",
|
||||
description:
|
||||
"Pages are things with content inside (images, text, anything). You can think of them as Notion pages. To create page, press the + button next to pages, then create title and put some content.",
|
||||
task: "create any Page with any content inside"
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Start tracking Learning status of some Topic",
|
||||
description:
|
||||
"What makes Learn Anything different from Notion and other tools is notion of topics. A topic is anything after learn-anything.xyz/<topic>, for example learn-anything.xyz/typescript. You can go to the page, then on top right corner where it says add to my profile, press it and change the state of the topic to I want to learn, Learning or Learned.",
|
||||
task: "go to any Topic, and mark it as I want to learn"
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Add a Link from a Topic into personal link collection",
|
||||
description:
|
||||
"If you noticed, there are links attached to topics as a list. This is the topic's study guide. It will be improved greatly in future and we will allow any user to edit these study guides too (Reddit style). You can click on the circle to left of the links and add a link to your personal collection with learning status too.",
|
||||
task: "add any Link from topic typescript into your personal collection"
|
||||
}
|
||||
]
|
||||
|
||||
const StepItem = ({
|
||||
number,
|
||||
title,
|
||||
description,
|
||||
task,
|
||||
done
|
||||
}: {
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
task: string
|
||||
done: boolean
|
||||
}) => (
|
||||
<div className="flex items-start space-x-4 py-4">
|
||||
<div className="border-foreground/20 w-6 flex-shrink-0 items-center justify-center rounded-3xl border text-center opacity-70">
|
||||
{number}
|
||||
</div>
|
||||
<div className="flex-grow space-y-2">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<p className="w-[90%] leading-relaxed opacity-70">{description}</p>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<LaIcon name={done ? "SquareCheck" : "Square"} className={`${done ? "text-green-500" : ""}`} />
|
||||
<p className={`${done ? "opacity-35" : ""}`}>{task}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function OnboardingRoute() {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
personalPages: [],
|
||||
personalLinks: [],
|
||||
topicsWantToLearn: []
|
||||
}
|
||||
})
|
||||
|
||||
const [isCreateLinkDone, setIsCreateLinkDone] = useAtom(isCreateLinkDoneAtom)
|
||||
const [isCreatePageDone, setIsCreatePageDone] = useAtom(isCreatePageDoneAtom)
|
||||
const [isStartTrackingDone, setIsStartTrackingDone] = useAtom(isStartTrackingDoneAtom)
|
||||
const [isAddLinkDone, setIsAddLinkDone] = useAtom(isAddLinkDoneAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
|
||||
if (me.root.personalLinks.length > 0 && !isCreateLinkDone) {
|
||||
setIsCreateLinkDone(true)
|
||||
}
|
||||
|
||||
if (me.root.personalPages.length > 0 && !isCreatePageDone) {
|
||||
setIsCreatePageDone(true)
|
||||
}
|
||||
|
||||
if (me.root.topicsWantToLearn.length > 0 && !isStartTrackingDone) {
|
||||
setIsStartTrackingDone(true)
|
||||
}
|
||||
|
||||
if (me.root.personalLinks.some(link => link?.topic?.name === "typescript") && !isAddLinkDone) {
|
||||
setIsAddLinkDone(true)
|
||||
}
|
||||
}, [
|
||||
me,
|
||||
isCreateLinkDone,
|
||||
isCreatePageDone,
|
||||
setIsCreateLinkDone,
|
||||
setIsCreatePageDone,
|
||||
isAddLinkDone,
|
||||
setIsAddLinkDone,
|
||||
isStartTrackingDone,
|
||||
setIsStartTrackingDone
|
||||
])
|
||||
|
||||
const completedSteps = [isCreateLinkDone, isCreatePageDone, isStartTrackingDone, isAddLinkDone].filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4 text-sm text-black dark:text-white">
|
||||
<div className="ml-10 flex flex-col items-start border-b border-neutral-200 bg-inherit dark:border-neutral-900">
|
||||
<p className="h-[70px] p-[20px] text-2xl font-semibold opacity-60">Onboarding</p>
|
||||
</div>
|
||||
<div className="mx-auto w-[70%] rounded-lg border border-neutral-200 bg-inherit p-6 shadow dark:border-neutral-900">
|
||||
<h2 className="mb-4 text-lg font-semibold">Complete the steps below to get started</h2>
|
||||
<p className="mb-4">
|
||||
Completed {completedSteps} out of {steps.length} steps
|
||||
</p>
|
||||
<div className="divide-y">
|
||||
{steps.map((step, index) => (
|
||||
<StepItem
|
||||
key={step.number}
|
||||
{...step}
|
||||
done={[isCreateLinkDone, isCreatePageDone, isStartTrackingDone, isAddLinkDone][index]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -97,7 +97,6 @@ export const SettingsRoute = () => {
|
||||
const [topInboxHotkey, setTopInboxHotkey] = useState("")
|
||||
|
||||
const saveSettings = () => {
|
||||
console.log("Saving settings:", { inboxHotkey, topInboxHotkey })
|
||||
toast.success("Settings saved", {
|
||||
description: "Your hotkey settings have been updated."
|
||||
})
|
||||
|
||||
74
web/components/routes/community/CommunityTopicRoute.tsx
Normal file
74
web/components/routes/community/CommunityTopicRoute.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { GuideCommunityToggle } from "@/components/custom/GuideCommunityToggle"
|
||||
import { QuestionList } from "@/components/custom/QuestionList"
|
||||
import { QuestionThread } from "@/components/custom/QuestionThread"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
|
||||
interface CommunityTopicRouteProps {
|
||||
topicName: string
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export function CommunityTopicRoute({ topicName }: CommunityTopicRouteProps) {
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me])
|
||||
const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } })
|
||||
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<Question | null>(null)
|
||||
|
||||
if (!topic) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<ContentHeader className="px-6 py-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 flex-col items-start">
|
||||
<p className="opacity-40">Topic</p>
|
||||
<span className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<GuideCommunityToggle topicName={topic.name} />
|
||||
</ContentHeader>
|
||||
<div className="relative flex flex-1 justify-center overflow-hidden">
|
||||
<div
|
||||
className={`w-1/2 overflow-y-auto p-3 transition-all duration-300 ${
|
||||
selectedQuestion ? "opacity-700 translate-x-[-50%]" : ""
|
||||
}`}
|
||||
>
|
||||
<QuestionList
|
||||
topicName={topic.name}
|
||||
onSelectQuestion={(question: Question) => setSelectedQuestion(question)}
|
||||
/>
|
||||
</div>
|
||||
{selectedQuestion && (
|
||||
<div className="absolute right-0 top-0 h-full w-1/2 overflow-y-auto">
|
||||
<QuestionThread
|
||||
question={{
|
||||
id: selectedQuestion.id,
|
||||
title: selectedQuestion.title,
|
||||
author: selectedQuestion.author,
|
||||
timestamp: selectedQuestion.timestamp
|
||||
}}
|
||||
onClose={() => setSelectedQuestion(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
web/components/routes/journal/JournalRoute.tsx
Normal file
114
web/components/routes/journal/JournalRoute.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { JournalEntry, JournalEntryLists } from "@/lib/schema/journal"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { calendarFormatDate } from "@/lib/utils"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
|
||||
export function JournalRoute() {
|
||||
const [date, setDate] = useState<Date>(new Date())
|
||||
const { me } = useAccount({ root: { journalEntries: [] } })
|
||||
const [newNote, setNewNote] = useState<JournalEntry | null>(null)
|
||||
|
||||
const notes = me?.root?.journalEntries || (me ? JournalEntryLists.create([], { owner: me }) : [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log("me:", me)
|
||||
}, [me])
|
||||
|
||||
const selectDate = (selectedDate: Date | undefined) => {
|
||||
if (selectedDate) {
|
||||
setDate(selectedDate)
|
||||
}
|
||||
}
|
||||
|
||||
const createNewNote = () => {
|
||||
if (me) {
|
||||
const newEntry = JournalEntry.create(
|
||||
{
|
||||
title: "",
|
||||
content: "",
|
||||
date: date,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{ owner: me._owner }
|
||||
)
|
||||
setNewNote(newEntry)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewNoteChange = (field: keyof JournalEntry, value: string) => {
|
||||
if (newNote) {
|
||||
setNewNote(prevNote => {
|
||||
if (prevNote) {
|
||||
return JournalEntry.create({ ...prevNote, [field]: value }, { owner: me!._owner })
|
||||
}
|
||||
return prevNote
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const saveNewNote = () => {
|
||||
if (newNote && me?.root?.journalEntries) {
|
||||
me.root.journalEntries.push(newNote)
|
||||
setNewNote(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<div className="flex-grow overflow-y-auto p-6">
|
||||
{newNote ? (
|
||||
<div className="mb-6 rounded-lg border p-4 shadow-sm">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={newNote.title}
|
||||
onChange={e => handleNewNoteChange("title", e.target.value)}
|
||||
className="mb-2 w-full text-xl font-semibold"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Content"
|
||||
value={newNote.content as string}
|
||||
onChange={e => handleNewNoteChange("content", e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<Button onClick={saveNewNote} className="mt-2">
|
||||
Save Note
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{notes.map((entry, index) => (
|
||||
<div key={index} className="mb-6 rounded-lg border p-4 shadow-sm">
|
||||
<h2 className="mb-2 text-xl font-semibold">{entry?.title}</h2>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{entry?.content &&
|
||||
(typeof entry.content === "string" ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: entry.content }} />
|
||||
) : (
|
||||
<pre>{JSON.stringify(entry.content, null, 2)}</pre>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm opacity-70">{entry?.date && calendarFormatDate(new Date(entry.date))}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-[22%] border-l p-2">
|
||||
<Calendar mode="single" selected={date} onSelect={selectDate} className="rounded-md border" />
|
||||
<Button onClick={createNewNote} className="mt-4 w-full">
|
||||
New Note
|
||||
</Button>
|
||||
<div className="p-2 text-sm opacity-50">
|
||||
<p>Total notes: {notes.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { LinkHeader } from "@/components/routes/link/header"
|
||||
import { LinkList } from "@/components/routes/link/list"
|
||||
import { LinkManage } from "@/components/routes/link/manage"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useEffect } from "react"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkEditIdAtom } from "@/store/link"
|
||||
import { atom } from "jotai"
|
||||
import { LinkBottomBar } from "./bottom-bar"
|
||||
|
||||
export function LinkRoute() {
|
||||
const [, setEditId] = useAtom(linkEditIdAtom)
|
||||
const [nuqsEditId] = useQueryState("editId")
|
||||
|
||||
useEffect(() => {
|
||||
setEditId(nuqsEditId)
|
||||
}, [nuqsEditId, setEditId])
|
||||
export const isDeleteConfirmShownAtom = atom(false)
|
||||
|
||||
export function LinkRoute(): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<>
|
||||
<LinkHeader />
|
||||
<LinkManage />
|
||||
<LinkList />
|
||||
<LinkBottomBar />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,73 +1,81 @@
|
||||
import React, { useEffect, useRef } from "react"
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { icons } from "lucide-react"
|
||||
import type { icons } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkShowCreateAtom } from "@/store/link"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLink } from "@/lib/schema"
|
||||
import { ID } from "jazz-tools"
|
||||
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
|
||||
import { toast } from "sonner"
|
||||
import { useLinkActions } from "./hooks/use-link-actions"
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||
icon: keyof typeof icons
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(({ icon, onClick, tooltip }, ref) => {
|
||||
const button = (
|
||||
<Button variant="ghost" className="h-8 min-w-14" onClick={onClick} ref={ref}>
|
||||
<LaIcon name={icon} />
|
||||
</Button>
|
||||
)
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||
({ icon, onClick, tooltip, className, ...props }, ref) => {
|
||||
const button = (
|
||||
<Button variant="ghost" className={cn("h-8 min-w-14 p-0", className)} onClick={onClick} ref={ref} {...props}>
|
||||
<LaIcon name={icon} />
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
})
|
||||
return button
|
||||
}
|
||||
)
|
||||
|
||||
ToolbarButton.displayName = "ToolbarButton"
|
||||
|
||||
export const LinkBottomBar: React.FC = () => {
|
||||
const [editId, setEditId] = useQueryState("editId")
|
||||
const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean)
|
||||
const [, setGlobalLinkFormExceptionRefsAtom] = useAtom(globalLinkFormExceptionRefsAtom)
|
||||
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
|
||||
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>)
|
||||
|
||||
const cancelBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const cancelBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const deleteBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const plusBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const plusMoreBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const deleteBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalLinkFormExceptionRefsAtom([
|
||||
const handleCreateMode = React.useCallback(() => {
|
||||
setEditId(null)
|
||||
requestAnimationFrame(() => {
|
||||
setCreateMode(prev => !prev)
|
||||
})
|
||||
}, [setEditId, setCreateMode])
|
||||
|
||||
const exceptionRefs = React.useMemo(
|
||||
() => [
|
||||
overlayRef,
|
||||
contentRef,
|
||||
deleteBtnRef,
|
||||
@@ -76,11 +84,16 @@ export const LinkBottomBar: React.FC = () => {
|
||||
confirmBtnRef,
|
||||
plusBtnRef,
|
||||
plusMoreBtnRef
|
||||
])
|
||||
}, [setGlobalLinkFormExceptionRefsAtom])
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
|
||||
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
if (!personalLink) return
|
||||
if (!personalLink || !me) return
|
||||
|
||||
const result = await confirm({
|
||||
title: `Delete "${personalLink.title}"?`,
|
||||
@@ -105,97 +118,60 @@ export const LinkBottomBar: React.FC = () => {
|
||||
})
|
||||
|
||||
if (result) {
|
||||
if (!me?.root.personalLinks) return
|
||||
|
||||
const index = me.root.personalLinks.findIndex(item => item?.id === personalLink.id)
|
||||
if (index === -1) {
|
||||
console.error("Delete operation fail", { index, personalLink })
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Link deleted.", {
|
||||
position: "bottom-right",
|
||||
description: (
|
||||
<span>
|
||||
<strong>{personalLink.title}</strong> has been deleted.
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
me.root.personalLinks.splice(index, 1)
|
||||
deleteLink(me, personalLink)
|
||||
setEditId(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isMacOS()) {
|
||||
if (event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n") {
|
||||
event.preventDefault()
|
||||
setShowCreate(true)
|
||||
}
|
||||
} else {
|
||||
// For Windows, we'll use Ctrl + Win + N
|
||||
// Note: The Windows key is not directly detectable in most browsers
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)) {
|
||||
event.preventDefault()
|
||||
setShowCreate(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [setShowCreate])
|
||||
|
||||
const shortcutKeys = getSpecialShortcut("expandToolbar")
|
||||
const shortcutText = formatShortcut(shortcutKeys)
|
||||
const shortcutText = getShortcutKeys(["c"])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-background absolute bottom-0 left-0 right-0 border-t"
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: "100%" }}
|
||||
>
|
||||
<div className="bg-background min-h-11 border-t">
|
||||
<AnimatePresence mode="wait">
|
||||
{editId && (
|
||||
<motion.div
|
||||
key="expanded"
|
||||
className="flex items-center justify-center gap-1 px-2 py-1"
|
||||
className="flex h-full items-center justify-center gap-1 border-t px-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} />
|
||||
<ToolbarButton icon={"Trash"} onClick={handleDelete} ref={deleteBtnRef} />
|
||||
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} />
|
||||
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} aria-label="Go back" />
|
||||
<ToolbarButton
|
||||
icon={"Trash"}
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:text-destructive"
|
||||
ref={deleteBtnRef}
|
||||
aria-label="Delete link"
|
||||
/>
|
||||
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} aria-label="More options" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!editId && (
|
||||
<motion.div
|
||||
key="collapsed"
|
||||
className="flex items-center justify-center gap-1 px-2 py-1"
|
||||
className="flex h-full items-center justify-center gap-1 px-2"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{showCreate && <ToolbarButton icon={"ArrowLeft"} onClick={() => setShowCreate(true)} />}
|
||||
{!showCreate && (
|
||||
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} aria-label="Go back" />}
|
||||
{!createMode && (
|
||||
<ToolbarButton
|
||||
icon={"Plus"}
|
||||
onClick={() => setShowCreate(true)}
|
||||
tooltip={`New Link (${shortcutText})`}
|
||||
onClick={handleCreateMode}
|
||||
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
|
||||
ref={plusBtnRef}
|
||||
aria-label="New link"
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton icon={"Ellipsis"} ref={plusMoreBtnRef} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ListFilterIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -15,6 +14,7 @@ import { LEARNING_STATES } from "@/lib/constants"
|
||||
import { useQueryState, parseAsStringLiteral } from "nuqs"
|
||||
import { FancySwitch } from "@omit/react-fancy-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
|
||||
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
|
||||
@@ -26,11 +26,11 @@ export const LinkHeader = React.memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<ContentHeader className="px-6 max-lg:px-4 lg:py-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left text-xl font-bold">Links</span>
|
||||
<span className="truncate text-left font-bold lg:text-xl">Links</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => {
|
||||
</ContentHeader>
|
||||
|
||||
{isTablet && (
|
||||
<div className="flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4">
|
||||
<div className="flex flex-row items-start justify-between border-b px-6 pb-4 pt-2 max-lg:pl-4">
|
||||
<LearningTab />
|
||||
</div>
|
||||
)}
|
||||
@@ -61,15 +61,19 @@ const LearningTab = React.memo(() => {
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setActiveTab(value)
|
||||
setActiveState(value)
|
||||
if (value !== activeTab) {
|
||||
setActiveTab(value)
|
||||
setActiveState(value)
|
||||
}
|
||||
},
|
||||
[setActiveTab, setActiveState]
|
||||
[activeTab, setActiveTab, setActiveState]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveTab(activeState)
|
||||
}, [activeState, setActiveTab])
|
||||
if (activeState !== activeTab) {
|
||||
setActiveTab(activeState)
|
||||
}
|
||||
}, [activeState, activeTab, setActiveTab])
|
||||
|
||||
return (
|
||||
<FancySwitch
|
||||
@@ -78,8 +82,8 @@ const LearningTab = React.memo(() => {
|
||||
handleTabChange(value as string)
|
||||
}}
|
||||
options={ALL_STATES}
|
||||
className="bg-secondary flex rounded-lg"
|
||||
highlighterClassName="bg-secondary-foreground/10 rounded-lg"
|
||||
className="bg-muted flex rounded-lg"
|
||||
highlighterClassName="bg-muted-foreground/10 rounded-md"
|
||||
radioClassName={cn(
|
||||
"relative mx-2 flex h-8 cursor-pointer items-center justify-center rounded-full px-1 text-sm text-secondary-foreground/60 data-[checked]:text-secondary-foreground font-medium transition-colors focus:outline-none"
|
||||
)}
|
||||
@@ -111,8 +115,8 @@ const FilterAndSort = React.memo(() => {
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm">
|
||||
<ListFilterIcon size={16} className="text-primary/60" />
|
||||
<Button size="sm" type="button" variant="secondary" className="min-w-8 gap-x-2 text-sm max-sm:p-0">
|
||||
<LaIcon name="ListFilter" className="text-primary/60" />
|
||||
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
32
web/components/routes/link/hooks/use-link-actions.ts
Normal file
32
web/components/routes/link/hooks/use-link-actions.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount, PersonalLink } from "@/lib/schema"
|
||||
|
||||
export const useLinkActions = () => {
|
||||
const deleteLink = React.useCallback((me: LaAccount, link: PersonalLink) => {
|
||||
if (!me.root?.personalLinks) return
|
||||
|
||||
try {
|
||||
const index = me.root.personalLinks.findIndex(item => item?.id === link.id)
|
||||
if (index === -1) {
|
||||
throw new Error(`Link with id ${link.id} not found`)
|
||||
}
|
||||
|
||||
me.root.personalLinks.splice(index, 1)
|
||||
|
||||
toast.success("Link deleted.", {
|
||||
position: "bottom-right",
|
||||
description: `${link.title} has been deleted.`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete link:", error)
|
||||
toast.error("Failed to delete link", {
|
||||
description: error instanceof Error ? error.message : "An unknown error occurred"
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
deleteLink
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -9,36 +8,55 @@ import {
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
UniqueIdentifier
|
||||
UniqueIdentifier,
|
||||
MeasuringStrategy,
|
||||
TouchSensor
|
||||
} from "@dnd-kit/core"
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import type { MeasuringConfiguration } from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkSortAtom } from "@/store/link"
|
||||
import { useKey } from "react-use"
|
||||
import { LinkItem } from "./partials/link-item"
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
import { learningStateAtom } from "./header"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useLinkActions } from "./hooks/use-link-actions"
|
||||
import { isDeleteConfirmShownAtom } from "./LinkRoute"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { useTouchSensor } from "@/hooks/use-touch-sensor"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
import { isModKey } from "@/lib/utils"
|
||||
|
||||
interface LinkListProps {}
|
||||
|
||||
const measuring: MeasuringConfiguration = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always
|
||||
}
|
||||
}
|
||||
|
||||
const LinkList: React.FC<LinkListProps> = () => {
|
||||
const isTouchDevice = useTouchSensor()
|
||||
const lastActiveIndexRef = React.useRef<number | null>(null)
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(null)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<number | null>(null)
|
||||
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
|
||||
const [editId, setEditId] = useQueryState("editId")
|
||||
const [createMode] = useQueryState("create", parseAsBoolean)
|
||||
const [activeLearningState] = useAtom(learningStateAtom)
|
||||
|
||||
const { me } = useAccount({
|
||||
root: { personalLinks: [] }
|
||||
})
|
||||
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
||||
|
||||
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null)
|
||||
const [sort] = useAtom(linkSortAtom)
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<UniqueIdentifier | null>(null)
|
||||
const linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({})
|
||||
|
||||
const filteredLinks = useMemo(
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
|
||||
const personalLinks = React.useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
||||
|
||||
const filteredLinks = React.useMemo(
|
||||
() =>
|
||||
personalLinks.filter(link => {
|
||||
if (activeLearningState === "all") return true
|
||||
@@ -48,7 +66,7 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
[personalLinks, activeLearningState]
|
||||
)
|
||||
|
||||
const sortedLinks = useMemo(
|
||||
const sortedLinks = React.useMemo(
|
||||
() =>
|
||||
sort === "title"
|
||||
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
||||
@@ -56,10 +74,22 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
[filteredLinks, sort]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editId !== null) {
|
||||
const index = sortedLinks.findIndex(link => link?.id === editId)
|
||||
if (index !== -1) {
|
||||
lastActiveIndexRef.current = index
|
||||
setActiveItemIndex(index)
|
||||
setKeyboardActiveIndex(index)
|
||||
}
|
||||
}
|
||||
}, [editId, setActiveItemIndex, setKeyboardActiveIndex, sortedLinks])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
...(isTouchDevice ? { delay: 100, tolerance: 5 } : {}),
|
||||
distance: 5
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
@@ -67,17 +97,7 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
})
|
||||
)
|
||||
|
||||
const registerRef = useCallback((id: string, ref: HTMLLIElement | null) => {
|
||||
linkRefs.current[id] = ref
|
||||
}, [])
|
||||
|
||||
useKey("Escape", () => {
|
||||
if (editId) {
|
||||
setEditId(null)
|
||||
}
|
||||
})
|
||||
|
||||
const updateSequences = useCallback((links: PersonalLinkLists) => {
|
||||
const updateSequences = React.useCallback((links: PersonalLinkLists) => {
|
||||
links.forEach((link, index) => {
|
||||
if (link) {
|
||||
link.sequence = index
|
||||
@@ -85,69 +105,73 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
|
||||
const handleDeleteLink = React.useCallback(async () => {
|
||||
if (activeItemIndex === null) return
|
||||
setIsDeleteConfirmShown(true)
|
||||
const activeLink = sortedLinks[activeItemIndex]
|
||||
if (!activeLink || !me) return
|
||||
|
||||
const currentIndex = sortedLinks.findIndex(link => link?.id === focusedId)
|
||||
const result = await confirm({
|
||||
title: `Delete "${activeLink.title}"?`,
|
||||
description: "This action cannot be undone.",
|
||||
alertDialogTitle: { className: "text-base" },
|
||||
cancelButton: { variant: "outline" },
|
||||
confirmButton: { variant: "destructive" }
|
||||
})
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex =
|
||||
e.key === "ArrowUp" ? Math.max(0, currentIndex - 1) : Math.min(sortedLinks.length - 1, currentIndex + 1)
|
||||
|
||||
if (e.metaKey && sort === "manual") {
|
||||
const currentLink = me.root.personalLinks[currentIndex]
|
||||
if (!currentLink) return
|
||||
|
||||
const linksArray = [...me.root.personalLinks]
|
||||
const newLinks = arrayMove(linksArray, currentIndex, newIndex)
|
||||
|
||||
while (me.root.personalLinks.length > 0) {
|
||||
me.root.personalLinks.pop()
|
||||
}
|
||||
|
||||
newLinks.forEach(link => {
|
||||
if (link) {
|
||||
me.root.personalLinks.push(link)
|
||||
}
|
||||
})
|
||||
|
||||
updateSequences(me.root.personalLinks)
|
||||
|
||||
const newFocusedLink = me.root.personalLinks[newIndex]
|
||||
if (newFocusedLink) {
|
||||
setFocusedId(newFocusedLink.id)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
linkRefs.current[newFocusedLink.id]?.focus()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const newFocusedLink = sortedLinks[newIndex]
|
||||
if (newFocusedLink) {
|
||||
setFocusedId(newFocusedLink.id)
|
||||
requestAnimationFrame(() => {
|
||||
linkRefs.current[newFocusedLink.id]?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result) {
|
||||
deleteLink(me, activeLink)
|
||||
}
|
||||
setIsDeleteConfirmShown(false)
|
||||
}, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown])
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort, updateSequences])
|
||||
useKeyDown(e => isModKey(e) && e.key === "Backspace", handleDeleteLink)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
const next = () => Math.min((activeItemIndex ?? 0) + 1, sortedLinks.length - 1)
|
||||
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
}
|
||||
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const handleDragStart = React.useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (sort !== "manual") return
|
||||
if (!me) return
|
||||
|
||||
const { active } = event
|
||||
const activeIndex = me?.root.personalLinks.findIndex(item => item?.id === active.id)
|
||||
|
||||
if (activeIndex === -1) {
|
||||
console.error("Drag operation fail", { activeIndex, activeId: active.id })
|
||||
return
|
||||
}
|
||||
|
||||
setActiveItemIndex(activeIndex)
|
||||
setDraggingId(active.id)
|
||||
},
|
||||
[sort]
|
||||
[sort, me, setActiveItemIndex]
|
||||
)
|
||||
|
||||
const handleDragCancel = React.useCallback(() => {
|
||||
setDraggingId(null)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
@@ -190,39 +214,64 @@ const LinkList: React.FC<LinkListProps> = () => {
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItemIndex(null)
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({
|
||||
activeIndex: keyboardActiveIndex
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mb-14 flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
measuring={measuring}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<div className="relative flex h-full grow items-stretch overflow-hidden" tabIndex={-1}>
|
||||
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
||||
<ul role="list" className="divide-primary/5 divide-y">
|
||||
{sortedLinks.map(
|
||||
linkItem =>
|
||||
linkItem && (
|
||||
<LinkItem
|
||||
key={linkItem.id}
|
||||
isEditing={editId === linkItem.id}
|
||||
setEditId={setEditId}
|
||||
personalLink={linkItem}
|
||||
disabled={sort !== "manual" || editId !== null}
|
||||
registerRef={registerRef}
|
||||
isDragging={draggingId === linkItem.id}
|
||||
isFocused={focusedId === linkItem.id}
|
||||
setFocusedId={setFocusedId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
<div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
|
||||
<div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
|
||||
<div className="relative overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
|
||||
{sortedLinks.map(
|
||||
(linkItem, index) =>
|
||||
linkItem && (
|
||||
<LinkItem
|
||||
key={linkItem.id}
|
||||
isActive={activeItemIndex === index}
|
||||
personalLink={linkItem}
|
||||
editId={editId}
|
||||
disabled={sort !== "manual" || editId !== null}
|
||||
onPointerMove={() => {
|
||||
if (editId !== null || draggingId !== null || createMode) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
onFormClose={() => {
|
||||
setEditId(null)
|
||||
setActiveItemIndex(lastActiveIndexRef.current)
|
||||
setKeyboardActiveIndex(lastActiveIndexRef.current)
|
||||
}}
|
||||
index={index}
|
||||
onItemSelected={link => setEditId(link.id)}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
ref={el => setElementRef(el, index)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { linkShowCreateAtom } from "@/store/link"
|
||||
import { useAtom } from "jotai"
|
||||
import { useKey } from "react-use"
|
||||
import { LinkForm } from "./partials/form/link-form"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
|
||||
interface LinkManageProps {}
|
||||
|
||||
const LinkManage: React.FC<LinkManageProps> = () => {
|
||||
const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom)
|
||||
const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean)
|
||||
|
||||
const handleFormClose = () => setShowCreate(false)
|
||||
const handleFormFail = () => {}
|
||||
|
||||
useKey("Escape", handleFormClose)
|
||||
const handleFormClose = () => setCreateMode(false)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showCreate && (
|
||||
{createMode && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />
|
||||
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
|
||||
<TextareaAutosize
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Description (optional)"
|
||||
placeholder="Description"
|
||||
className="placeholder:text-muted-foreground/70 resize-none overflow-y-auto border-none p-1.5 text-[13px] font-medium shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FormField, FormItem, FormLabel } from "@/components/ui/form"
|
||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||
import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { useOnClickOutside } from "@/hooks/use-on-click-outside"
|
||||
|
||||
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
|
||||
|
||||
@@ -78,26 +79,16 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
[exceptionsRefs, globalExceptionRefs]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const isClickInsideForm = formRef.current && formRef.current.contains(event.target as Node)
|
||||
|
||||
const isClickInsideExceptions = allExceptionRefs.some((ref, index) => {
|
||||
const isInside = ref.current && ref.current.contains(event.target as Node)
|
||||
return isInside
|
||||
})
|
||||
|
||||
if (!isClickInsideForm && !istopicSelectorOpen && !islearningStateSelectorOpen && !isClickInsideExceptions) {
|
||||
onClose?.()
|
||||
}
|
||||
useOnClickOutside(formRef, event => {
|
||||
if (
|
||||
!istopicSelectorOpen &&
|
||||
!islearningStateSelectorOpen &&
|
||||
!allExceptionRefs.some(ref => ref.current?.contains(event.target as Node))
|
||||
) {
|
||||
console.log("clicking outside")
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose])
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedLink) {
|
||||
@@ -118,7 +109,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
const fetchMetadata = async (url: string) => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "no-cache" })
|
||||
const res = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { cache: "force-cache" })
|
||||
const data = await res.json()
|
||||
setUrlFetched(data.url)
|
||||
form.setValue("url", data.url, {
|
||||
@@ -135,7 +126,6 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
shouldValidate: true
|
||||
})
|
||||
form.setFocus("title")
|
||||
console.log(form.formState.isValid, "form state after....")
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch metadata", err)
|
||||
} finally {
|
||||
@@ -147,8 +137,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
if (isFetching || !me) return
|
||||
|
||||
try {
|
||||
const personalLinks = me.root?.personalLinks?.toJSON() || []
|
||||
const slug = generateUniqueSlug(personalLinks, values.title)
|
||||
const slug = generateUniqueSlug(values.title)
|
||||
|
||||
if (selectedLink) {
|
||||
const { topic, ...diffValues } = values
|
||||
@@ -195,7 +184,15 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
const canSubmit = form.formState.isValid && !form.formState.isSubmitting
|
||||
|
||||
return (
|
||||
<div className="p-3 transition-all">
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="p-3 transition-all"
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape") {
|
||||
handleCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("bg-muted/30 relative rounded-md border", isFetching && "opacity-50")}>
|
||||
<Form {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1">
|
||||
@@ -215,7 +212,6 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
<LearningStateSelector
|
||||
value={field.value}
|
||||
onChange={value => {
|
||||
// toggle, if already selected set undefined
|
||||
form.setValue("learningState", field.value === value ? undefined : value)
|
||||
}}
|
||||
showSearch={false}
|
||||
@@ -233,7 +229,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
<TopicSelector
|
||||
{...field}
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">{selectedTopic?.prettyName || "Select a topic"}</span>
|
||||
<span className="truncate">{selectedTopic?.prettyName || "Topic"}</span>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => {
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Take a notes..."
|
||||
placeholder="Notes"
|
||||
className={cn("placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0")}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormField, FormItem, FormControl, FormLabel, FormMessage } from "@/comp
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LinkFormValues } from "./schema"
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||
import { TooltipArrow } from "@radix-ui/react-tooltip"
|
||||
|
||||
interface UrlInputProps {
|
||||
@@ -38,30 +38,28 @@ export const UrlInput: React.FC<UrlInputProps> = ({ urlFetched, fetchMetadata, i
|
||||
>
|
||||
<FormLabel className="sr-only">Url</FormLabel>
|
||||
<FormControl>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip open={shouldShowTooltip && !isFetchingUrlMetadata}>
|
||||
<TooltipTrigger asChild>
|
||||
<Input
|
||||
{...field}
|
||||
type={urlFetched ? "hidden" : "text"}
|
||||
autoComplete="off"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Paste a link or write a link"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="center" side="top">
|
||||
<TooltipArrow className="text-primary fill-current" />
|
||||
<span>
|
||||
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip open={shouldShowTooltip && !isFetchingUrlMetadata}>
|
||||
<TooltipTrigger asChild>
|
||||
<Input
|
||||
{...field}
|
||||
type={urlFetched ? "hidden" : "text"}
|
||||
autoComplete="off"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
placeholder="Paste a link or write a link"
|
||||
className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="center" side="top">
|
||||
<TooltipArrow className="text-primary fill-current" />
|
||||
<span>
|
||||
Press <kbd className="px-1.5">Enter</kbd> to fetch metadata
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
<FormMessage className="px-1.5" />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useAtom } from "jotai"
|
||||
@@ -13,107 +11,108 @@ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
import { PersonalLink } from "@/lib/schema/personal-link"
|
||||
import { LinkForm } from "./form/link-form"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn, ensureUrlProtocol } from "@/lib/utils"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkOpenPopoverForIdAtom, linkShowCreateAtom } from "@/store/link"
|
||||
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
||||
|
||||
interface LinkItemProps {
|
||||
interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
personalLink: PersonalLink
|
||||
disabled?: boolean
|
||||
isEditing: boolean
|
||||
setEditId: (id: string | null) => void
|
||||
isDragging: boolean
|
||||
isFocused: boolean
|
||||
setFocusedId: (id: string | null) => void
|
||||
registerRef: (id: string, ref: HTMLLIElement | null) => void
|
||||
editId: string | null
|
||||
isActive: boolean
|
||||
index: number
|
||||
onItemSelected?: (personalLink: PersonalLink) => void
|
||||
onFormClose?: () => void
|
||||
}
|
||||
|
||||
export const LinkItem: React.FC<LinkItemProps> = ({
|
||||
isEditing,
|
||||
setEditId,
|
||||
personalLink,
|
||||
disabled = false,
|
||||
isDragging,
|
||||
isFocused,
|
||||
setFocusedId,
|
||||
registerRef
|
||||
}) => {
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||
export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
({ personalLink, disabled, editId, isActive, index, onItemSelected, onFormClose, ...props }, ref) => {
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
pointerEvents: isDragging ? "none" : "auto"
|
||||
}),
|
||||
[transform, transition, isDragging]
|
||||
)
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
}),
|
||||
[transform, transition]
|
||||
)
|
||||
|
||||
const refCallback = useCallback(
|
||||
(node: HTMLLIElement | null) => {
|
||||
setNodeRef(node)
|
||||
registerRef(personalLink.id, node)
|
||||
},
|
||||
[setNodeRef, registerRef, personalLink.id]
|
||||
)
|
||||
const selectedLearningState = React.useMemo(
|
||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
setEditId(personalLink.id)
|
||||
}
|
||||
},
|
||||
[setEditId, personalLink.id]
|
||||
)
|
||||
const handleLearningStateSelect = React.useCallback(
|
||||
(value: string) => {
|
||||
const learningState = value as LearningStateValue
|
||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[personalLink, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onItemSelected?.(personalLink)
|
||||
}
|
||||
},
|
||||
[personalLink, onItemSelected]
|
||||
)
|
||||
|
||||
const selectedLearningState = useMemo(
|
||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState]
|
||||
)
|
||||
if (editId === personalLink.id) {
|
||||
return <LinkForm onClose={onFormClose} personalLink={personalLink} onSuccess={onFormClose} onFail={() => {}} />
|
||||
}
|
||||
|
||||
const handleLearningStateSelect = useCallback(
|
||||
(value: string) => {
|
||||
const learningState = value as LearningStateValue
|
||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[personalLink, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={refCallback}
|
||||
style={style as React.CSSProperties}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onFocus={() => setFocusedId(personalLink.id)}
|
||||
onBlur={() => setFocusedId(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("relative flex h-14 cursor-default items-center outline-none xl:h-11", {
|
||||
"bg-muted-foreground/10": isFocused,
|
||||
"hover:bg-muted/50": !isFocused
|
||||
})}
|
||||
onDoubleClick={handleRowDoubleClick}
|
||||
>
|
||||
<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">
|
||||
return (
|
||||
<div
|
||||
ref={node => {
|
||||
setNodeRef(node)
|
||||
if (typeof ref === "function") {
|
||||
ref(node)
|
||||
} else if (ref) {
|
||||
ref.current = node
|
||||
}
|
||||
}}
|
||||
style={style as React.CSSProperties}
|
||||
{...props}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onDoubleClick={() => onItemSelected?.(personalLink)}
|
||||
aria-disabled={disabled}
|
||||
aria-selected={isActive}
|
||||
data-disabled={disabled}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"w-full overflow-visible border-b-[0.5px] border-transparent outline-none",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full grow overflow-visible outline-none",
|
||||
"flex items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2"
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
open={openPopoverForId === personalLink.id}
|
||||
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onDoubleClick={e => e.stopPropagation()}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||
) : (
|
||||
@@ -121,12 +120,7 @@ export const LinkItem: React.FC<LinkItemProps> = ({
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
@@ -136,47 +130,51 @@ export const LinkItem: React.FC<LinkItemProps> = ({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{personalLink.icon && (
|
||||
<Image
|
||||
src={personalLink.icon}
|
||||
alt={personalLink.title}
|
||||
className="size-5 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<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="text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate">
|
||||
{personalLink.title}
|
||||
</p>
|
||||
{personalLink.url && (
|
||||
<div className="group flex items-center gap-x-1">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary flex-none"
|
||||
/>
|
||||
<Link
|
||||
href={personalLink.url}
|
||||
passHref
|
||||
prefetch={false}
|
||||
target="_blank"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="xl:truncate">{personalLink.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start gap-y-1.5 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{personalLink.icon && (
|
||||
<Image
|
||||
src={personalLink.icon}
|
||||
alt={personalLink.title}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium">{personalLink.title}</p>
|
||||
</div>
|
||||
{personalLink.url && (
|
||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
||||
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
|
||||
<Link
|
||||
href={ensureUrlProtocol(personalLink.url)}
|
||||
passHref
|
||||
prefetch={false}
|
||||
target="_blank"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:text-primary mr-1 truncate text-xs"
|
||||
>
|
||||
{personalLink.url}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
{personalLink.topic && (
|
||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
||||
{personalLink.topic.prettyName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-x-4">
|
||||
{personalLink.topic && <Badge variant="secondary">{personalLink.topic.prettyName}</Badge>}
|
||||
</div>
|
||||
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--link-border-after)]"></div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
LinkItem.displayName = "LinkItem"
|
||||
|
||||
13
web/components/routes/page/PageRoute.tsx
Normal file
13
web/components/routes/page/PageRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "./header"
|
||||
import { PageList } from "./list"
|
||||
|
||||
export function PageRoute() {
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<PageHeader />
|
||||
<PageList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useCallback, useRef, useEffect } from "react"
|
||||
import { ID } from "jazz-tools"
|
||||
import { PersonalPage } from "@/lib/schema"
|
||||
import { useCallback, useRef, useEffect } from "react"
|
||||
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
||||
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
||||
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
||||
@@ -14,13 +13,13 @@ import { Editor } from "@tiptap/core"
|
||||
import { generateUniqueSlug } from "@/lib/utils"
|
||||
import { FocusClasses } from "@tiptap/extension-focus"
|
||||
import { DetailPageHeader } from "./header"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { TopicSelector } from "@/components/custom/topic-selector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { usePageActions } from "../hooks/use-page-actions"
|
||||
|
||||
const TITLE_PLACEHOLDER = "Untitled"
|
||||
|
||||
@@ -29,52 +28,24 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||
const isMobile = useMedia("(max-width: 770px)")
|
||||
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
||||
const router = useRouter()
|
||||
|
||||
const { deletePage } = usePageActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = useCallback(async () => {
|
||||
const result = await confirm({
|
||||
title: "Delete page",
|
||||
description: "Are you sure you want to delete this page?",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
cancelButton: {
|
||||
variant: "outline"
|
||||
},
|
||||
confirmButton: {
|
||||
variant: "destructive"
|
||||
}
|
||||
cancelButton: { variant: "outline" },
|
||||
confirmButton: { variant: "destructive" }
|
||||
})
|
||||
|
||||
if (result) {
|
||||
if (!me?.root.personalPages) return
|
||||
|
||||
try {
|
||||
const index = me.root.personalPages.findIndex(item => item?.id === pageId)
|
||||
if (index === -1) {
|
||||
toast.error("Page not found.")
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Page deleted.", {
|
||||
position: "bottom-right",
|
||||
description: (
|
||||
<span>
|
||||
<strong>{page?.title}</strong> has been deleted.
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
me.root.personalPages.splice(index, 1)
|
||||
|
||||
// push without history
|
||||
router.replace("/")
|
||||
} catch (error) {
|
||||
console.error("Delete operation fail", { error })
|
||||
return
|
||||
}
|
||||
if (result && me?.root.personalPages) {
|
||||
deletePage(me, pageId as ID<PersonalPage>)
|
||||
router.push("/pages")
|
||||
}
|
||||
}
|
||||
}, [confirm, deletePage, me, pageId, router])
|
||||
|
||||
if (!page) return null
|
||||
|
||||
@@ -82,79 +53,78 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||
<div className="absolute inset-0 flex flex-row overflow-hidden">
|
||||
<div className="flex h-full w-full">
|
||||
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
||||
<DetailPageHeader page={page} handleDelete={handleDelete} />
|
||||
<DetailPageHeader page={page} handleDelete={handleDelete} isMobile={isMobile} />
|
||||
<DetailPageForm page={page} />
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div className="relative min-w-56 max-w-72 border-l">
|
||||
<div className="flex">
|
||||
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
||||
<span className="text-left text-[13px] font-medium">Page actions</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
variant="ghost"
|
||||
className="-ml-1.5"
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>
|
||||
)}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} className="-ml-1.5">
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && <SidebarActions page={page} handleDelete={handleDelete} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
const { me } = useAccount()
|
||||
const SidebarActions = ({ page, handleDelete }: { page: PersonalPage; handleDelete: () => void }) => (
|
||||
<div className="relative min-w-56 max-w-72 border-l">
|
||||
<div className="flex">
|
||||
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
||||
<span className="text-left text-[13px] font-medium">Page actions</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
||||
<div className="flex flex-row">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
variant="ghost"
|
||||
className="-ml-1.5"
|
||||
renderSelectedText={() => <span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} className="-ml-1.5">
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
<span className="text-sm">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
const titleEditorRef = useRef<Editor | null>(null)
|
||||
const contentEditorRef = useRef<LAEditorRef>(null)
|
||||
|
||||
const isTitleInitialMount = useRef(true)
|
||||
const isContentInitialMount = useRef(true)
|
||||
const isInitialFocusApplied = useRef(false)
|
||||
|
||||
const updatePageContent = (content: Content, model: PersonalPage) => {
|
||||
const updatePageContent = useCallback((content: Content, model: PersonalPage) => {
|
||||
if (isContentInitialMount.current) {
|
||||
isContentInitialMount.current = false
|
||||
return
|
||||
}
|
||||
|
||||
model.content = content
|
||||
model.updatedAt = new Date()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleUpdateTitle = (editor: Editor) => {
|
||||
if (isTitleInitialMount.current) {
|
||||
isTitleInitialMount.current = false
|
||||
return
|
||||
}
|
||||
const handleUpdateTitle = useCallback(
|
||||
(editor: Editor) => {
|
||||
if (isTitleInitialMount.current) {
|
||||
isTitleInitialMount.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const personalPages = me?.root?.personalPages?.toJSON() || []
|
||||
const newTitle = editor.getText()
|
||||
|
||||
// Only update if the title has actually changed
|
||||
if (newTitle !== page.title) {
|
||||
const slug = generateUniqueSlug(personalPages, page.slug || "")
|
||||
|
||||
page.title = newTitle
|
||||
page.slug = slug
|
||||
page.updatedAt = new Date()
|
||||
}
|
||||
}
|
||||
const newTitle = editor.getText()
|
||||
if (newTitle !== page.title) {
|
||||
const slug = generateUniqueSlug(page.title?.toString() || "")
|
||||
page.title = newTitle
|
||||
page.slug = slug
|
||||
page.updatedAt = new Date()
|
||||
}
|
||||
},
|
||||
[page]
|
||||
)
|
||||
|
||||
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||
const editor = titleEditorRef.current
|
||||
@@ -181,7 +151,6 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return false
|
||||
}, [])
|
||||
|
||||
@@ -198,13 +167,11 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
titleEditorRef.current?.commands.focus("end")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const titleEditor = useEditor({
|
||||
immediatelyRender: false,
|
||||
autofocus: true,
|
||||
extensions: [
|
||||
FocusClasses,
|
||||
Paragraph,
|
||||
@@ -217,9 +184,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
strike: false,
|
||||
focus: false,
|
||||
gapcursor: false,
|
||||
placeholder: {
|
||||
placeholder: TITLE_PLACEHOLDER
|
||||
}
|
||||
placeholder: { placeholder: TITLE_PLACEHOLDER }
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
@@ -250,7 +215,16 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
useEffect(() => {
|
||||
isTitleInitialMount.current = true
|
||||
isContentInitialMount.current = true
|
||||
}, [])
|
||||
|
||||
if (!isInitialFocusApplied.current && titleEditor && contentEditorRef.current?.editor) {
|
||||
isInitialFocusApplied.current = true
|
||||
if (!page.title) {
|
||||
titleEditor?.commands.focus()
|
||||
} else {
|
||||
contentEditorRef.current.editor.commands.focus()
|
||||
}
|
||||
}
|
||||
}, [page.title, titleEditor])
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import React from "react"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { PersonalPage } from "@/lib/schema/personal-page"
|
||||
import { useMedia } from "react-use"
|
||||
import { TopicSelector } from "@/components/custom/topic-selector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
export const DetailPageHeader = ({ page, handleDelete }: { page: PersonalPage; handleDelete: () => void }) => {
|
||||
const isMobile = useMedia("(max-width: 770px)")
|
||||
interface DetailPageHeaderProps {
|
||||
page: PersonalPage
|
||||
handleDelete: () => void
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export const DetailPageHeader: React.FC<DetailPageHeaderProps> = ({ page, handleDelete, isMobile }) => {
|
||||
if (!isMobile) return null
|
||||
|
||||
return (
|
||||
isMobile && (
|
||||
<>
|
||||
<ContentHeader className="lg:min-h-0">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
</ContentHeader>
|
||||
|
||||
<div className="flex flex-row items-start gap-1.5 border-b px-6 py-2 max-lg:pl-4">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
align="start"
|
||||
variant="outline"
|
||||
renderSelectedText={() => <span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleDelete}>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
<>
|
||||
<ContentHeader className="lg:min-h-0">
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
</ContentHeader>
|
||||
|
||||
<div className="flex flex-row items-start gap-1.5 border-b px-6 py-2 max-lg:pl-4">
|
||||
<TopicSelector
|
||||
value={page.topic?.name}
|
||||
onTopicChange={topic => {
|
||||
page.topic = topic
|
||||
page.updatedAt = new Date()
|
||||
}}
|
||||
align="start"
|
||||
variant="outline"
|
||||
renderSelectedText={() => <span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleDelete}>
|
||||
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
58
web/components/routes/page/header.tsx
Normal file
58
web/components/routes/page/header.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { usePageActions } from "./hooks/use-page-actions"
|
||||
|
||||
interface PageHeaderProps {}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
|
||||
const { me } = useAccount()
|
||||
const router = useRouter()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const handleNewPageClick = () => {
|
||||
const page = newPage(me)
|
||||
router.push(`/pages/${page.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
<NewPageButton onClick={handleNewPageClick} />
|
||||
</ContentHeader>
|
||||
)
|
||||
})
|
||||
|
||||
PageHeader.displayName = "PageHeader"
|
||||
|
||||
const HeaderTitle: React.FC = () => (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">Pages</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface NewPageButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const NewPageButton: React.FC<NewPageButtonProps> = ({ onClick }) => (
|
||||
<div className="flex w-auto items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={onClick}>
|
||||
<LaIcon name="Plus" />
|
||||
<span className="hidden md:block">New page</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
16
web/components/routes/page/hooks/use-column-styles.ts
Normal file
16
web/components/routes/page/hooks/use-column-styles.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
|
||||
export const useColumnStyles = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
|
||||
return {
|
||||
title: {
|
||||
"--width": "69px",
|
||||
"--min-width": "200px",
|
||||
"--max-width": isTablet ? "none" : "auto"
|
||||
},
|
||||
content: { "--width": "auto", "--min-width": "200px", "--max-width": "200px" },
|
||||
topic: { "--width": "65px", "--min-width": "120px", "--max-width": "120px" },
|
||||
updated: { "--width": "82px", "--min-width": "82px", "--max-width": "82px" }
|
||||
}
|
||||
}
|
||||
45
web/components/routes/page/hooks/use-page-actions.ts
Normal file
45
web/components/routes/page/hooks/use-page-actions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { LaAccount, PersonalPage } from "@/lib/schema"
|
||||
import { ID } from "jazz-tools"
|
||||
|
||||
export const usePageActions = () => {
|
||||
const newPage = useCallback((me: LaAccount): PersonalPage => {
|
||||
const newPersonalPage = PersonalPage.create(
|
||||
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ owner: me._owner }
|
||||
)
|
||||
me.root?.personalPages?.push(newPersonalPage)
|
||||
return newPersonalPage
|
||||
}, [])
|
||||
|
||||
const deletePage = useCallback((me: LaAccount, pageId: ID<PersonalPage>): void => {
|
||||
if (!me.root?.personalPages) return
|
||||
|
||||
const index = me.root.personalPages.findIndex(item => item?.id === pageId)
|
||||
if (index === -1) {
|
||||
toast.error("Page not found")
|
||||
return
|
||||
}
|
||||
|
||||
const page = me.root.personalPages[index]
|
||||
if (!page) {
|
||||
toast.error("Page data is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
me.root.personalPages.splice(index, 1)
|
||||
|
||||
toast.success("Page deleted", {
|
||||
position: "bottom-right",
|
||||
description: `${page.title} has been deleted.`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to delete page", error)
|
||||
toast.error("Failed to delete page")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { newPage, deletePage }
|
||||
}
|
||||
89
web/components/routes/page/list.tsx
Normal file
89
web/components/routes/page/list.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as React from "react"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { PageItem } from "./partials/page-item"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useColumnStyles } from "./hooks/use-column-styles"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { Column } from "@/components/custom/column"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
|
||||
interface PageListProps {}
|
||||
|
||||
export const PageList: React.FC<PageListProps> = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
const { me } = useAccount({ root: { personalPages: [] } })
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(null)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<number | null>(null)
|
||||
const personalPages = React.useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
|
||||
|
||||
const next = () => Math.min((activeItemIndex ?? 0) + 1, (personalPages?.length ?? 0) - 1)
|
||||
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
}
|
||||
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: keyboardActiveIndex })
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<Primitive.div
|
||||
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
{personalPages?.map(
|
||||
(page, index) =>
|
||||
page?.id && (
|
||||
<PageItem
|
||||
key={page.id}
|
||||
ref={el => setElementRef(el, index)}
|
||||
page={page}
|
||||
isActive={index === activeItemIndex}
|
||||
onPointerMove={() => {
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Primitive.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ColumnHeader: React.FC = () => {
|
||||
const columnStyles = useColumnStyles()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Title</Column.Text>
|
||||
</Column.Wrapper>
|
||||
<Column.Wrapper style={columnStyles.topic}>
|
||||
<Column.Text>Topic</Column.Text>
|
||||
</Column.Wrapper>
|
||||
<Column.Wrapper style={columnStyles.updated}>
|
||||
<Column.Text>Updated</Column.Text>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
web/components/routes/page/partials/page-item.tsx
Normal file
67
web/components/routes/page/partials/page-item.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalPage } from "@/lib/schema"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useColumnStyles } from "../hooks/use-column-styles"
|
||||
import { format } from "date-fns"
|
||||
import { Column } from "@/components/custom/column"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface PageItemProps extends React.HTMLAttributes<HTMLAnchorElement> {
|
||||
page: PersonalPage
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ page, isActive, ...props }, ref) => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
const columnStyles = useColumnStyles()
|
||||
const router = useRouter()
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
router.push(`/pages/${page.id}`)
|
||||
}
|
||||
},
|
||||
[router, page.id]
|
||||
)
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
"relative block cursor-default outline-none",
|
||||
"min-h-12 py-2 max-lg:px-4 sm:px-6",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
|
||||
)}
|
||||
href={`/pages/${page.id}`}
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex h-full items-center gap-4">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">{page.title || "Untitled"}</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
{!isTablet && (
|
||||
<Column.Wrapper style={columnStyles.topic}>
|
||||
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
|
||||
</Column.Wrapper>
|
||||
)}
|
||||
|
||||
<Column.Wrapper style={columnStyles.updated} className="flex justify-end">
|
||||
<Column.Text className="text-[13px]">{format(new Date(page.updatedAt), "d MMM yyyy")}</Column.Text>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
|
||||
PageItem.displayName = "PageItem"
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useRef, useCallback, useMemo } from "react"
|
||||
import * as React from "react"
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils"
|
||||
import { useIsMounted } from "@/hooks/use-is-mounted"
|
||||
|
||||
interface GraphNode {
|
||||
name: string
|
||||
@@ -14,99 +13,120 @@ interface GraphNode {
|
||||
|
||||
interface AutocompleteProps {
|
||||
topics: GraphNode[]
|
||||
onSelect: (topic: GraphNode) => void
|
||||
onSelect: (topic: string) => void
|
||||
onInputChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function Autocomplete({ topics = [], onSelect, onInputChange }: AutocompleteProps): JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [, setOpen] = React.useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const [hasInteracted, setHasInteracted] = React.useState(false)
|
||||
const [showDropdown, setShowDropdown] = React.useState(false)
|
||||
|
||||
const filteredTopics = useMemo(() => {
|
||||
const initialShuffledTopics = React.useMemo(() => shuffleArray(topics).slice(0, 5), [topics])
|
||||
|
||||
const filteredTopics = React.useMemo(() => {
|
||||
if (!inputValue) {
|
||||
return topics.slice(0, 5)
|
||||
return initialShuffledTopics
|
||||
}
|
||||
const regex = new RegExp(inputValue.split("").join(".*"), "i")
|
||||
return topics.filter(
|
||||
topic =>
|
||||
regex.test(topic.name) ||
|
||||
regex.test(topic.prettyName) ||
|
||||
topic.connectedTopics.some(connectedTopic => regex.test(connectedTopic))
|
||||
)
|
||||
}, [inputValue, topics])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
const regex = searchSafeRegExp(inputValue)
|
||||
return topics
|
||||
.filter(
|
||||
topic =>
|
||||
regex.test(topic.name) ||
|
||||
regex.test(topic.prettyName) ||
|
||||
topic.connectedTopics.some(connectedTopic => regex.test(connectedTopic))
|
||||
)
|
||||
.sort((a, b) => a.prettyName.localeCompare(b.prettyName))
|
||||
.slice(0, 10)
|
||||
}, [inputValue, topics, initialShuffledTopics])
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(topic: GraphNode) => {
|
||||
setInputValue(topic.prettyName)
|
||||
setOpen(false)
|
||||
onSelect(topic)
|
||||
onSelect(topic.name)
|
||||
},
|
||||
[onSelect]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Enter" && filteredTopics.length > 0) {
|
||||
handleSelect(filteredTopics[0])
|
||||
} else if ((e.key === "Backspace" || e.key === "Delete") && inputRef.current?.value === "") {
|
||||
setOpen(true)
|
||||
}
|
||||
},
|
||||
[filteredTopics, handleSelect]
|
||||
)
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
const handleInputChange = React.useCallback(
|
||||
(value: string) => {
|
||||
setInputValue(value)
|
||||
setOpen(true)
|
||||
setShowDropdown(true)
|
||||
setHasInteracted(true)
|
||||
onInputChange(value)
|
||||
},
|
||||
[onInputChange]
|
||||
)
|
||||
|
||||
const handleFocus = React.useCallback(() => {
|
||||
setHasInteracted(true)
|
||||
}, [])
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setShowDropdown(true)
|
||||
setHasInteracted(true)
|
||||
}, [])
|
||||
|
||||
const commandKey = React.useMemo(() => {
|
||||
return filteredTopics
|
||||
.map(topic => `${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`)
|
||||
.join("__")
|
||||
}, [filteredTopics])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (inputRef.current && isMounted() && hasInteracted) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [commandKey, isMounted, hasInteracted])
|
||||
|
||||
return (
|
||||
<Command
|
||||
className={cn("bg-background relative overflow-visible", {
|
||||
"rounded-lg border": !open,
|
||||
"rounded-none rounded-t-lg border-l border-r border-t": open
|
||||
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", {
|
||||
"rounded-lg border": !showDropdown,
|
||||
"rounded-none rounded-t-lg border-l border-r border-t": showDropdown
|
||||
})}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="flex items-center p-2">
|
||||
<div className={"relative flex items-center px-2 py-3"}>
|
||||
<CommandPrimitive.Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onValueChange={handleInputChange}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 100)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="Search for a topic..."
|
||||
className={cn("placeholder:text-muted-foreground flex-1 bg-transparent px-2 py-1 outline-none", {
|
||||
"mb-1 border-b pb-2.5": open
|
||||
})}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setShowDropdown(false), 100)
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
placeholder={filteredTopics[0]?.prettyName}
|
||||
className={cn("placeholder:text-muted-foreground flex-1 bg-transparent px-2 outline-none")}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
{showDropdown && hasInteracted && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="bg-background absolute left-0 right-0 z-10 -mx-px rounded-b-lg border-b border-l border-r shadow-lg"
|
||||
className="bg-background absolute left-0 right-0 z-10 -mx-px rounded-b-lg border-b border-l border-r border-t shadow-lg"
|
||||
>
|
||||
<CommandList className="max-h-52">
|
||||
<CommandGroup className="mb-2">
|
||||
{filteredTopics.map(topic => (
|
||||
<CommandList className="max-h-56">
|
||||
<CommandGroup className="my-2">
|
||||
{filteredTopics.map((topic, index) => (
|
||||
<CommandItem
|
||||
key={topic.name}
|
||||
key={index}
|
||||
value={topic.name}
|
||||
onSelect={() => handleSelect(topic)}
|
||||
className="min-h-10 rounded-none px-3 py-1.5"
|
||||
>
|
||||
<span>{topic.prettyName}</span>
|
||||
<span className="text-muted-foreground ml-auto text-xs">
|
||||
{topic.connectedTopics.length > 0 ? topic.connectedTopics.join(", ") : "-"}
|
||||
<span className="text-muted-foreground/80 ml-auto text-xs">
|
||||
{topic.connectedTopics.length > 0 && topic.connectedTopics.join(", ")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -5,12 +5,14 @@ import dynamic from "next/dynamic"
|
||||
import { motion } from "framer-motion"
|
||||
import { Autocomplete } from "./Autocomplete"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { raleway } from "@/app/fonts"
|
||||
|
||||
let graph_data_promise = import("./graph-data.json").then(a => a.default)
|
||||
|
||||
const ForceGraphClient = dynamic(() => import("./force-graph-client-lazy"), { ssr: false })
|
||||
|
||||
interface GraphNode {
|
||||
export interface GraphNode {
|
||||
name: string
|
||||
prettyName: string
|
||||
connectedTopics: string[]
|
||||
@@ -21,8 +23,8 @@ export function PublicHomeRoute() {
|
||||
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||
const [filterQuery, setFilterQuery] = React.useState<string>("")
|
||||
|
||||
const handleTopicSelect = (topicName: string) => {
|
||||
router.push(`/${topicName}`)
|
||||
const handleTopicSelect = (topic: string) => {
|
||||
router.replace(`/${topic}`)
|
||||
}
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
@@ -31,33 +33,21 @@ export function PublicHomeRoute() {
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-screen">
|
||||
<ForceGraphClient
|
||||
raw_nodes={raw_graph_data}
|
||||
onNodeClick={val => handleTopicSelect(val)}
|
||||
filter_query={filterQuery}
|
||||
/>
|
||||
<ForceGraphClient raw_nodes={raw_graph_data} onNodeClick={handleTopicSelect} filter_query={filterQuery} />
|
||||
|
||||
<motion.div
|
||||
className="absolute left-1/2 top-1/2 w-full max-w-md -translate-x-1/2 -translate-y-1/2 transform max-sm:px-5"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{ x: "-50%", y: "-50%" }}
|
||||
>
|
||||
<motion.h1
|
||||
className="mb-2 text-center text-3xl font-bold uppercase sm:mb-4 md:text-5xl"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
I want to learn
|
||||
</motion.h1>
|
||||
<Autocomplete
|
||||
topics={raw_graph_data}
|
||||
onSelect={topic => handleTopicSelect(topic.name)}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</motion.div>
|
||||
<div className="absolute left-1/2 top-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 transform max-sm:px-5">
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
|
||||
<motion.h1
|
||||
className={cn("mb-2 text-center text-5xl font-bold tracking-tight sm:mb-4 md:text-7xl", raleway.className)}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
I want to learn
|
||||
</motion.h1>
|
||||
<Autocomplete topics={raw_graph_data} onSelect={handleTopicSelect} onInputChange={handleInputChange} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import * as react from "react"
|
||||
import * as fg from "@nothing-but/force-graph"
|
||||
import { ease, trig, raf } from "@nothing-but/utils"
|
||||
|
||||
import * as schedule from "@/lib/utils/schedule"
|
||||
import * as canvas from "@/lib/utils/canvas"
|
||||
import { searchSafeRegExp } from "@/lib/utils"
|
||||
import { ease, trig, raf, color } from "@nothing-but/utils"
|
||||
|
||||
export type RawGraphNode = {
|
||||
name: string
|
||||
@@ -13,70 +13,36 @@ export type RawGraphNode = {
|
||||
connectedTopics: string[]
|
||||
}
|
||||
|
||||
type HSL = [hue: number, saturation: number, lightness: number]
|
||||
|
||||
const COLORS: readonly HSL[] = [
|
||||
const COLORS: readonly color.HSL[] = [
|
||||
[3, 86, 64],
|
||||
[15, 87, 66],
|
||||
[31, 90, 69],
|
||||
[15, 87, 66]
|
||||
[15, 87, 66],
|
||||
[31, 90, 69],
|
||||
[344, 87, 70]
|
||||
]
|
||||
|
||||
/* use a plain object instead of Map for faster lookups */
|
||||
type ColorMap = {[key: string]: string}
|
||||
type HSLMap = Map<fg.graph.Node, HSL>
|
||||
type ColorMap = Record<string, color.HSL>
|
||||
|
||||
const MAX_COLOR_ITERATIONS = 10
|
||||
function generateColorMap(g: fg.graph.Graph): ColorMap {
|
||||
const hsl_map: ColorMap = {}
|
||||
|
||||
/**
|
||||
* Add a color to a node and all its connected nodes.
|
||||
*/
|
||||
function visitColorNode(
|
||||
g: fg.graph.Graph,
|
||||
prev: fg.graph.Node,
|
||||
node: fg.graph.Node,
|
||||
hsl_map: HSLMap,
|
||||
add: HSL,
|
||||
iteration: number = 1
|
||||
): void {
|
||||
if (iteration > MAX_COLOR_ITERATIONS) return
|
||||
|
||||
const color = hsl_map.get(node)
|
||||
|
||||
if (!color) {
|
||||
hsl_map.set(node, [...add])
|
||||
} else {
|
||||
const add_strength = MAX_COLOR_ITERATIONS / iteration
|
||||
color[0] = (color[0] + add[0] * add_strength) / (1 + add_strength)
|
||||
color[1] = (color[1] + add[1] * add_strength) / (1 + add_strength)
|
||||
color[2] = (color[2] + add[2] * add_strength) / (1 + add_strength)
|
||||
for (let i = 0; i < g.nodes.length; i++) {
|
||||
hsl_map[g.nodes[i].key as string] = COLORS[i % COLORS.length]
|
||||
}
|
||||
|
||||
for (let edge of g.edges) {
|
||||
let b: fg.graph.Node
|
||||
if (edge.a === node) b = edge.b
|
||||
else if (edge.b === node) b = edge.a
|
||||
else continue
|
||||
if (b !== prev) {
|
||||
visitColorNode(g, node, b, hsl_map, add, iteration + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let { a, b } of g.edges) {
|
||||
let a_hsl = hsl_map[a.key as string]
|
||||
let b_hsl = hsl_map[b.key as string]
|
||||
|
||||
function generateColorMap(g: fg.graph.Graph, nodes: readonly fg.graph.Node[]): ColorMap {
|
||||
const hls_map: HSLMap = new Map()
|
||||
let am = a.mass - 1
|
||||
let bm = b.mass - 1
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]!
|
||||
const color = COLORS[i % COLORS.length]!
|
||||
visitColorNode(g, node, node, hls_map, color)
|
||||
hsl_map[a.key as string] = color.mix(a_hsl, b_hsl, am * am * am, bm)
|
||||
hsl_map[b.key as string] = color.mix(a_hsl, b_hsl, am, bm * bm * bm)
|
||||
}
|
||||
|
||||
const color_map: ColorMap = {}
|
||||
for (const [node, [hue, saturation, lightness]] of hls_map.entries()) {
|
||||
color_map[node.key as string] = `${hue} ${saturation}% ${lightness}%`
|
||||
}
|
||||
|
||||
return color_map
|
||||
return hsl_map
|
||||
}
|
||||
|
||||
function generateNodesFromRawData(g: fg.graph.Graph, raw_data: RawGraphNode[]): void {
|
||||
@@ -107,14 +73,9 @@ function generateNodesFromRawData(g: fg.graph.Graph, raw_data: RawGraphNode[]):
|
||||
let edges = fg.graph.get_node_edges(g, node)
|
||||
node.mass = fg.graph.node_mass_from_edges(edges.length)
|
||||
}
|
||||
|
||||
fg.graph.randomize_positions(g)
|
||||
}
|
||||
|
||||
function filterNodes(
|
||||
s: State,
|
||||
filter: string
|
||||
): void {
|
||||
function filterNodes(s: State, filter: string): void {
|
||||
fg.graph.clear_nodes(s.graph)
|
||||
|
||||
if (filter === "") {
|
||||
@@ -122,10 +83,16 @@ function filterNodes(
|
||||
fg.graph.add_edges(s.graph, s.edges)
|
||||
} else {
|
||||
// regex matching all letters of the filter (out of order)
|
||||
const regex = new RegExp(filter.split("").join(".*"), "i")
|
||||
|
||||
fg.graph.add_nodes(s.graph, s.nodes.filter(node => regex.test(node.label)))
|
||||
fg.graph.add_edges(s.graph, s.edges.filter(edge => regex.test(edge.a.label) && regex.test(edge.b.label)))
|
||||
const regex = searchSafeRegExp(filter)
|
||||
|
||||
fg.graph.add_nodes(
|
||||
s.graph,
|
||||
s.nodes.filter(node => regex.test(node.label))
|
||||
)
|
||||
fg.graph.add_edges(
|
||||
s.graph,
|
||||
s.edges.filter(edge => regex.test(edge.a.label) && regex.test(edge.b.label))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +102,7 @@ const GRAPH_OPTIONS: fg.graph.Options = {
|
||||
origin_strength: 0.01,
|
||||
repel_distance: 40,
|
||||
repel_strength: 2,
|
||||
link_strength: 0.015,
|
||||
link_strength: 0.03,
|
||||
grid_size: 500
|
||||
}
|
||||
|
||||
@@ -143,28 +110,30 @@ const TITLE_SIZE_PX = 400
|
||||
|
||||
const simulateGraph = (
|
||||
alpha: number,
|
||||
graph: fg.graph.Graph,
|
||||
canvas: fg.canvas.CanvasState,
|
||||
gestures: fg.canvas.CanvasGestures,
|
||||
vw: number,
|
||||
vh: number
|
||||
): void => {
|
||||
let c = gestures.canvas
|
||||
let g = c.graph
|
||||
|
||||
alpha = alpha / 10 // slow things down a bit
|
||||
|
||||
fg.graph.simulate(graph, alpha)
|
||||
fg.graph.simulate(g, alpha)
|
||||
|
||||
/*
|
||||
Push nodes away from the center (the title)
|
||||
*/
|
||||
let grid_radius = graph.options.grid_size / 2
|
||||
let origin_x = grid_radius + canvas.translate.x
|
||||
let origin_y = grid_radius + canvas.translate.y
|
||||
let grid_radius = g.options.grid_size / 2
|
||||
let origin_x = grid_radius + c.translate.x
|
||||
let origin_y = grid_radius + c.translate.y
|
||||
let vmax = Math.max(vw, vh)
|
||||
let push_radius =
|
||||
(Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) * (graph.options.grid_size / canvas.scale) +
|
||||
(Math.min(TITLE_SIZE_PX, vw / 2, vh / 2) / vmax) * (g.options.grid_size / c.scale) +
|
||||
80 /* additional margin for when scrolled in */
|
||||
|
||||
for (let node of graph.nodes) {
|
||||
|
||||
for (let node of g.nodes) {
|
||||
//
|
||||
let dist_x = node.pos.x - origin_x
|
||||
let dist_y = (node.pos.y - origin_y) * 2
|
||||
let dist = Math.sqrt(dist_x * dist_x + dist_y * dist_y)
|
||||
@@ -175,6 +144,25 @@ const simulateGraph = (
|
||||
node.vel.x += strength * (node.pos.x - origin_x) * 10 * alpha
|
||||
node.vel.y += strength * (node.pos.y - origin_y) * 10 * alpha
|
||||
}
|
||||
|
||||
/*
|
||||
When a node is being dragged
|
||||
it will pull it's connections
|
||||
*/
|
||||
if (gestures.mode.type === fg.canvas.Mode.DraggingNode) {
|
||||
//
|
||||
let node = gestures.mode.node
|
||||
|
||||
for (let edge of fg.graph.each_node_edge(g, node)) {
|
||||
let b = edge.b === node ? edge.a : edge.b
|
||||
|
||||
let dx = (b.pos.x - node.pos.x) * g.options.link_strength * edge.strength * alpha * 10
|
||||
let dy = (b.pos.y - node.pos.y) * g.options.link_strength * edge.strength * alpha * 10
|
||||
|
||||
b.vel.x -= dx / b.mass
|
||||
b.vel.y -= dy / b.mass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drawGraph = (c: fg.canvas.CanvasState, color_map: ColorMap): void => {
|
||||
@@ -185,32 +173,31 @@ const drawGraph = (c: fg.canvas.CanvasState, color_map: ColorMap): void => {
|
||||
Draw text nodes
|
||||
*/
|
||||
let grid_size = c.graph.options.grid_size
|
||||
let max_size = Math.max(c.ctx.canvas.width, c.ctx.canvas.height)
|
||||
let max_size = Math.max(c.ctx.canvas.width, c.ctx.canvas.height)
|
||||
|
||||
let clip_rect = fg.canvas.get_ctx_clip_rect(c.ctx, {x: 100, y: 20})
|
||||
let clip_rect = fg.canvas.get_ctx_clip_rect(c.ctx, { x: 100, y: 20 })
|
||||
|
||||
c.ctx.textAlign = "center"
|
||||
c.ctx.textBaseline = "middle"
|
||||
|
||||
for (let node of c.graph.nodes) {
|
||||
|
||||
let x = node.pos.x / grid_size * max_size
|
||||
let y = node.pos.y / grid_size * max_size
|
||||
let x = (node.pos.x / grid_size) * max_size
|
||||
let y = (node.pos.y / grid_size) * max_size
|
||||
|
||||
if (fg.canvas.in_rect_xy(clip_rect, x, y)) {
|
||||
|
||||
let base_size = max_size / 220
|
||||
let base_size = max_size / 220
|
||||
let mass_boost_size = max_size / 140
|
||||
let mass_boost = (node.mass - 1) / 8 / c.scale
|
||||
let mass_boost = (node.mass - 1) / 8 / c.scale
|
||||
|
||||
c.ctx.font = `${base_size + mass_boost * mass_boost_size}px sans-serif`
|
||||
|
||||
|
||||
let opacity = 0.6 + ((node.mass - 1) / 50) * 4
|
||||
|
||||
c.ctx.fillStyle = node.anchor || c.hovered_node === node
|
||||
? `rgba(129, 140, 248, ${opacity})`
|
||||
: `hsl(${color_map[node.key as string]} / ${opacity})`
|
||||
|
||||
c.ctx.fillStyle =
|
||||
node.anchor || c.hovered_node === node
|
||||
? `rgba(129, 140, 248, ${opacity})`
|
||||
: color.hsl_to_hsla_string(color_map[node.key as string], opacity)
|
||||
|
||||
c.ctx.fillText(node.label, x, y)
|
||||
}
|
||||
}
|
||||
@@ -242,7 +229,7 @@ function init(
|
||||
canvas_el: HTMLCanvasElement | null
|
||||
}
|
||||
) {
|
||||
let {canvas_el, raw_nodes} = props
|
||||
let { canvas_el, raw_nodes } = props
|
||||
|
||||
if (canvas_el == null) return
|
||||
|
||||
@@ -250,10 +237,12 @@ function init(
|
||||
if (s.ctx == null) return
|
||||
|
||||
generateNodesFromRawData(s.graph, raw_nodes)
|
||||
fg.graph.set_positions_smart(s.graph)
|
||||
|
||||
s.nodes = s.graph.nodes.slice()
|
||||
s.edges = s.graph.edges.slice()
|
||||
|
||||
let color_map = generateColorMap(s.graph, s.nodes)
|
||||
let color_map = generateColorMap(s.graph)
|
||||
|
||||
let canvas_state = fg.canvas.canvasState({
|
||||
ctx: s.ctx,
|
||||
@@ -263,6 +252,23 @@ function init(
|
||||
init_grid_pos: trig.ZERO
|
||||
})
|
||||
|
||||
let gestures = (s.gestures = fg.canvas.canvasGestures({
|
||||
canvas: canvas_state,
|
||||
onGesture: e => {
|
||||
switch (e.type) {
|
||||
case fg.canvas.GestureEventType.Translate:
|
||||
s.bump_end = raf.bump(s.bump_end)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeClick:
|
||||
props.onNodeClick(e.node.key as string)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeDrag:
|
||||
fg.graph.set_position(canvas_state.graph, e.node, e.pos)
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
s.ro = new ResizeObserver(() => {
|
||||
if (canvas.resizeCanvasToDisplaySize(canvas_el)) {
|
||||
fg.canvas.updateTranslate(canvas_state, canvas_state.translate.x, canvas_state.translate.y)
|
||||
@@ -270,15 +276,19 @@ function init(
|
||||
})
|
||||
s.ro.observe(canvas_el)
|
||||
|
||||
// initial simulation is the most crazy
|
||||
// so it's off-screen
|
||||
simulateGraph(6, gestures, window.innerWidth, window.innerHeight)
|
||||
|
||||
function loop(time: number) {
|
||||
let is_active = gestures.mode.type === fg.canvas.Mode.DraggingNode
|
||||
let iterations = Math.min(2, raf.calcIterations(s.frame_iter_limit, time))
|
||||
|
||||
for (let i = iterations; i > 0; i--) {
|
||||
s.alpha = raf.updateAlpha(s.alpha, is_active || time < s.bump_end)
|
||||
simulateGraph(s.alpha, s.graph, canvas_state, window.innerWidth, window.innerHeight)
|
||||
simulateGraph(s.alpha, gestures, window.innerWidth, window.innerHeight)
|
||||
}
|
||||
|
||||
|
||||
if (iterations > 0) {
|
||||
drawGraph(canvas_state, color_map)
|
||||
}
|
||||
@@ -286,23 +296,6 @@ function init(
|
||||
s.raf_id = requestAnimationFrame(loop)
|
||||
}
|
||||
s.raf_id = requestAnimationFrame(loop)
|
||||
|
||||
let gestures = (s.gestures = fg.canvas.canvasGestures({
|
||||
canvas: canvas_state,
|
||||
onGesture: e => {
|
||||
switch (e.type) {
|
||||
case fg.canvas.GestureEventType.Translate:
|
||||
s.bump_end = raf.bump(s.bump_end)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeClick:
|
||||
props.onNodeClick(e.node.key as string)
|
||||
break
|
||||
case fg.canvas.GestureEventType.NodeDrag:
|
||||
fg.graph.set_position(canvas_state.graph, e.node, e.pos)
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function updateQuery(s: State, filter_query: string) {
|
||||
|
||||
@@ -130,7 +130,7 @@ export const SearchWrapper = () => {
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={handleSearch}
|
||||
placeholder="Search something..."
|
||||
placeholder="Search topics, links, pages"
|
||||
className="dark:bg-input w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600"
|
||||
/>
|
||||
{searchText && (
|
||||
@@ -178,12 +178,13 @@ export const SearchWrapper = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5">
|
||||
{searchText && !showAiSearch && (
|
||||
{/* {searchText && !showAiSearch && ( */}
|
||||
{searchText && (
|
||||
<div
|
||||
className="cursor-pointer rounded-lg bg-blue-700 p-4 font-semibold text-white"
|
||||
onClick={() => setShowAiSearch(true)}
|
||||
className="cursor-default rounded-lg bg-blue-700 p-4 font-semibold text-white"
|
||||
// onClick={() => setShowAiSearch(true)}
|
||||
>
|
||||
✨ Didn't find what you were looking for? Ask AI
|
||||
✨ Didn't find what you were looking for? Will soon have AI assistant builtin
|
||||
</div>
|
||||
)}
|
||||
{showAiSearch && <AiSearch searchQuery={searchText} />}
|
||||
|
||||
114
web/components/routes/task/TaskForm.tsx
Normal file
114
web/components/routes/task/TaskForm.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { ListOfTasks, Task } from "@/lib/schema/tasks"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { format } from "date-fns"
|
||||
|
||||
interface TaskFormProps {}
|
||||
|
||||
export const TaskForm: React.FC<TaskFormProps> = ({}) => {
|
||||
const [title, setTitle] = useState("")
|
||||
const [inputVisible, setInputVisible] = useState(false)
|
||||
const { me } = useAccount({ root: {} })
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (title.trim()) {
|
||||
if (me?.root?.tasks === undefined) {
|
||||
if (!me) return
|
||||
me.root.tasks = ListOfTasks.create([], { owner: me })
|
||||
}
|
||||
|
||||
const newTask = Task.create(
|
||||
{
|
||||
title,
|
||||
description: "",
|
||||
status: "todo",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{ owner: me._owner }
|
||||
)
|
||||
me.root.tasks?.push(newTask)
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle("")
|
||||
setInputVisible(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
resetForm()
|
||||
} else if (e.key === "Backspace" && title.trim() === "") {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inputVisible && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [inputVisible])
|
||||
|
||||
const formattedDate = format(new Date(), "EEE, MMMM do, yyyy")
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<AnimatePresence mode="wait">
|
||||
{!inputVisible ? (
|
||||
<motion.div
|
||||
key="add-button"
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: "auto" }}
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Button
|
||||
className="flex flex-row items-center gap-1"
|
||||
onClick={() => setInputVisible(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<LaIcon name="Plus" />
|
||||
Add task
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="input-form"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: "100%", opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-result flex w-full items-center justify-between rounded-lg p-2 px-3"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Checkbox checked={false} onCheckedChange={() => {}} />
|
||||
<Input
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
value={title}
|
||||
className="flex-grow border-none bg-transparent p-0 focus-visible:ring-0"
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
// placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-xs">{formattedDate}</span>
|
||||
</div>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
web/components/routes/task/TaskItem.tsx
Normal file
26
web/components/routes/task/TaskItem.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Task } from "@/lib/schema/tasks"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { format } from "date-fns"
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task
|
||||
onUpdateTask: (taskId: string, updates: Partial<Task>) => void
|
||||
}
|
||||
|
||||
export const TaskItem: React.FC<TaskItemProps> = ({ task, onUpdateTask }) => {
|
||||
const statusChange = (checked: boolean) => {
|
||||
onUpdateTask(task.id, { status: checked ? "done" : "todo" })
|
||||
}
|
||||
|
||||
const formattedDate = format(new Date(task.createdAt), "EEE, MMMM do, yyyy")
|
||||
|
||||
return (
|
||||
<li className="bg-result transitiion-opacity flex items-center justify-between rounded-lg p-2 px-3 hover:opacity-60">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Checkbox checked={task.status === "done"} onCheckedChange={statusChange} />
|
||||
<p className={task.status === "done" ? "text-foreground line-through" : ""}>{task.title}</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">{formattedDate}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
23
web/components/routes/task/TaskList.tsx
Normal file
23
web/components/routes/task/TaskList.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react"
|
||||
import { ListOfTasks, Task } from "@/lib/schema/tasks"
|
||||
import { TaskItem } from "./TaskItem"
|
||||
|
||||
interface TaskListProps {
|
||||
tasks?: ListOfTasks
|
||||
onUpdateTask: (taskId: string, updates: Partial<Task>) => void
|
||||
}
|
||||
|
||||
export const TaskList: React.FC<TaskListProps> = ({ tasks, onUpdateTask }) => {
|
||||
return (
|
||||
<ul className="flex flex-col gap-y-2">
|
||||
{tasks?.map(
|
||||
task =>
|
||||
task?.id && (
|
||||
<li key={task.id}>
|
||||
<TaskItem task={task} onUpdateTask={onUpdateTask} />
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
33
web/components/routes/task/TaskRoute.tsx
Normal file
33
web/components/routes/task/TaskRoute.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { Task } from "@/lib/schema/tasks"
|
||||
import { TaskList } from "./TaskList"
|
||||
import { TaskForm } from "./TaskForm"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
export const TaskRoute: React.FC = () => {
|
||||
const { me } = useAccount({ root: { tasks: [] } })
|
||||
const tasks = me?.root.tasks
|
||||
console.log(tasks, "tasks here")
|
||||
|
||||
const updateTask = (taskId: string, updates: Partial<Task>) => {
|
||||
if (me?.root?.tasks) {
|
||||
const taskIndex = me.root.tasks.findIndex(task => task?.id === taskId)
|
||||
if (taskIndex !== -1) {
|
||||
Object.assign(me.root.tasks[taskIndex]!, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<LaIcon name="ListTodo" className="size-6" />
|
||||
<h1 className="text-xl font-bold">Current Tasks</h1>
|
||||
</div>
|
||||
<TaskForm />
|
||||
<TaskList tasks={tasks} onUpdateTask={updateTask} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
web/components/routes/topics/TopicRoute.tsx
Normal file
13
web/components/routes/topics/TopicRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { TopicHeader } from "./header"
|
||||
import { TopicList } from "./list"
|
||||
|
||||
export function TopicRoute() {
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<TopicHeader />
|
||||
<TopicList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo, useRef } from "react"
|
||||
import { TopicDetailHeader } from "./Header"
|
||||
import { TopicSections } from "./partials/topic-sections"
|
||||
import { atom } from "jotai"
|
||||
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { TopicDetailHeader } from "./header"
|
||||
import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { Topic } from "@/lib/schema"
|
||||
import { TopicDetailList } from "./list"
|
||||
import { atom } from "jotai"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { GraphNode } from "../../public/PublicHomeRoute"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
|
||||
interface TopicDetailRouteProps {
|
||||
topicName: string
|
||||
}
|
||||
@@ -15,31 +19,71 @@ interface TopicDetailRouteProps {
|
||||
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
|
||||
const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me])
|
||||
const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [{ links: [] }] } })
|
||||
// const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks)
|
||||
const linksRefDummy = useRef<(HTMLLIElement | null)[]>([])
|
||||
const containerRefDummy = useRef<HTMLDivElement>(null)
|
||||
const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } })
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
||||
if (!topic || !me) {
|
||||
return null
|
||||
const topicExists = raw_graph_data.find(node => node.name === topicName)
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-auto flex-col">
|
||||
<>
|
||||
<TopicDetailHeader topic={topic} />
|
||||
<TopicSections
|
||||
topic={topic}
|
||||
sections={topic.latestGlobalGuide?.sections}
|
||||
activeIndex={0}
|
||||
setActiveIndex={() => {}}
|
||||
linkRefs={linksRefDummy}
|
||||
containerRef={containerRefDummy}
|
||||
me={me}
|
||||
personalLinks={me.root.personalLinks}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,21 @@ 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 { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LearningStateValue } from "@/lib/constants"
|
||||
import { useClerk } from "@clerk/nextjs"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
|
||||
interface TopicDetailHeaderProps {
|
||||
topic: Topic
|
||||
}
|
||||
|
||||
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
|
||||
const { me } = useAccount({
|
||||
const clerk = useClerk()
|
||||
const pathname = usePathname()
|
||||
const isMobile = useMedia("(max-width: 770px)")
|
||||
const { me } = useAccountOrGuest({
|
||||
root: {
|
||||
topicsWantToLearn: [],
|
||||
topicsLearning: [],
|
||||
@@ -26,34 +32,43 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }
|
||||
learningState: LearningStateValue
|
||||
} | null = null
|
||||
|
||||
const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1
|
||||
const wantToLearnIndex =
|
||||
me?._type === "Anonymous" ? -1 : (me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1)
|
||||
if (wantToLearnIndex !== -1) {
|
||||
p = {
|
||||
index: wantToLearnIndex,
|
||||
topic: me?.root.topicsWantToLearn[wantToLearnIndex],
|
||||
topic: me && me._type !== "Anonymous" ? me.root.topicsWantToLearn[wantToLearnIndex] : undefined,
|
||||
learningState: "wantToLearn"
|
||||
}
|
||||
}
|
||||
|
||||
const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1
|
||||
const learningIndex =
|
||||
me?._type === "Anonymous" ? -1 : (me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1)
|
||||
if (learningIndex !== -1) {
|
||||
p = {
|
||||
index: learningIndex,
|
||||
topic: me?.root.topicsLearning[learningIndex],
|
||||
topic: me && me._type !== "Anonymous" ? me?.root.topicsLearning[learningIndex] : undefined,
|
||||
learningState: "learning"
|
||||
}
|
||||
}
|
||||
|
||||
const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1
|
||||
const learnedIndex =
|
||||
me?._type === "Anonymous" ? -1 : (me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1)
|
||||
if (learnedIndex !== -1) {
|
||||
p = {
|
||||
index: learnedIndex,
|
||||
topic: me?.root.topicsLearned[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,
|
||||
@@ -77,20 +92,22 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left text-xl font-bold">{topic.prettyName}</span>
|
||||
<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="Add to my profile"
|
||||
defaultLabel={isMobile ? "" : "Add to profile"}
|
||||
defaultIcon="Circle"
|
||||
/>
|
||||
</ContentHeader>
|
||||
)
|
||||
93
web/components/routes/topics/detail/list.tsx
Normal file
93
web/components/routes/topics/detail/list.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useRef, useCallback } from "react"
|
||||
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
|
||||
import { Link as LinkSchema, Section as SectionSchema, Topic } from "@/lib/schema"
|
||||
import { LinkItem } from "./partials/link-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 = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 44,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
const renderItem = 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useAtom } from "jotai"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -10,33 +10,33 @@ import { Button } from "@/components/ui/button"
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
|
||||
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
|
||||
import { LaAccount, Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
|
||||
import { Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic } from "@/lib/schema"
|
||||
import { openPopoverForIdAtom } from "../TopicDetailRoute"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useClerk } from "@clerk/nextjs"
|
||||
|
||||
interface LinkItemProps {
|
||||
interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
topic: Topic
|
||||
link: LinkSchema
|
||||
isActive: boolean
|
||||
index: number
|
||||
setActiveIndex: (index: number) => void
|
||||
me: {
|
||||
root: {
|
||||
personalLinks: PersonalLinkLists
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
personalLinks: PersonalLinkLists
|
||||
personalLinks?: PersonalLinkLists
|
||||
}
|
||||
|
||||
export const LinkItem = React.memo(
|
||||
React.forwardRef<HTMLLIElement, LinkItemProps>(
|
||||
({ topic, link, isActive, index, setActiveIndex, me, personalLinks }, ref) => {
|
||||
React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
({ topic, link, isActive, index, setActiveIndex, className, personalLinks, ...props }, ref) => {
|
||||
const clerk = useClerk()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
const personalLink = useMemo(() => {
|
||||
return personalLinks.find(pl => pl?.link?.id === link.id)
|
||||
return personalLinks?.find(pl => pl?.link?.id === link.id)
|
||||
}, [personalLinks, link.id])
|
||||
|
||||
const selectedLearningState = useMemo(() => {
|
||||
@@ -53,13 +53,19 @@ export const LinkItem = React.memo(
|
||||
|
||||
const handleSelectLearningState = 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: () => router.push("/")
|
||||
onClick: () => router.push("/links")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +78,7 @@ export const LinkItem = React.memo(
|
||||
toast.success("Link learning state updated", defaultToast)
|
||||
}
|
||||
} else {
|
||||
const slug = generateUniqueSlug(personalLinks.toJSON(), link.title)
|
||||
const slug = generateUniqueSlug(link.title)
|
||||
const newPersonalLink = PersonalLink.create(
|
||||
{
|
||||
url: link.url,
|
||||
@@ -100,7 +106,7 @@ export const LinkItem = React.memo(
|
||||
setOpenPopoverForId(null)
|
||||
setIsPopoverOpen(false)
|
||||
},
|
||||
[personalLink, personalLinks, me, link, router, setOpenPopoverForId, topic]
|
||||
[personalLink, personalLinks, me, link, router, topic, setOpenPopoverForId, clerk, pathname]
|
||||
)
|
||||
|
||||
const handlePopoverOpenChange = useCallback(
|
||||
@@ -112,14 +118,19 @@ export const LinkItem = React.memo(
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
<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={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">
|
||||
@@ -140,12 +151,7 @@ export const LinkItem = React.memo(
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
@@ -159,7 +165,7 @@ export const LinkItem = React.memo(
|
||||
<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 xl:truncate",
|
||||
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
|
||||
isActive && "font-bold"
|
||||
)}
|
||||
>
|
||||
@@ -170,7 +176,7 @@ export const LinkItem = React.memo(
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary flex-none"
|
||||
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
|
||||
/>
|
||||
|
||||
<Link
|
||||
@@ -181,15 +187,14 @@ export const LinkItem = React.memo(
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="xl:truncate">{link.url}</span>
|
||||
<span className="line-clamp-1">{link.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-x-4"></div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { LinkItem } from "./link-item"
|
||||
import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
interface SectionProps {
|
||||
topic: Topic
|
||||
section: SectionSchema
|
||||
activeIndex: number
|
||||
startIndex: number
|
||||
linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
|
||||
setActiveIndex: (index: number) => void
|
||||
me: {
|
||||
root: {
|
||||
personalLinks: PersonalLinkLists
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
export function Section({
|
||||
topic,
|
||||
section,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
startIndex,
|
||||
linkRefs,
|
||||
me,
|
||||
personalLinks
|
||||
}: SectionProps) {
|
||||
const [nLinksToLoad, setNLinksToLoad] = useState(10)
|
||||
|
||||
const linksToLoad = useMemo(() => {
|
||||
return section.links?.slice(0, nLinksToLoad)
|
||||
}, [section.links, nLinksToLoad])
|
||||
|
||||
return (
|
||||
<div 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">{section.title}</p>
|
||||
<div className="flex-1 border-b"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-px py-2">
|
||||
{linksToLoad?.map((link, index) =>
|
||||
link?.url ? (
|
||||
<LinkItem
|
||||
key={index}
|
||||
topic={topic}
|
||||
link={link}
|
||||
isActive={activeIndex === startIndex + index}
|
||||
index={startIndex + index}
|
||||
setActiveIndex={setActiveIndex}
|
||||
ref={el => {
|
||||
linkRefs.current[startIndex + index] = el
|
||||
}}
|
||||
me={me}
|
||||
personalLinks={personalLinks}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton key={index} className="h-14 w-full xl:h-11" />
|
||||
)
|
||||
)}
|
||||
{section.links?.length && section.links?.length > nLinksToLoad && (
|
||||
<LoadMoreSpinner onLoadMore={() => setNLinksToLoad(n => n + 10)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => {
|
||||
const spinnerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
const [entry] = entries
|
||||
if (entry.isIntersecting) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
[onLoadMore]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
threshold: 1.0
|
||||
})
|
||||
|
||||
const currentSpinnerRef = spinnerRef.current
|
||||
|
||||
if (currentSpinnerRef) {
|
||||
observer.observe(currentSpinnerRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentSpinnerRef) {
|
||||
observer.unobserve(currentSpinnerRef)
|
||||
}
|
||||
}
|
||||
}, [handleIntersection])
|
||||
|
||||
return (
|
||||
<div ref={spinnerRef} className="flex justify-center py-4">
|
||||
<LaIcon name="Loader" className="size-6 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from "react"
|
||||
import { Section } from "./section"
|
||||
import { LaAccount, ListOfSections, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
|
||||
|
||||
interface TopicSectionsProps {
|
||||
topic: Topic
|
||||
sections: (ListOfSections | null) | undefined
|
||||
activeIndex: number
|
||||
setActiveIndex: (index: number) => void
|
||||
linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
|
||||
containerRef: React.RefObject<HTMLDivElement>
|
||||
me: {
|
||||
root: {
|
||||
personalLinks: PersonalLinkLists
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
personalLinks: PersonalLinkLists
|
||||
}
|
||||
|
||||
export function TopicSections({
|
||||
topic,
|
||||
sections,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
linkRefs,
|
||||
containerRef,
|
||||
me,
|
||||
personalLinks
|
||||
}: TopicSectionsProps) {
|
||||
return (
|
||||
<div ref={containerRef} className="flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<div tabIndex={-1} className="outline-none">
|
||||
<div className="flex flex-1 flex-col gap-4" role="listbox" aria-label="Topic sections">
|
||||
{sections?.map(
|
||||
(section, sectionIndex) =>
|
||||
section?.id && (
|
||||
<Section
|
||||
key={sectionIndex}
|
||||
topic={topic}
|
||||
section={section}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={setActiveIndex}
|
||||
startIndex={sections.slice(0, sectionIndex).reduce((acc, s) => acc + (s?.links?.length || 0), 0)}
|
||||
linkRefs={linkRefs}
|
||||
me={me}
|
||||
personalLinks={personalLinks}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { Link as LinkSchema } from "@/lib/schema"
|
||||
import { ensureUrlProtocol } from "@/lib/utils"
|
||||
|
||||
export function useLinkNavigation(allLinks: (LinkSchema | null)[]) {
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const linkRefs = useRef<(HTMLLIElement | null)[]>(allLinks.map(() => null))
|
||||
|
||||
const scrollToLink = useCallback((index: number) => {
|
||||
if (linkRefs.current[index] && containerRef.current) {
|
||||
const linkElement = linkRefs.current[index]
|
||||
const container = containerRef.current
|
||||
|
||||
const linkRect = linkElement?.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
|
||||
if (linkRect && containerRect) {
|
||||
if (linkRect.bottom > containerRect.bottom) {
|
||||
container.scrollTop += linkRect.bottom - containerRect.bottom
|
||||
} else if (linkRect.top < containerRect.top) {
|
||||
container.scrollTop -= containerRect.top - linkRect.top
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
console.log("handleKeyDown")
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveIndex(prevIndex => {
|
||||
const newIndex = (prevIndex + 1) % allLinks.length
|
||||
scrollToLink(newIndex)
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setActiveIndex(prevIndex => {
|
||||
const newIndex = (prevIndex - 1 + allLinks.length) % allLinks.length
|
||||
scrollToLink(newIndex)
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "Enter" && activeIndex !== -1) {
|
||||
const link = allLinks[activeIndex]
|
||||
if (link) {
|
||||
window.open(ensureUrlProtocol(link.url), "_blank")
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeIndex, allLinks, scrollToLink]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
return { activeIndex, setActiveIndex, containerRef, linkRefs }
|
||||
}
|
||||
31
web/components/routes/topics/header.tsx
Normal file
31
web/components/routes/topics/header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
|
||||
interface TopicHeaderProps {}
|
||||
|
||||
export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
|
||||
const { me } = useAccount()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
</ContentHeader>
|
||||
)
|
||||
})
|
||||
|
||||
TopicHeader.displayName = "TopicHeader"
|
||||
|
||||
const HeaderTitle: React.FC = () => (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">Topics</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
14
web/components/routes/topics/hooks/use-column-styles.ts
Normal file
14
web/components/routes/topics/hooks/use-column-styles.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
|
||||
export const useColumnStyles = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
|
||||
return {
|
||||
title: {
|
||||
"--width": "69px",
|
||||
"--min-width": "200px",
|
||||
"--max-width": isTablet ? "none" : "auto"
|
||||
},
|
||||
topic: { "--width": "65px", "--min-width": "120px", "--max-width": "120px" }
|
||||
}
|
||||
}
|
||||
123
web/components/routes/topics/list.tsx
Normal file
123
web/components/routes/topics/list.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as React from "react"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { atom } from "jotai"
|
||||
import { TopicItem } from "./partials/topic-item"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { Column } from "@/components/custom/column"
|
||||
import { useColumnStyles } from "./hooks/use-column-styles"
|
||||
import { LaAccount, ListOfTopics, Topic, UserRoot } from "@/lib/schema"
|
||||
import { LearningStateValue } from "@/lib/constants"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
|
||||
interface TopicListProps {}
|
||||
|
||||
interface MainTopicListProps extends TopicListProps {
|
||||
me: {
|
||||
root: {
|
||||
topicsWantToLearn: ListOfTopics
|
||||
topicsLearning: ListOfTopics
|
||||
topicsLearned: ListOfTopics
|
||||
} & UserRoot
|
||||
} & LaAccount
|
||||
}
|
||||
|
||||
export interface PersonalTopic {
|
||||
topic: Topic | null
|
||||
learningState: LearningStateValue
|
||||
}
|
||||
|
||||
export const topicOpenPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export const TopicList: React.FC<TopicListProps> = () => {
|
||||
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return <MainTopicList me={me} />
|
||||
}
|
||||
|
||||
export const MainTopicList: React.FC<MainTopicListProps> = ({ me }) => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(null)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<number | null>(null)
|
||||
|
||||
const personalTopics = React.useMemo(
|
||||
() => [
|
||||
...me.root.topicsWantToLearn.map(topic => ({ topic, learningState: "wantToLearn" as const })),
|
||||
...me.root.topicsLearning.map(topic => ({ topic, learningState: "learning" as const })),
|
||||
...me.root.topicsLearned.map(topic => ({ topic, learningState: "learned" as const }))
|
||||
],
|
||||
[me.root.topicsWantToLearn, me.root.topicsLearning, me.root.topicsLearned]
|
||||
)
|
||||
|
||||
const next = () => Math.min((activeItemIndex ?? 0) + 1, (personalTopics?.length ?? 0) - 1)
|
||||
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
}
|
||||
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: keyboardActiveIndex })
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<Primitive.div
|
||||
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
tabIndex={-1}
|
||||
role="list"
|
||||
>
|
||||
{personalTopics?.map(
|
||||
(pt, index) =>
|
||||
pt.topic?.id && (
|
||||
<TopicItem
|
||||
key={pt.topic.id}
|
||||
ref={el => setElementRef(el, index)}
|
||||
topic={pt.topic}
|
||||
learningState={pt.learningState}
|
||||
isActive={index === activeItemIndex}
|
||||
onPointerMove={() => {
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Primitive.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ColumnHeader: React.FC = () => {
|
||||
const columnStyles = useColumnStyles()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text>Name</Column.Text>
|
||||
</Column.Wrapper>
|
||||
<Column.Wrapper style={columnStyles.topic}>
|
||||
<Column.Text>State</Column.Text>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
174
web/components/routes/topics/partials/topic-item.tsx
Normal file
174
web/components/routes/topics/partials/topic-item.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useColumnStyles } from "../hooks/use-column-styles"
|
||||
import { ListOfTopics, Topic } from "@/lib/schema"
|
||||
import { Column } from "@/components/custom/column"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
import { useAtom } from "jotai"
|
||||
import { topicOpenPopoverForIdAtom } from "../list"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface TopicItemProps extends React.HTMLAttributes<HTMLAnchorElement> {
|
||||
topic: Topic
|
||||
learningState: LearningStateValue
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const TopicItem = React.forwardRef<HTMLAnchorElement, TopicItemProps>(
|
||||
({ topic, learningState, isActive, ...props }, ref) => {
|
||||
const columnStyles = useColumnStyles()
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(topicOpenPopoverForIdAtom)
|
||||
const router = useRouter()
|
||||
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
|
||||
|
||||
let p: {
|
||||
index: number
|
||||
topic?: Topic | null
|
||||
learningState: LearningStateValue
|
||||
} | null = null
|
||||
|
||||
const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1
|
||||
if (wantToLearnIndex !== -1) {
|
||||
p = {
|
||||
index: wantToLearnIndex,
|
||||
topic: me?.root.topicsWantToLearn[wantToLearnIndex],
|
||||
learningState: "wantToLearn"
|
||||
}
|
||||
}
|
||||
|
||||
const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1
|
||||
if (learningIndex !== -1) {
|
||||
p = {
|
||||
index: learningIndex,
|
||||
topic: me?.root.topicsLearning[learningIndex],
|
||||
learningState: "learning"
|
||||
}
|
||||
}
|
||||
|
||||
const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1
|
||||
if (learnedIndex !== -1) {
|
||||
p = {
|
||||
index: learnedIndex,
|
||||
topic: me?.root.topicsLearned[learnedIndex],
|
||||
learningState: "learned"
|
||||
}
|
||||
}
|
||||
|
||||
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === learningState), [learningState])
|
||||
|
||||
const handleLearningStateSelect = useCallback(
|
||||
(value: string) => {
|
||||
const newLearningState = value as LearningStateValue
|
||||
|
||||
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 (newLearningState === p.learningState) {
|
||||
removeFromList(p.learningState, p.index)
|
||||
return
|
||||
}
|
||||
removeFromList(p.learningState, p.index)
|
||||
}
|
||||
|
||||
topicLists[newLearningState]?.push(topic)
|
||||
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[setOpenPopoverForId, me?.root.topicsWantToLearn, me?.root.topicsLearning, me?.root.topicsLearned, p, topic]
|
||||
)
|
||||
|
||||
const handlePopoverTriggerClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setOpenPopoverForId(openPopoverForId === topic.id ? null : topic.id)
|
||||
}
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
router.push(`/${topic.name}`)
|
||||
}
|
||||
},
|
||||
[router, topic.name]
|
||||
)
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={`/${topic.name}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
"relative block cursor-default outline-none",
|
||||
"min-h-12 py-2 max-lg:px-4 sm:px-6",
|
||||
"data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
|
||||
)}
|
||||
aria-selected={isActive}
|
||||
data-active={isActive}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex h-full cursor-default items-center gap-4 outline-none" tabIndex={isActive ? 0 : -1}>
|
||||
<Column.Wrapper style={columnStyles.title}>
|
||||
<Column.Text className="truncate text-[13px] font-medium">{topic.prettyName}</Column.Text>
|
||||
</Column.Wrapper>
|
||||
|
||||
<Column.Wrapper style={columnStyles.topic} className="max-sm:justify-end">
|
||||
<Popover
|
||||
open={openPopoverForId === topic.id}
|
||||
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? topic.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={handlePopoverTriggerClick}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={learningState}
|
||||
onSelect={handleLearningStateSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Column.Wrapper>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TopicItem.displayName = "TopicItem"
|
||||
Reference in New Issue
Block a user