fix: conflict

This commit is contained in:
Aslam H
2024-09-28 19:53:32 +07:00
205 changed files with 8806 additions and 2121 deletions

View 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>
)
}

View File

@@ -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."
})

View 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>
)
}

View 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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View 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
}
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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")}
/>
</>

View File

@@ -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>

View File

@@ -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"

View 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>
)
}

View File

@@ -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]">

View File

@@ -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>
</>
)
}

View 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>
)

View 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" }
}
}

View 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 }
}

View 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>
)
}

View 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"

View File

@@ -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>
))}

View File

@@ -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>
)
}

View File

@@ -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) {

View File

@@ -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&apos;t find what you were looking for? Ask AI
Didn&apos;t find what you were looking for? Will soon have AI assistant builtin
</div>
)}
{showAiSearch && <AiSearch searchQuery={searchText} />}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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>
)
}
)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 }
}

View 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>
)

View 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" }
}
}

View 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>
)
}

View 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"