mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Merge branch 'main' into tasks
This commit is contained in:
@@ -14,14 +14,14 @@
|
||||
"web"
|
||||
],
|
||||
"dependencies": {
|
||||
"@clerk/themes": "^2.1.27",
|
||||
"@tauri-apps/cli": "^2.0.0-rc.12",
|
||||
"@clerk/themes": "^2.1.30",
|
||||
"@tauri-apps/cli": "^2.0.0-rc.16",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
||||
"jazz-nodejs": "0.7.35-guest-auth.5",
|
||||
"react-icons": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.27"
|
||||
"bun-types": "^1.1.28"
|
||||
},
|
||||
"prettier": {
|
||||
"plugins": [
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import type { Viewport } from "next"
|
||||
import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
||||
import { CommandPalette } from "@/components/custom/command-palette/command-palette"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
|
||||
import { Shortcut } from "@/components/custom/Shortcut/shortcut"
|
||||
import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler"
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width, shrink-to-fit=no",
|
||||
maximumScale: 1,
|
||||
userScalable: false
|
||||
}
|
||||
|
||||
export default function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
||||
<Sidebar />
|
||||
<LearnAnythingOnboarding />
|
||||
<GlobalKeydownHandler />
|
||||
|
||||
{me._type !== "Anonymous" && <CommandPalette />}
|
||||
<CommandPalette />
|
||||
<Shortcut />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
5
web/app/(pages)/topics/page.tsx
Normal file
5
web/app/(pages)/topics/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
|
||||
|
||||
export default function Page() {
|
||||
return <TopicRoute />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ThemeProvider } from "@/lib/providers/theme-provider"
|
||||
import "./globals.css"
|
||||
@@ -10,7 +10,13 @@ import { DeepLinkProvider } from "@/lib/providers/deep-link-provider"
|
||||
import { GeistMono, GeistSans } from "./fonts"
|
||||
import { JazzAndAuth } from "@/lib/providers/jazz-provider"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
height: "device-height",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover"
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Learn Anything",
|
||||
@@ -43,7 +49,7 @@ export default function RootLayout({
|
||||
<body className={cn("h-full w-full font-sans antialiased", GeistSans.variable, GeistMono.variable)}>
|
||||
<Providers>
|
||||
{children}
|
||||
<LearnAnythingOnboarding />
|
||||
|
||||
<Toaster expand={false} />
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
156
web/components/custom/Shortcut/shortcut.tsx
Normal file
156
web/components/custom/Shortcut/shortcut.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { Sheet, SheetPortal, SheetOverlay, SheetTitle, sheetVariants, SheetDescription } from "@/components/ui/sheet"
|
||||
import { LaIcon } from "../la-icon"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
|
||||
export const showShortcutAtom = atom(false)
|
||||
|
||||
type ShortcutItem = {
|
||||
label: string
|
||||
keys: string[]
|
||||
then?: string[]
|
||||
}
|
||||
|
||||
type ShortcutSection = {
|
||||
title: string
|
||||
shortcuts: ShortcutItem[]
|
||||
}
|
||||
|
||||
const SHORTCUTS: ShortcutSection[] = [
|
||||
{
|
||||
title: "General",
|
||||
shortcuts: [
|
||||
{ label: "Open command menu", keys: ["⌘", "k"] },
|
||||
{ label: "Log out", keys: ["⌥", "⇧", "q"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Navigation",
|
||||
shortcuts: [
|
||||
{ label: "Go to link", keys: ["G"], then: ["L"] },
|
||||
{ label: "Go to page", keys: ["G"], then: ["P"] },
|
||||
{ label: "Go to topic", keys: ["G"], then: ["T"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => (
|
||||
<kbd
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-5 items-center justify-center rounded border font-sans text-xs capitalize"
|
||||
>
|
||||
{keyChar}
|
||||
</kbd>
|
||||
)
|
||||
|
||||
const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<dt className="flex grow items-center">
|
||||
<span className="text-muted-foreground text-left text-sm">{label}</span>
|
||||
</dt>
|
||||
<dd className="flex items-end">
|
||||
<span className="text-left">
|
||||
<span
|
||||
aria-label={keys.join(" ") + (then ? ` then ${then.join(" ")}` : "")}
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
{keys.map((key, index) => (
|
||||
<ShortcutKey key={index} keyChar={key} />
|
||||
))}
|
||||
{then && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs">then</span>
|
||||
{then.map((key, index) => (
|
||||
<ShortcutKey key={`then-${index}`} keyChar={key} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ShortcutSection: React.FC<ShortcutSection> = ({ title, shortcuts }) => (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="inline-flex gap-1.5 text-sm">{title}</h2>
|
||||
<dl className="m-0 flex flex-col gap-2">
|
||||
{shortcuts.map((shortcut, index) => (
|
||||
<ShortcutItem key={index} {...shortcut} />
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
|
||||
export function Shortcut() {
|
||||
const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("shortcutSection")
|
||||
|
||||
React.useEffect(() => {
|
||||
disableKeydown(showShortcut)
|
||||
}, [showShortcut, disableKeydown])
|
||||
|
||||
const filteredShortcuts = React.useMemo(() => {
|
||||
if (!searchQuery) return SHORTCUTS
|
||||
|
||||
return SHORTCUTS.map(section => ({
|
||||
...section,
|
||||
shortcuts: section.shortcuts.filter(shortcut => shortcut.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
})).filter(section => section.shortcuts.length > 0)
|
||||
}, [searchQuery])
|
||||
|
||||
return (
|
||||
<Sheet open={showShortcut} onOpenChange={setShowShortcut}>
|
||||
<SheetPortal>
|
||||
<SheetOverlay className="bg-black/10" />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(sheetVariants({ side: "right" }), "m-3 h-[calc(100vh-24px)] rounded-md p-0")}
|
||||
>
|
||||
<header className="flex flex-[0_0_auto] items-center gap-3 px-5 pb-4 pt-5">
|
||||
<SheetTitle className="text-base font-medium">Keyboard Shortcuts</SheetTitle>
|
||||
<SheetDescription className="sr-only">Quickly navigate around the app</SheetDescription>
|
||||
|
||||
<div className="flex-auto"></div>
|
||||
|
||||
<SheetPrimitive.Close className={cn(buttonVariants({ size: "icon", variant: "ghost" }), "size-6 p-0")}>
|
||||
<LaIcon name="X" className="text-muted-foreground size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-1 px-5 pb-6">
|
||||
<form className="relative flex items-center">
|
||||
<LaIcon name="Search" className="text-muted-foreground absolute left-3 size-4" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search shortcuts"
|
||||
className="border-muted-foreground/50 focus-visible:border-muted-foreground h-10 pl-10 focus-visible:ring-0"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main className="flex-auto overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
|
||||
<div className="px-5 pb-5">
|
||||
<div role="region" aria-live="polite" className="flex flex-col gap-7">
|
||||
{filteredShortcuts.map((section, index) => (
|
||||
<ShortcutSection key={index} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { HTMLLikeElement } from "@/lib/utils"
|
||||
export type CommandAction = string | (() => void)
|
||||
|
||||
export interface CommandItemType {
|
||||
id?: string
|
||||
icon?: keyof typeof icons
|
||||
value: string
|
||||
label: HTMLLikeElement | string
|
||||
|
||||
@@ -11,14 +11,14 @@ export interface CommandItemProps extends Omit<CommandItemType, "action"> {
|
||||
}
|
||||
|
||||
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = React.memo(({ content }) => {
|
||||
return <>{renderHTMLLikeElement(content)}</>
|
||||
return <span className="line-clamp-1">{renderHTMLLikeElement(content)}</span>
|
||||
})
|
||||
|
||||
HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
|
||||
|
||||
export const CommandItem: React.FC<CommandItemProps> = React.memo(
|
||||
({ icon, label, action, payload, shortcut, handleAction }) => (
|
||||
<Command.Item onSelect={() => handleAction(action, payload)}>
|
||||
({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
|
||||
<Command.Item value={`${item.id}-${item.value}`} onSelect={() => handleAction(action, payload)}>
|
||||
{icon && <LaIcon name={icon} />}
|
||||
<HTMLLikeRenderer content={label} />
|
||||
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Command } from "cmdk"
|
||||
import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { CommandGroup } from "./command-items"
|
||||
import { CommandAction, CommandItemType, createCommandGroups } from "./command-data"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { searchSafeRegExp } from "@/lib/utils"
|
||||
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
|
||||
import { useCommandActions } from "./hooks/use-command-actions"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
|
||||
const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
|
||||
|
||||
@@ -18,6 +21,14 @@ const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
|
||||
export const commandPaletteOpenAtom = atom(false)
|
||||
|
||||
export function CommandPalette() {
|
||||
const { me } = useAccountOrGuest()
|
||||
|
||||
if (me._type === "Anonymous") return null
|
||||
|
||||
return <RealCommandPalette />
|
||||
}
|
||||
|
||||
export function RealCommandPalette() {
|
||||
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
||||
const dialogRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
@@ -29,17 +40,17 @@ export function CommandPalette() {
|
||||
|
||||
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
const handleKeydown = React.useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
return () => document.removeEventListener("keydown", down)
|
||||
}, [setOpen])
|
||||
useKeydownListener(handleKeydown)
|
||||
|
||||
const bounce = React.useCallback(() => {
|
||||
if (dialogRef.current) {
|
||||
@@ -86,6 +97,7 @@ export function CommandPalette() {
|
||||
heading: "Personal Links",
|
||||
items:
|
||||
me?.root.personalLinks?.map(link => ({
|
||||
id: link?.id,
|
||||
icon: "Link" as const,
|
||||
value: link?.title || "Untitled",
|
||||
label: link?.title || "Untitled",
|
||||
@@ -100,6 +112,7 @@ export function CommandPalette() {
|
||||
heading: "Personal Pages",
|
||||
items:
|
||||
me?.root.personalPages?.map(page => ({
|
||||
id: page?.id,
|
||||
icon: "FileText" as const,
|
||||
value: page?.title || "Untitled",
|
||||
label: page?.title || "Untitled",
|
||||
@@ -116,11 +129,9 @@ export function CommandPalette() {
|
||||
|
||||
if (activePage === "home") {
|
||||
if (!inputValue) {
|
||||
// Only show items from the home object when there's no search input
|
||||
return commandGroups.home
|
||||
}
|
||||
|
||||
// When there's a search input, search across all categories
|
||||
const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics]
|
||||
|
||||
return allGroups
|
||||
@@ -131,7 +142,6 @@ export function CommandPalette() {
|
||||
.filter(group => group.items.length > 0)
|
||||
}
|
||||
|
||||
// Handle other active pages (searchLinks, searchPages, etc.)
|
||||
switch (activePage) {
|
||||
case "searchLinks":
|
||||
return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }]
|
||||
@@ -184,7 +194,7 @@ export function CommandPalette() {
|
||||
const commandKey = React.useMemo(() => {
|
||||
return filteredCommands
|
||||
.map(group => {
|
||||
const itemsKey = group.items.map(item => `${item.label}-${item.action}`).join("|")
|
||||
const itemsKey = group.items.map(item => `${item.label}-${item.value}`).join("|")
|
||||
return `${group.heading}:${itemsKey}`
|
||||
})
|
||||
.join("__")
|
||||
|
||||
@@ -3,11 +3,13 @@ import { ensureUrlProtocol } from "@/lib/utils"
|
||||
import { useTheme } from "next-themes"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LaAccount, PersonalPage } from "@/lib/schema"
|
||||
import { LaAccount } from "@/lib/schema"
|
||||
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
|
||||
|
||||
export const useCommandActions = () => {
|
||||
const { setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const changeTheme = React.useCallback(
|
||||
(theme: string) => {
|
||||
@@ -35,19 +37,10 @@ export const useCommandActions = () => {
|
||||
|
||||
const createNewPage = React.useCallback(
|
||||
(me: LaAccount) => {
|
||||
try {
|
||||
const newPersonalPage = PersonalPage.create(
|
||||
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ owner: me._owner }
|
||||
)
|
||||
|
||||
me.root?.personalPages?.push(newPersonalPage)
|
||||
router.push(`/pages/${newPersonalPage.id}`)
|
||||
} catch (error) {
|
||||
toast.error("Failed to create page")
|
||||
}
|
||||
const page = newPage(me)
|
||||
router.push(`/pages/${page.id}`)
|
||||
},
|
||||
[router]
|
||||
[router, newPage]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
23
web/components/custom/discordIcon.tsx
Normal file
23
web/components/custom/discordIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export const DiscordIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.9143 7.38378L4.93679 14.6174C4.82454 15.448 5.24219 16.2606 5.983 16.6528L8.99995 18.25L9.99995 15.75C9.99995 15.75 10.6562 16.25 11.9999 16.25C13.3437 16.25 13.9999 15.75 13.9999 15.75L14.9999 18.25L18.0169 16.6528C18.7577 16.2606 19.1754 15.448 19.0631 14.6174L18.0856 7.38378C18.0334 6.99739 17.7613 6.67658 17.3887 6.56192L14.7499 5.75003V6.25003C14.7499 6.80232 14.3022 7.25003 13.7499 7.25003H10.2499C9.69766 7.25003 9.24995 6.80232 9.24995 6.25003V5.75003L6.61122 6.56192C6.23855 6.67658 5.96652 6.99739 5.9143 7.38378Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M10.5 12C10.5 12.2761 10.2761 12.5 10 12.5C9.72386 12.5 9.5 12.2761 9.5 12C9.5 11.7239 9.72386 11.5 10 11.5C10.2761 11.5 10.5 11.7239 10.5 12Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
<path
|
||||
d="M14.5 12C14.5 12.2761 14.2761 12.5 14 12.5C13.7239 12.5 13.5 12.2761 13.5 12C13.5 11.7239 13.7239 11.5 14 11.5C14.2761 11.5 14.5 11.7239 14.5 12Z"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
63
web/components/custom/global-keydown-handler.tsx
Normal file
63
web/components/custom/global-keydown-handler.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
import { useAuth } from "@clerk/nextjs"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
type Sequence = {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
const SEQUENCES: Sequence = {
|
||||
GL: "/links",
|
||||
GP: "/pages",
|
||||
GT: "/topics"
|
||||
}
|
||||
|
||||
const MAX_SEQUENCE_TIME = 1000
|
||||
|
||||
export function GlobalKeydownHandler() {
|
||||
const [sequence, setSequence] = useState<string[]>([])
|
||||
const { signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const resetSequence = useCallback(() => {
|
||||
setSequence([])
|
||||
}, [])
|
||||
|
||||
const checkSequence = useCallback(() => {
|
||||
const sequenceStr = sequence.join("")
|
||||
const route = SEQUENCES[sequenceStr]
|
||||
|
||||
if (route) {
|
||||
console.log(`Navigating to ${route}...`)
|
||||
router.push(route)
|
||||
resetSequence()
|
||||
}
|
||||
}, [sequence, router, resetSequence])
|
||||
|
||||
useKeydownListener((e: KeyboardEvent) => {
|
||||
// Check for logout shortcut
|
||||
if (e.altKey && e.shiftKey && e.code === "KeyQ") {
|
||||
signOut()
|
||||
return
|
||||
}
|
||||
|
||||
// Key sequence handling
|
||||
const key = e.key.toUpperCase()
|
||||
setSequence(prev => [...prev, key])
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
checkSequence()
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resetSequence()
|
||||
}, MAX_SEQUENCE_TIME)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [sequence, checkSequence, resetSequence])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -20,7 +20,7 @@ interface LearningStateSelectorProps {
|
||||
|
||||
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
showSearch = true,
|
||||
defaultLabel = "Select state",
|
||||
defaultLabel = "State",
|
||||
searchPlaceholder = "Search state...",
|
||||
value,
|
||||
onChange,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils"
|
||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { toast } from "sonner"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { icons } from "lucide-react"
|
||||
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
|
||||
|
||||
type SortOption = "title" | "recent"
|
||||
type ShowOption = 5 | 10 | 15 | 20 | 0
|
||||
@@ -101,20 +101,13 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActi
|
||||
const NewPageButton: React.FC = () => {
|
||||
const { me } = useAccount()
|
||||
const router = useRouter()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const handleClick = () => {
|
||||
try {
|
||||
const newPersonalPage = PersonalPage.create(
|
||||
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ owner: me._owner }
|
||||
)
|
||||
me.root?.personalPages?.push(newPersonalPage)
|
||||
router.push(`/pages/${newPersonalPage.id}`)
|
||||
} catch (error) {
|
||||
toast.error("Failed to create page")
|
||||
}
|
||||
const page = newPage(me)
|
||||
router.push(`/pages/${page.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useState } from "react"
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { SignInButton, useAuth, useUser } from "@clerk/nextjs"
|
||||
import { useAtom } from "jotai"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { DiscordIcon } from "@/components/custom/discordIcon"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -9,17 +17,25 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Feedback } from "./feedback"
|
||||
import { showShortcutAtom } from "@/components/custom/Shortcut/shortcut"
|
||||
import { ShortcutKey } from "@/components/minimal-tiptap/components/shortcut-key"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { user, isSignedIn } = useUser()
|
||||
const { signOut } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const [, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("profileSection")
|
||||
|
||||
useEffect(() => {
|
||||
disableKeydown(menuOpen)
|
||||
}, [menuOpen, disableKeydown])
|
||||
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
@@ -37,78 +53,104 @@ export const ProfileSection: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
||||
<div className="flex h-10 min-w-full items-center">
|
||||
<div className="flex min-w-0">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">{user.fullName}</span>
|
||||
<LaIcon
|
||||
name="ChevronDown"
|
||||
className={cn("size-4 shrink-0 transition-transform duration-300", {
|
||||
"rotate-180": menuOpen
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" href="/profile">
|
||||
<div className="relative flex flex-1 items-center gap-2">
|
||||
<LaIcon name="CircleUser" />
|
||||
<span className="line-clamp-1 flex-1">My profile</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" href="/onboarding">
|
||||
<div className="relative flex flex-1 items-center gap-2">
|
||||
<LaIcon name="LayoutList" />
|
||||
<span className="line-clamp-1 flex-1">Onboarding</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" href="https://docs.learn-anything.xyz/">
|
||||
<div className="relative flex flex-1 items-center gap-2">
|
||||
<LaIcon name="Sticker" />
|
||||
<span className="line-clamp-1 flex-1">Docs</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" href="https://github.com/learn-anything/learn-anything">
|
||||
<div className="relative flex flex-1 items-center gap-2">
|
||||
<LaIcon name="Github" />
|
||||
<span className="line-clamp-1 flex-1">GitHub</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={() => signOut()}>
|
||||
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
|
||||
<LaIcon name="LogOut" />
|
||||
<span className="line-clamp-1 flex-1">Log out</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<ProfileDropdown
|
||||
user={user}
|
||||
menuOpen={menuOpen}
|
||||
setMenuOpen={setMenuOpen}
|
||||
signOut={signOut}
|
||||
setShowShortcut={setShowShortcut}
|
||||
/>
|
||||
<Feedback />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileDropdownProps {
|
||||
user: any
|
||||
menuOpen: boolean
|
||||
setMenuOpen: (open: boolean) => void
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const ProfileDropdown: React.FC<ProfileDropdownProps> = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => (
|
||||
<div className="flex min-w-0">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Profile"
|
||||
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
|
||||
</Avatar>
|
||||
<span className="truncate text-left text-sm font-medium -tracking-wider">{user.fullName}</span>
|
||||
<LaIcon
|
||||
name="ChevronDown"
|
||||
className={cn("size-4 shrink-0 transition-transform duration-300", {
|
||||
"rotate-180": menuOpen
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start" side="top">
|
||||
<DropdownMenuItems signOut={signOut} setShowShortcut={setShowShortcut} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface DropdownMenuItemsProps {
|
||||
signOut: () => void
|
||||
setShowShortcut: (show: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownMenuItems: React.FC<DropdownMenuItemsProps> = ({ signOut, setShowShortcut }) => (
|
||||
<>
|
||||
<MenuLink href="/profile" icon="CircleUser" text="My profile" />
|
||||
<DropdownMenuItem className="gap-2" onClick={() => setShowShortcut(true)}>
|
||||
<LaIcon name="Keyboard" />
|
||||
<span>Shortcut</span>
|
||||
</DropdownMenuItem>
|
||||
<MenuLink href="/onboarding" icon="LayoutList" text="Onboarding" />
|
||||
<DropdownMenuSeparator />
|
||||
<MenuLink href="https://docs.learn-anything.xyz/" icon="Sticker" text="Docs" />
|
||||
<MenuLink href="https://github.com/learn-anything/learn-anything" icon="Github" text="GitHub" />
|
||||
<MenuLink href="https://discord.com/invite/bxtD8x6aNF" icon={DiscordIcon} text="Discord" iconClass="-ml-1" />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={signOut}>
|
||||
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
|
||||
<LaIcon name="LogOut" />
|
||||
<span>Log out</span>
|
||||
<div className="absolute right-0">
|
||||
<ShortcutKey keys={["alt", "shift", "q"]} />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
|
||||
interface MenuLinkProps {
|
||||
href: string
|
||||
icon: keyof typeof icons | React.FC
|
||||
text: string
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
const MenuLink: React.FC<MenuLinkProps> = ({ href, icon, text, iconClass = "" }) => {
|
||||
const IconComponent = typeof icon === "string" ? icons[icon] : icon
|
||||
return (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className="cursor-pointer" href={href}>
|
||||
<div className={cn("relative flex flex-1 items-center gap-2", iconClass)}>
|
||||
<IconComponent className="size-4" />
|
||||
<span className="line-clamp-1 flex-1">{text}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileSection
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getShortcutKey } from "../../lib/utils"
|
||||
import { getShortcutKey } from "@/lib/utils"
|
||||
|
||||
export interface ShortcutKeyWrapperProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
ariaLabel: string
|
||||
@@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ class
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{getShortcutKey(shortcut)}
|
||||
{getShortcutKey(shortcut).symbol}
|
||||
</kbd>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
import { Command, MenuListProps } from "./types"
|
||||
import { getShortcutKeys } from "../../lib/utils"
|
||||
import { getShortcutKeys } from "@/lib/utils"
|
||||
import { Icon } from "../../components/ui/icon"
|
||||
import { PopoverWrapper } from "../../components/ui/popover-wrapper"
|
||||
import { Shortcut } from "../../components/ui/shortcut"
|
||||
@@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => {
|
||||
<Icon name={command.iconName} />
|
||||
<span className="truncate text-sm">{command.label}</span>
|
||||
<div className="flex flex-auto flex-row"></div>
|
||||
<Shortcut.Wrapper ariaLabel={getShortcutKeys(command.shortcuts)}>
|
||||
<Shortcut.Wrapper
|
||||
ariaLabel={getShortcutKeys(command.shortcuts)
|
||||
.map(shortcut => shortcut.readable)
|
||||
.join(" + ")}
|
||||
>
|
||||
{command.shortcuts.map(shortcut => (
|
||||
<Shortcut.Key shortcut={shortcut} key={shortcut} />
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) {
|
||||
return ""
|
||||
}
|
||||
|
||||
export * from "./keyboard"
|
||||
export * from "./platform"
|
||||
export * from "./isCustomNodeSelected"
|
||||
export * from "./isTextSelected"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { isMacOS } from "./platform"
|
||||
|
||||
export const getShortcutKey = (key: string) => {
|
||||
const lowercaseKey = key.toLowerCase()
|
||||
const macOS = isMacOS()
|
||||
|
||||
switch (lowercaseKey) {
|
||||
case "mod":
|
||||
return macOS ? "⌘" : "Ctrl"
|
||||
case "alt":
|
||||
return macOS ? "⌥" : "Alt"
|
||||
case "shift":
|
||||
return macOS ? "⇧" : "Shift"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
export const getShortcutKeys = (keys: string | string[], separator: string = "") => {
|
||||
const keyArray = Array.isArray(keys) ? keys : keys.split(/\s+/)
|
||||
const shortcutKeys = keyArray.map(getShortcutKey)
|
||||
return shortcutKeys.join(separator)
|
||||
}
|
||||
|
||||
export default { getShortcutKey, getShortcutKeys }
|
||||
@@ -1,46 +0,0 @@
|
||||
export interface NavigatorWithUserAgentData extends Navigator {
|
||||
userAgentData?: {
|
||||
brands: { brand: string; version: string }[]
|
||||
mobile: boolean
|
||||
platform: string
|
||||
getHighEntropyValues: (hints: string[]) => Promise<{
|
||||
platform: string
|
||||
platformVersion: string
|
||||
uaFullVersion: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
let isMac: boolean | undefined
|
||||
|
||||
const getPlatform = () => {
|
||||
const nav = navigator as NavigatorWithUserAgentData
|
||||
if (nav.userAgentData) {
|
||||
if (nav.userAgentData.platform) {
|
||||
return nav.userAgentData.platform
|
||||
}
|
||||
|
||||
nav.userAgentData
|
||||
.getHighEntropyValues(["platform"])
|
||||
.then(highEntropyValues => {
|
||||
if (highEntropyValues.platform) {
|
||||
return highEntropyValues.platform
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
return navigator.platform || ""
|
||||
})
|
||||
}
|
||||
|
||||
return navigator.platform || ""
|
||||
}
|
||||
|
||||
export const isMacOS = () => {
|
||||
if (isMac === undefined) {
|
||||
isMac = getPlatform().toLowerCase().includes("mac")
|
||||
}
|
||||
|
||||
return isMac
|
||||
}
|
||||
|
||||
export default isMacOS
|
||||
@@ -1,33 +1,33 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getShortcutKey } from '../utils'
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getShortcutKey } from "@/lib/utils"
|
||||
|
||||
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
keys: string[]
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
|
||||
const modifiedKeys = keys.map(key => getShortcutKey(key))
|
||||
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ')
|
||||
const modifiedKeys = keys.map(key => getShortcutKey(key))
|
||||
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ")
|
||||
|
||||
return (
|
||||
<span aria-label={ariaLabel} className={cn('inline-flex items-center gap-0.5', className)} {...props} ref={ref}>
|
||||
{modifiedKeys.map(shortcut => (
|
||||
<kbd
|
||||
key={shortcut.symbol}
|
||||
className={cn(
|
||||
'inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]',
|
||||
return (
|
||||
<span aria-label={ariaLabel} className={cn("inline-flex items-center gap-0.5", className)} {...props} ref={ref}>
|
||||
{modifiedKeys.map(shortcut => (
|
||||
<kbd
|
||||
key={shortcut.symbol}
|
||||
className={cn(
|
||||
"inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{shortcut.symbol}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{shortcut.symbol}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
ShortcutKey.displayName = 'ShortcutKey'
|
||||
ShortcutKey.displayName = "ShortcutKey"
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
import * as React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CaretDownIcon } from '@radix-ui/react-icons'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { ToolbarButton } from './toolbar-button'
|
||||
import { ShortcutKey } from './shortcut-key'
|
||||
import { getShortcutKey } from '../utils'
|
||||
import type { FormatAction } from '../types'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
import * as React from "react"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CaretDownIcon } from "@radix-ui/react-icons"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { ToolbarButton } from "./toolbar-button"
|
||||
import { ShortcutKey } from "./shortcut-key"
|
||||
import { getShortcutKey } from "@/lib/utils"
|
||||
import type { FormatAction } from "../types"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
interface ToolbarSectionProps extends VariantProps<typeof toggleVariants> {
|
||||
editor: Editor
|
||||
actions: FormatAction[]
|
||||
activeActions?: string[]
|
||||
mainActionCount?: number
|
||||
dropdownIcon?: React.ReactNode
|
||||
dropdownTooltip?: string
|
||||
dropdownClassName?: string
|
||||
editor: Editor
|
||||
actions: FormatAction[]
|
||||
activeActions?: string[]
|
||||
mainActionCount?: number
|
||||
dropdownIcon?: React.ReactNode
|
||||
dropdownTooltip?: string
|
||||
dropdownClassName?: string
|
||||
}
|
||||
|
||||
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
|
||||
editor,
|
||||
actions,
|
||||
activeActions = actions.map(action => action.value),
|
||||
mainActionCount = 0,
|
||||
dropdownIcon,
|
||||
dropdownTooltip = 'More options',
|
||||
dropdownClassName = 'w-12',
|
||||
size,
|
||||
variant
|
||||
editor,
|
||||
actions,
|
||||
activeActions = actions.map(action => action.value),
|
||||
mainActionCount = 0,
|
||||
dropdownIcon,
|
||||
dropdownTooltip = "More options",
|
||||
dropdownClassName = "w-12",
|
||||
size,
|
||||
variant
|
||||
}) => {
|
||||
const { mainActions, dropdownActions } = React.useMemo(() => {
|
||||
const sortedActions = actions
|
||||
.filter(action => activeActions.includes(action.value))
|
||||
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
|
||||
const { mainActions, dropdownActions } = React.useMemo(() => {
|
||||
const sortedActions = actions
|
||||
.filter(action => activeActions.includes(action.value))
|
||||
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
|
||||
|
||||
return {
|
||||
mainActions: sortedActions.slice(0, mainActionCount),
|
||||
dropdownActions: sortedActions.slice(mainActionCount)
|
||||
}
|
||||
}, [actions, activeActions, mainActionCount])
|
||||
return {
|
||||
mainActions: sortedActions.slice(0, mainActionCount),
|
||||
dropdownActions: sortedActions.slice(mainActionCount)
|
||||
}
|
||||
}, [actions, activeActions, mainActionCount])
|
||||
|
||||
const renderToolbarButton = React.useCallback(
|
||||
(action: FormatAction) => (
|
||||
<ToolbarButton
|
||||
key={action.label}
|
||||
onClick={() => action.action(editor)}
|
||||
disabled={!action.canExecute(editor)}
|
||||
isActive={action.isActive(editor)}
|
||||
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`}
|
||||
aria-label={action.label}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
{action.icon}
|
||||
</ToolbarButton>
|
||||
),
|
||||
[editor, size, variant]
|
||||
)
|
||||
const renderToolbarButton = React.useCallback(
|
||||
(action: FormatAction) => (
|
||||
<ToolbarButton
|
||||
key={action.label}
|
||||
onClick={() => action.action(editor)}
|
||||
disabled={!action.canExecute(editor)}
|
||||
isActive={action.isActive(editor)}
|
||||
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`}
|
||||
aria-label={action.label}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
{action.icon}
|
||||
</ToolbarButton>
|
||||
),
|
||||
[editor, size, variant]
|
||||
)
|
||||
|
||||
const renderDropdownMenuItem = React.useCallback(
|
||||
(action: FormatAction) => (
|
||||
<DropdownMenuItem
|
||||
key={action.label}
|
||||
onClick={() => action.action(editor)}
|
||||
disabled={!action.canExecute(editor)}
|
||||
className={cn('flex flex-row items-center justify-between gap-4', {
|
||||
'bg-accent': action.isActive(editor)
|
||||
})}
|
||||
aria-label={action.label}
|
||||
>
|
||||
<span className="grow">{action.label}</span>
|
||||
<ShortcutKey keys={action.shortcuts} />
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
[editor]
|
||||
)
|
||||
const renderDropdownMenuItem = React.useCallback(
|
||||
(action: FormatAction) => (
|
||||
<DropdownMenuItem
|
||||
key={action.label}
|
||||
onClick={() => action.action(editor)}
|
||||
disabled={!action.canExecute(editor)}
|
||||
className={cn("flex flex-row items-center justify-between gap-4", {
|
||||
"bg-accent": action.isActive(editor)
|
||||
})}
|
||||
aria-label={action.label}
|
||||
>
|
||||
<span className="grow">{action.label}</span>
|
||||
<ShortcutKey keys={action.shortcuts} />
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
[editor]
|
||||
)
|
||||
|
||||
const isDropdownActive = React.useMemo(
|
||||
() => dropdownActions.some(action => action.isActive(editor)),
|
||||
[dropdownActions, editor]
|
||||
)
|
||||
const isDropdownActive = React.useMemo(
|
||||
() => dropdownActions.some(action => action.isActive(editor)),
|
||||
[dropdownActions, editor]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{mainActions.map(renderToolbarButton)}
|
||||
{dropdownActions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton
|
||||
isActive={isDropdownActive}
|
||||
tooltip={dropdownTooltip}
|
||||
aria-label={dropdownTooltip}
|
||||
className={cn(dropdownClassName)}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
{dropdownIcon || <CaretDownIcon className="size-5" />}
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-full">
|
||||
{dropdownActions.map(renderDropdownMenuItem)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{mainActions.map(renderToolbarButton)}
|
||||
{dropdownActions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ToolbarButton
|
||||
isActive={isDropdownActive}
|
||||
tooltip={dropdownTooltip}
|
||||
aria-label={dropdownTooltip}
|
||||
className={cn(dropdownClassName)}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
{dropdownIcon || <CaretDownIcon className="size-5" />}
|
||||
</ToolbarButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-full">
|
||||
{dropdownActions.map(renderDropdownMenuItem)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolbarSection
|
||||
|
||||
@@ -1,81 +1,14 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { MinimalTiptapProps } from './minimal-tiptap'
|
||||
import type { Editor } from "@tiptap/core"
|
||||
import type { MinimalTiptapProps } from "./minimal-tiptap"
|
||||
|
||||
let isMac: boolean | undefined
|
||||
export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) {
|
||||
if (format === "json") {
|
||||
return editor.getJSON()
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
userAgentData?: {
|
||||
brands: { brand: string; version: string }[]
|
||||
mobile: boolean
|
||||
platform: string
|
||||
getHighEntropyValues: (hints: string[]) => Promise<{
|
||||
platform: string
|
||||
platformVersion: string
|
||||
uaFullVersion: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatform(): string {
|
||||
const nav = navigator as Navigator
|
||||
|
||||
if (nav.userAgentData) {
|
||||
if (nav.userAgentData.platform) {
|
||||
return nav.userAgentData.platform
|
||||
}
|
||||
|
||||
nav.userAgentData.getHighEntropyValues(['platform']).then(highEntropyValues => {
|
||||
if (highEntropyValues.platform) {
|
||||
return highEntropyValues.platform
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof navigator.platform === 'string') {
|
||||
return navigator.platform
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isMacOS() {
|
||||
if (isMac === undefined) {
|
||||
isMac = getPlatform().toLowerCase().includes('mac')
|
||||
}
|
||||
|
||||
return isMac
|
||||
}
|
||||
|
||||
interface ShortcutKeyResult {
|
||||
symbol: string
|
||||
readable: string
|
||||
}
|
||||
|
||||
export function getShortcutKey(key: string): ShortcutKeyResult {
|
||||
const lowercaseKey = key.toLowerCase()
|
||||
if (lowercaseKey === 'mod') {
|
||||
return isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' }
|
||||
} else if (lowercaseKey === 'alt') {
|
||||
return isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' }
|
||||
} else if (lowercaseKey === 'shift') {
|
||||
return isMacOS() ? { symbol: '⇧', readable: 'Shift' } : { symbol: 'Shift', readable: 'Shift' }
|
||||
} else {
|
||||
return { symbol: key, readable: key }
|
||||
}
|
||||
}
|
||||
|
||||
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
|
||||
return keys.map(key => getShortcutKey(key))
|
||||
}
|
||||
|
||||
export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) {
|
||||
if (format === 'json') {
|
||||
return editor.getJSON()
|
||||
}
|
||||
|
||||
if (format === 'html') {
|
||||
return editor.getText() ? editor.getHTML() : ''
|
||||
}
|
||||
|
||||
return editor.getText()
|
||||
if (format === "html") {
|
||||
return editor.getText() ? editor.getHTML() : ""
|
||||
}
|
||||
|
||||
return editor.getText()
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { LinkBottomBar } from "./bottom-bar"
|
||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||
import { useKey } from "react-use"
|
||||
|
||||
export const isDeleteConfirmShownAtom = atom(false)
|
||||
|
||||
export function LinkRoute(): React.ReactElement {
|
||||
const [nuqsEditId] = useQueryState("editId")
|
||||
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
||||
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
|
||||
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||
@@ -50,8 +51,13 @@ export function LinkRoute(): React.ReactElement {
|
||||
}
|
||||
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
|
||||
|
||||
useKey("Escape", () => {
|
||||
setDisableEnterKey(false)
|
||||
setNuqsEditId(null)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<>
|
||||
<LinkHeader />
|
||||
<LinkManage />
|
||||
<LinkList
|
||||
@@ -61,6 +67,6 @@ export function LinkRoute(): React.ReactElement {
|
||||
disableEnterKey={disableEnterKey}
|
||||
/>
|
||||
<LinkBottomBar />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useEffect, useRef } 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, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils"
|
||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAtom } from "jotai"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
@@ -13,6 +15,7 @@ import { PersonalLink } from "@/lib/schema"
|
||||
import { ID } from "jazz-tools"
|
||||
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
|
||||
import { useLinkActions } from "./hooks/use-link-actions"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
|
||||
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||
icon: keyof typeof icons
|
||||
@@ -21,9 +24,9 @@ interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Butto
|
||||
}
|
||||
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||
({ icon, onClick, tooltip, ...props }, ref) => {
|
||||
({ icon, onClick, tooltip, className, ...props }, ref) => {
|
||||
const button = (
|
||||
<Button variant="ghost" className="h-8 min-w-14" onClick={onClick} ref={ref} {...props}>
|
||||
<Button variant="ghost" className={cn("h-8 min-w-14 p-0", className)} onClick={onClick} ref={ref} {...props}>
|
||||
<LaIcon name={icon} />
|
||||
</Button>
|
||||
)
|
||||
@@ -116,28 +119,25 @@ export const LinkBottomBar: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isCreateShortcut = isMacOS()
|
||||
? event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n"
|
||||
: event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)
|
||||
const handleKeydown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const isCreateShortcut = event.key === "c"
|
||||
|
||||
if (isCreateShortcut) {
|
||||
event.preventDefault()
|
||||
handleCreateMode()
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleCreateMode]
|
||||
)
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handleCreateMode])
|
||||
useKeydownListener(handleKeydown)
|
||||
|
||||
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"
|
||||
className="bg-background absolute bottom-0 left-0 right-0 h-11 border-t"
|
||||
animate={{ y: 0 }}
|
||||
initial={{ y: "100%" }}
|
||||
>
|
||||
@@ -145,7 +145,7 @@ export const LinkBottomBar: React.FC = () => {
|
||||
{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 px-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
@@ -165,7 +165,7 @@ export const LinkBottomBar: React.FC = () => {
|
||||
{!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 }}
|
||||
@@ -176,11 +176,10 @@ export const LinkBottomBar: React.FC = () => {
|
||||
<ToolbarButton
|
||||
icon={"Plus"}
|
||||
onClick={handleCreateMode}
|
||||
tooltip={`New Link (${shortcutText})`}
|
||||
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
|
||||
ref={plusBtnRef}
|
||||
/>
|
||||
)}
|
||||
{/* <ToolbarButton icon={"Ellipsis"} ref={plusMoreBtnRef} /> */}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentHeader className="px-6 max-lg:px-4 lg:py-5">
|
||||
<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">
|
||||
|
||||
@@ -24,6 +24,9 @@ import { commandPaletteOpenAtom } from "@/components/custom/command-palette/comm
|
||||
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 { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
|
||||
interface LinkListProps {
|
||||
activeItemIndex: number | null
|
||||
@@ -77,12 +80,6 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
||||
})
|
||||
)
|
||||
|
||||
useKey("Escape", () => {
|
||||
if (editId) {
|
||||
setEditId(null)
|
||||
}
|
||||
})
|
||||
|
||||
useKey(
|
||||
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
|
||||
async () => {
|
||||
@@ -136,59 +133,52 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isCommandPalettePpen || !me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
|
||||
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(prevIndex => {
|
||||
if (prevIndex === null) return 0
|
||||
const newIndex =
|
||||
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
|
||||
useKeydownListener((e: KeyboardEvent) => {
|
||||
if (
|
||||
isKeyboardDisabled ||
|
||||
isCommandPalettePpen ||
|
||||
!me?.root?.personalLinks ||
|
||||
sortedLinks.length === 0 ||
|
||||
editId !== null
|
||||
)
|
||||
return
|
||||
|
||||
if (e.metaKey && sort === "manual") {
|
||||
const linksArray = [...me.root.personalLinks]
|
||||
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(prevIndex => {
|
||||
if (prevIndex === null) return 0
|
||||
const newIndex =
|
||||
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
|
||||
|
||||
while (me.root.personalLinks.length > 0) {
|
||||
me.root.personalLinks.pop()
|
||||
}
|
||||
if (e.metaKey && sort === "manual") {
|
||||
const linksArray = [...me.root.personalLinks]
|
||||
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
|
||||
|
||||
newLinks.forEach(link => {
|
||||
if (link) {
|
||||
me.root.personalLinks.push(link)
|
||||
}
|
||||
})
|
||||
|
||||
updateSequences(me.root.personalLinks)
|
||||
while (me.root.personalLinks.length > 0) {
|
||||
me.root.personalLinks.pop()
|
||||
}
|
||||
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
|
||||
e.preventDefault()
|
||||
const activeLink = sortedLinks[activeItemIndex]
|
||||
if (activeLink) {
|
||||
setEditId(activeLink.id)
|
||||
newLinks.forEach(link => {
|
||||
if (link) {
|
||||
me.root.personalLinks.push(link)
|
||||
}
|
||||
})
|
||||
|
||||
updateSequences(me.root.personalLinks)
|
||||
}
|
||||
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
|
||||
e.preventDefault()
|
||||
const activeLink = sortedLinks[activeItemIndex]
|
||||
if (activeLink) {
|
||||
setEditId(activeLink.id)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [
|
||||
me?.root?.personalLinks,
|
||||
sortedLinks,
|
||||
editId,
|
||||
sort,
|
||||
updateSequences,
|
||||
isCommandPalettePpen,
|
||||
activeItemIndex,
|
||||
setEditId,
|
||||
setActiveItemIndex,
|
||||
disableEnterKey
|
||||
])
|
||||
})
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
@@ -245,9 +235,11 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
|
||||
|
||||
return (
|
||||
<Primitive.div
|
||||
className="mb-14 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||
tabIndex={0}
|
||||
>
|
||||
<DndContext
|
||||
@@ -271,6 +263,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
||||
isActive={activeItemIndex === index}
|
||||
setActiveItemIndex={setActiveItemIndex}
|
||||
index={index}
|
||||
ref={el => setElementRef(el, index)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -231,7 +231,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
<TopicSelector
|
||||
{...field}
|
||||
renderSelectedText={() => (
|
||||
<span className="truncate">{selectedTopic?.prettyName || "Select a topic"}</span>
|
||||
<span className="truncate">{selectedTopic?.prettyName || "Topic"}</span>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => {
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Take a notes..."
|
||||
placeholder="Notes"
|
||||
className={cn("placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0")}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
|
||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
||||
|
||||
interface LinkItemProps {
|
||||
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
personalLink: PersonalLink
|
||||
disabled?: boolean
|
||||
isEditing: boolean
|
||||
@@ -26,134 +26,138 @@ interface LinkItemProps {
|
||||
index: number
|
||||
}
|
||||
|
||||
export const LinkItem: React.FC<LinkItemProps> = ({
|
||||
isEditing,
|
||||
setEditId,
|
||||
personalLink,
|
||||
disabled = false,
|
||||
isDragging,
|
||||
isActive,
|
||||
setActiveItemIndex,
|
||||
index
|
||||
}) => {
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
|
||||
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, 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 = useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
pointerEvents: isDragging ? "none" : "auto"
|
||||
}),
|
||||
[transform, transition, isDragging]
|
||||
)
|
||||
|
||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
||||
|
||||
const selectedLearningState = useMemo(
|
||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState]
|
||||
)
|
||||
const selectedLearningState = useMemo(
|
||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState]
|
||||
)
|
||||
|
||||
const handleLearningStateSelect = useCallback(
|
||||
(value: string) => {
|
||||
const learningState = value as LearningStateValue
|
||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||
setOpenPopoverForId(null)
|
||||
},
|
||||
[personalLink, setOpenPopoverForId]
|
||||
)
|
||||
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={() => {}} />
|
||||
}
|
||||
if (isEditing) {
|
||||
return (
|
||||
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={style as React.CSSProperties}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onFocus={() => setActiveItemIndex(index)}
|
||||
onBlur={() => setActiveItemIndex(null)}
|
||||
className={cn(
|
||||
"relative cursor-default outline-none",
|
||||
"mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2",
|
||||
{
|
||||
"bg-muted-foreground/5": isActive,
|
||||
"hover:bg-muted/50": !isActive
|
||||
}
|
||||
)}
|
||||
onDoubleClick={handleRowDoubleClick}
|
||||
>
|
||||
<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">
|
||||
{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="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={personalLink.learningState}
|
||||
onSelect={handleLearningStateSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||
{personalLink.icon && (
|
||||
<Image
|
||||
src={personalLink.icon}
|
||||
alt={personalLink.title}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
return (
|
||||
<li
|
||||
ref={node => {
|
||||
setNodeRef(node)
|
||||
if (typeof ref === "function") {
|
||||
ref(node)
|
||||
} else if (ref) {
|
||||
ref.current = node
|
||||
}
|
||||
}}
|
||||
style={style as React.CSSProperties}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={0}
|
||||
onFocus={() => setActiveItemIndex(index)}
|
||||
onBlur={() => setActiveItemIndex(null)}
|
||||
className={cn(
|
||||
"relative cursor-default outline-none",
|
||||
"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
|
||||
{
|
||||
"bg-muted-foreground/5": isActive,
|
||||
"hover:bg-muted/50": !isActive
|
||||
}
|
||||
)}
|
||||
onDoubleClick={handleRowDoubleClick}
|
||||
>
|
||||
<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">
|
||||
{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="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={personalLink.learningState}
|
||||
onSelect={handleLearningStateSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
|
||||
{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 truncate text-xs"
|
||||
>
|
||||
{personalLink.url}
|
||||
</Link>
|
||||
</div>
|
||||
{personalLink.icon && (
|
||||
<Image
|
||||
src={personalLink.icon}
|
||||
alt={personalLink.title}
|
||||
className="size-5 shrink-0 rounded-full"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
|
||||
{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 truncate text-xs"
|
||||
>
|
||||
{personalLink.url}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
{personalLink.topic && (
|
||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
||||
{personalLink.topic.prettyName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
LinkItem.displayName = "LinkItem"
|
||||
|
||||
@@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions"
|
||||
|
||||
const TITLE_PLACEHOLDER = "Untitled"
|
||||
|
||||
const emptyPage = (page: PersonalPage): boolean => {
|
||||
const isPageEmpty = (page: PersonalPage): boolean => {
|
||||
return (!page.title || page.title.trim() === "") && (!page.content || Object.keys(page.content).length === 0)
|
||||
}
|
||||
|
||||
export const DeleteEmptyPage = (currentPageId: string | null) => {
|
||||
const useDeleteEmptyPage = (currentPageId: string | null) => {
|
||||
const router = useRouter()
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
@@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => {
|
||||
return () => {
|
||||
if (!currentPageId || !me?.root?.personalPages) return
|
||||
|
||||
const currentPage = me.root.personalPages.find(page => page?.id === currentPageId)
|
||||
if (currentPage && emptyPage(currentPage)) {
|
||||
if (currentPage && isPageEmpty(currentPage)) {
|
||||
const index = me.root.personalPages.findIndex(page => page?.id === currentPageId)
|
||||
if (index !== -1) {
|
||||
me.root.personalPages.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
handleRouteChange()
|
||||
}
|
||||
}, [currentPageId, me, router])
|
||||
}
|
||||
|
||||
@@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||
const { deletePage } = usePageActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
DeleteEmptyPage(pageId)
|
||||
// useDeleteEmptyPage(pageId)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = useCallback(async () => {
|
||||
const result = await confirm({
|
||||
title: "Delete page",
|
||||
description: "Are you sure you want to delete this page?",
|
||||
@@ -78,7 +74,7 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
|
||||
deletePage(me, pageId as ID<PersonalPage>)
|
||||
router.push("/pages")
|
||||
}
|
||||
}
|
||||
}, [confirm, deletePage, me, pageId, router])
|
||||
|
||||
if (!page) return null
|
||||
|
||||
@@ -130,30 +126,34 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
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 newTitle = editor.getText()
|
||||
if (newTitle !== page.title) {
|
||||
const slug = generateUniqueSlug(page.title?.toString() || "")
|
||||
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
|
||||
@@ -201,7 +201,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
|
||||
const titleEditor = useEditor({
|
||||
immediatelyRender: false,
|
||||
autofocus: false,
|
||||
extensions: [
|
||||
FocusClasses,
|
||||
Paragraph,
|
||||
@@ -246,12 +245,15 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||
isTitleInitialMount.current = true
|
||||
isContentInitialMount.current = true
|
||||
|
||||
if (!page.title) {
|
||||
titleEditor?.commands.focus()
|
||||
} else {
|
||||
contentEditorRef.current?.editor?.commands.focus()
|
||||
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, contentEditorRef])
|
||||
}, [page.title, titleEditor])
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||
|
||||
@@ -1,54 +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 { useRouter } from "next/navigation"
|
||||
import { PersonalPage } from "@/lib/schema"
|
||||
import { toast } from "sonner"
|
||||
import { usePageActions } from "./hooks/use-page-actions"
|
||||
|
||||
export const PageHeader = React.memo(() => {
|
||||
interface PageHeaderProps {}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
|
||||
const { me } = useAccount()
|
||||
const router = useRouter()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
const handleClick = () => {
|
||||
try {
|
||||
const newPersonalPage = PersonalPage.create(
|
||||
{ public: false, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ owner: me._owner }
|
||||
)
|
||||
me.root?.personalPages?.push(newPersonalPage)
|
||||
router.push(`/pages/${newPersonalPage.id}`)
|
||||
} catch (error) {
|
||||
toast.error("Failed to create page")
|
||||
}
|
||||
const handleNewPageClick = () => {
|
||||
const page = newPage(me)
|
||||
router.push(`/pages/${page.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-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 font-bold lg:text-xl">Pages</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
|
||||
<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={handleClick}>
|
||||
<LaIcon name="Plus" />
|
||||
<span className="hidden md:block">New page</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { useEffect, useRef, useCallback } from "react"
|
||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
|
||||
|
||||
interface UseKeyboardNavigationProps {
|
||||
personalPages?: PersonalPageLists | null
|
||||
activeItemIndex: number | null
|
||||
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
|
||||
isCommandPaletteOpen: boolean
|
||||
disableEnterKey: boolean
|
||||
onEnter?: (selectedPage: PersonalPage) => void
|
||||
}
|
||||
|
||||
export const useKeyboardNavigation = ({
|
||||
personalPages,
|
||||
activeItemIndex,
|
||||
setActiveItemIndex,
|
||||
isCommandPaletteOpen,
|
||||
disableEnterKey,
|
||||
onEnter
|
||||
}: UseKeyboardNavigationProps) => {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLAnchorElement | null)[]>([])
|
||||
const itemCount = personalPages?.length || 0
|
||||
|
||||
const scrollIntoView = useCallback((index: number) => {
|
||||
if (itemRefs.current[index]) {
|
||||
itemRefs.current[index]?.scrollIntoView({
|
||||
block: "nearest"
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeItemIndex !== null) {
|
||||
scrollIntoView(activeItemIndex)
|
||||
}
|
||||
}, [activeItemIndex, scrollIntoView])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (isCommandPaletteOpen) return
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(prevIndex => {
|
||||
if (prevIndex === null) return 0
|
||||
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) {
|
||||
e.preventDefault()
|
||||
const selectedPage = personalPages[activeItemIndex]
|
||||
if (selectedPage) onEnter?.(selectedPage)
|
||||
}
|
||||
},
|
||||
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, onEnter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
const setItemRef = useCallback((el: HTMLAnchorElement | null, index: number) => {
|
||||
itemRefs.current[index] = el
|
||||
}, [])
|
||||
|
||||
return { listRef, setItemRef }
|
||||
}
|
||||
@@ -4,6 +4,15 @@ 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
|
||||
|
||||
@@ -32,5 +41,5 @@ export const usePageActions = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { deletePage }
|
||||
return { newPage, deletePage }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useMemo, useCallback } from "react"
|
||||
import React, { useMemo, useCallback, useEffect } from "react"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { useAtom } from "jotai"
|
||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||
import { PageItem } from "./partials/page-item"
|
||||
import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation"
|
||||
import { useMedia } from "react-use"
|
||||
import { Column } from "./partials/column"
|
||||
import { useColumnStyles } from "./hooks/use-column-styles"
|
||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { Column } from "@/components/custom/column"
|
||||
|
||||
interface PageListProps {
|
||||
activeItemIndex: number | null
|
||||
@@ -23,6 +23,7 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
|
||||
const { me } = useAccount({ root: { personalPages: [] } })
|
||||
const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
|
||||
const router = useRouter()
|
||||
const itemCount = personalPages?.length || 0
|
||||
|
||||
const handleEnter = useCallback(
|
||||
(selectedPage: PersonalPage) => {
|
||||
@@ -31,24 +32,35 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
|
||||
[router]
|
||||
)
|
||||
|
||||
const { listRef, setItemRef } = useKeyboardNavigation({
|
||||
personalPages,
|
||||
activeItemIndex,
|
||||
setActiveItemIndex,
|
||||
isCommandPaletteOpen,
|
||||
disableEnterKey,
|
||||
onEnter: handleEnter
|
||||
})
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (isCommandPaletteOpen) return
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(prevIndex => {
|
||||
if (prevIndex === null) return 0
|
||||
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) {
|
||||
e.preventDefault()
|
||||
const selectedPage = personalPages[activeItemIndex]
|
||||
if (selectedPage) handleEnter?.(selectedPage)
|
||||
}
|
||||
},
|
||||
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, handleEnter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<PageListItems
|
||||
listRef={listRef}
|
||||
setItemRef={setItemRef}
|
||||
personalPages={personalPages}
|
||||
activeItemIndex={activeItemIndex}
|
||||
/>
|
||||
<PageListItems personalPages={personalPages} activeItemIndex={activeItemIndex} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => {
|
||||
}
|
||||
|
||||
interface PageListItemsProps {
|
||||
listRef: React.RefObject<HTMLDivElement>
|
||||
setItemRef: (el: HTMLAnchorElement | null, index: number) => void
|
||||
personalPages?: PersonalPageLists | null
|
||||
activeItemIndex: number | null
|
||||
}
|
||||
|
||||
const PageListItems: React.FC<PageListItemsProps> = ({ listRef, setItemRef, personalPages, activeItemIndex }) => (
|
||||
<Primitive.div
|
||||
ref={listRef}
|
||||
className="divide-primary/5 mx-auto my-2 flex w-[99%] 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: HTMLAnchorElement | null) => setItemRef(el, index)}
|
||||
page={page}
|
||||
isActive={index === activeItemIndex}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Primitive.div>
|
||||
)
|
||||
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
|
||||
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Primitive.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PersonalPage } from "@/lib/schema"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Column } from "./column"
|
||||
import { useMedia } from "react-use"
|
||||
import { useColumnStyles } from "../hooks/use-column-styles"
|
||||
import { format } from "date-fns"
|
||||
import { Column } from "@/components/custom/column"
|
||||
|
||||
interface PageItemProps {
|
||||
page: PersonalPage
|
||||
@@ -21,14 +21,10 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
|
||||
<Link
|
||||
ref={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className={cn(
|
||||
"relative block cursor-default rounded-lg outline-none",
|
||||
"h-12 items-center gap-x-2 py-2 max-lg:px-4 sm:px-6",
|
||||
{
|
||||
"bg-muted-foreground/10": isActive,
|
||||
"hover:bg-muted/50": !isActive
|
||||
}
|
||||
)}
|
||||
className={cn("relative block cursor-default outline-none", "min-h-12 py-2 max-lg:px-4 sm:px-6", {
|
||||
"bg-muted-foreground/5": isActive,
|
||||
"hover:bg-muted/50": !isActive
|
||||
})}
|
||||
href={`/pages/${page.id}`}
|
||||
role="listitem"
|
||||
>
|
||||
@@ -38,14 +34,9 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
|
||||
</Column.Wrapper>
|
||||
|
||||
{!isTablet && (
|
||||
<>
|
||||
{/* <Column.Wrapper style={columnStyles.content}>
|
||||
<Column.Text className="text-[13px]">{page.slug}</Column.Text>
|
||||
</Column.Wrapper> */}
|
||||
<Column.Wrapper style={columnStyles.topic}>
|
||||
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
|
||||
</Column.Wrapper>
|
||||
</>
|
||||
<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">
|
||||
|
||||
35
web/components/routes/topics/TopicRoute.tsx
Normal file
35
web/components/routes/topics/TopicRoute.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { TopicHeader } from "./header"
|
||||
import { TopicList } from "./list"
|
||||
import { useAtom } from "jotai"
|
||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||
|
||||
export function TopicRoute() {
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
||||
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||
const [disableEnterKey, setDisableEnterKey] = useState(false)
|
||||
|
||||
const handleCommandPaletteClose = useCallback(() => {
|
||||
setDisableEnterKey(true)
|
||||
setTimeout(() => setDisableEnterKey(false), 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCommandPaletteOpen) {
|
||||
handleCommandPaletteClose()
|
||||
}
|
||||
}, [isCommandPaletteOpen, handleCommandPaletteClose])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||
<TopicHeader />
|
||||
<TopicList
|
||||
activeItemIndex={activeItemIndex}
|
||||
setActiveItemIndex={setActiveItemIndex}
|
||||
disableEnterKey={disableEnterKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo, useRef } from "react"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { TopicDetailHeader } from "./Header"
|
||||
import { TopicSections } from "./partials/topic-sections"
|
||||
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 { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useTopicData } from "@/hooks/use-topic-data"
|
||||
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
|
||||
}
|
||||
@@ -14,27 +19,71 @@ interface TopicDetailRouteProps {
|
||||
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||
|
||||
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
const { topic } = useTopicData(topicName, me)
|
||||
// const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks)
|
||||
const linksRefDummy = useRef<(HTMLLIElement | null)[]>([])
|
||||
const containerRefDummy = useRef<HTMLDivElement>(null)
|
||||
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||
|
||||
if (!topic || !me) {
|
||||
return null
|
||||
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 [activeIndex, setActiveIndex] = useState(-1)
|
||||
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
93
web/components/routes/topics/detail/list.tsx
Normal file
93
web/components/routes/topics/detail/list.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useRef, useCallback } from "react"
|
||||
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
|
||||
import { Link as LinkSchema, Section as SectionSchema, Topic } from "@/lib/schema"
|
||||
import { LinkItem } from "./partials/link-item"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
|
||||
export type FlattenedItem = { type: "link"; data: LinkSchema | null } | { type: "section"; data: SectionSchema | null }
|
||||
|
||||
interface TopicDetailListProps {
|
||||
items: FlattenedItem[]
|
||||
topic: Topic
|
||||
activeIndex: number
|
||||
setActiveIndex: (index: number) => void
|
||||
}
|
||||
|
||||
export function TopicDetailList({ items, topic, activeIndex, setActiveIndex }: TopicDetailListProps) {
|
||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
const personalLinks = !me || me._type === "Anonymous" ? undefined : me.root.personalLinks
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 44,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
const renderItem = useCallback(
|
||||
(virtualRow: VirtualItem) => {
|
||||
const item = items[virtualRow.index]
|
||||
|
||||
if (item.type === "section") {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
|
||||
<p className="text-foreground text-sm font-medium">{item.data?.title}</p>
|
||||
<div className="flex-1 border-b" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.data?.id) {
|
||||
return (
|
||||
<LinkItem
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
topic={topic}
|
||||
link={item.data as LinkSchema}
|
||||
isActive={activeIndex === virtualRow.index}
|
||||
index={virtualRow.index}
|
||||
setActiveIndex={setActiveIndex}
|
||||
personalLinks={personalLinks}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks]
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="flex-1 overflow-auto">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,198 +10,199 @@ import { Button } from "@/components/ui/button"
|
||||
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||
|
||||
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
|
||||
import { Link as LinkSchema, PersonalLink, Topic } 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
|
||||
personalLinks?: PersonalLinkLists
|
||||
}
|
||||
|
||||
export const LinkItem = React.memo(
|
||||
React.forwardRef<HTMLLIElement, LinkItemProps>(({ topic, link, isActive, index, setActiveIndex }, ref) => {
|
||||
const clerk = useClerk()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
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 { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||
const personalLink = useMemo(() => {
|
||||
return personalLinks?.find(pl => pl?.link?.id === link.id)
|
||||
}, [personalLinks, link.id])
|
||||
|
||||
const personalLinks = useMemo(() => {
|
||||
if (!me || me._type === "Anonymous") return undefined
|
||||
return me?.root?.personalLinks || []
|
||||
}, [me])
|
||||
const selectedLearningState = useMemo(() => {
|
||||
return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
|
||||
}, [personalLink?.learningState])
|
||||
|
||||
const personalLink = useMemo(() => {
|
||||
return personalLinks?.find(pl => pl?.link?.id === link.id)
|
||||
}, [personalLinks, link.id])
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setActiveIndex(index)
|
||||
},
|
||||
[index, setActiveIndex]
|
||||
)
|
||||
|
||||
const selectedLearningState = useMemo(() => {
|
||||
return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
|
||||
}, [personalLink?.learningState])
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setActiveIndex(index)
|
||||
},
|
||||
[index, setActiveIndex]
|
||||
)
|
||||
|
||||
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("/links")
|
||||
const handleSelectLearningState = useCallback(
|
||||
(learningState: LearningStateValue) => {
|
||||
if (!personalLinks || !me || me?._type === "Anonymous") {
|
||||
return clerk.redirectToSignIn({
|
||||
redirectUrl: pathname
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (personalLink) {
|
||||
if (personalLink.learningState === learningState) {
|
||||
personalLink.learningState = undefined
|
||||
toast.error("Link learning state removed", defaultToast)
|
||||
const defaultToast = {
|
||||
duration: 5000,
|
||||
position: "bottom-right" as const,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: "Go to list",
|
||||
onClick: () => router.push("/links")
|
||||
}
|
||||
}
|
||||
|
||||
if (personalLink) {
|
||||
if (personalLink.learningState === learningState) {
|
||||
personalLink.learningState = undefined
|
||||
toast.error("Link learning state removed", defaultToast)
|
||||
} else {
|
||||
personalLink.learningState = learningState
|
||||
toast.success("Link learning state updated", defaultToast)
|
||||
}
|
||||
} else {
|
||||
personalLink.learningState = learningState
|
||||
toast.success("Link learning state updated", defaultToast)
|
||||
const slug = generateUniqueSlug(link.title)
|
||||
const newPersonalLink = PersonalLink.create(
|
||||
{
|
||||
url: link.url,
|
||||
title: link.title,
|
||||
slug,
|
||||
link,
|
||||
learningState,
|
||||
sequence: personalLinks.length + 1,
|
||||
completed: false,
|
||||
topic,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{ owner: me }
|
||||
)
|
||||
|
||||
personalLinks.push(newPersonalLink)
|
||||
|
||||
toast.success("Link added.", {
|
||||
...defaultToast,
|
||||
description: `${link.title} has been added to your personal link.`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const slug = generateUniqueSlug(link.title)
|
||||
const newPersonalLink = PersonalLink.create(
|
||||
|
||||
setOpenPopoverForId(null)
|
||||
setIsPopoverOpen(false)
|
||||
},
|
||||
[personalLink, personalLinks, me, link, router, topic, setOpenPopoverForId, clerk, pathname]
|
||||
)
|
||||
|
||||
const handlePopoverOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsPopoverOpen(open)
|
||||
setOpenPopoverForId(open ? link.id : null)
|
||||
},
|
||||
[link.id, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"relative flex h-14 cursor-pointer items-center outline-none xl:h-11",
|
||||
{
|
||||
url: link.url,
|
||||
title: link.title,
|
||||
slug,
|
||||
link,
|
||||
learningState,
|
||||
sequence: personalLinks.length + 1,
|
||||
completed: false,
|
||||
topic,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
"bg-muted-foreground/10": isActive,
|
||||
"hover:bg-muted/50": !isActive
|
||||
},
|
||||
{ owner: me }
|
||||
)
|
||||
|
||||
personalLinks.push(newPersonalLink)
|
||||
|
||||
toast.success("Link added.", {
|
||||
...defaultToast,
|
||||
description: `${link.title} has been added to your personal link.`
|
||||
})
|
||||
}
|
||||
|
||||
setOpenPopoverForId(null)
|
||||
setIsPopoverOpen(false)
|
||||
},
|
||||
[personalLink, personalLinks, me, link, router, setOpenPopoverForId, topic, clerk, pathname]
|
||||
)
|
||||
|
||||
const handlePopoverOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsPopoverOpen(open)
|
||||
setOpenPopoverForId(open ? link.id : null)
|
||||
},
|
||||
[link.id, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
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
|
||||
})}
|
||||
>
|
||||
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
||||
<div className="flex min-w-0 items-center gap-x-4">
|
||||
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon name={selectedLearningState.icon} className={selectedLearningState.className} />
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={personalLink?.learningState}
|
||||
onSelect={(value: string) => handleSelectLearningState(value as LearningStateValue)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="w-full min-w-0 flex-auto">
|
||||
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
|
||||
<p
|
||||
className={cn(
|
||||
"text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate",
|
||||
isActive && "font-bold"
|
||||
)}
|
||||
>
|
||||
{link.title}
|
||||
</p>
|
||||
|
||||
<div className="group flex items-center gap-x-1">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary flex-none"
|
||||
/>
|
||||
|
||||
<Link
|
||||
href={ensureUrlProtocol(link.url)}
|
||||
passHref
|
||||
prefetch={false}
|
||||
target="_blank"
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
||||
<div className="flex min-w-0 items-center gap-x-4">
|
||||
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
role="combobox"
|
||||
variant="secondary"
|
||||
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="xl:truncate">{link.url}</span>
|
||||
</Link>
|
||||
{selectedLearningState?.icon ? (
|
||||
<LaIcon name={selectedLearningState.icon} className={selectedLearningState.className} />
|
||||
) : (
|
||||
<LaIcon name="Circle" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={personalLink?.learningState}
|
||||
onSelect={(value: string) => handleSelectLearningState(value as LearningStateValue)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="w-full min-w-0 flex-auto">
|
||||
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
|
||||
<p
|
||||
className={cn(
|
||||
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
|
||||
isActive && "font-bold"
|
||||
)}
|
||||
>
|
||||
{link.title}
|
||||
</p>
|
||||
|
||||
<div className="group flex items-center gap-x-1">
|
||||
<LaIcon
|
||||
name="Link"
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
|
||||
/>
|
||||
|
||||
<Link
|
||||
href={ensureUrlProtocol(link.url)}
|
||||
passHref
|
||||
prefetch={false}
|
||||
target="_blank"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary text-xs"
|
||||
>
|
||||
<span className="line-clamp-1">{link.url}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-x-4"></div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
LinkItem.displayName = "LinkItem"
|
||||
|
||||
@@ -1,94 +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
|
||||
}
|
||||
|
||||
export function Section({ topic, section, activeIndex, setActiveIndex, startIndex, linkRefs }: 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
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton key={index} className="h-14 w-full xl:h-11" />
|
||||
)
|
||||
)}
|
||||
{section.links?.length && section.links?.length > nLinksToLoad && (
|
||||
<LoadMoreSpinner onLoadMore={() => setNLinksToLoad(n => n + 10)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => {
|
||||
const spinnerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleIntersection = useCallback(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
const [entry] = entries
|
||||
if (entry.isIntersecting) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
[onLoadMore]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
threshold: 1.0
|
||||
})
|
||||
|
||||
const currentSpinnerRef = spinnerRef.current
|
||||
|
||||
if (currentSpinnerRef) {
|
||||
observer.observe(currentSpinnerRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentSpinnerRef) {
|
||||
observer.unobserve(currentSpinnerRef)
|
||||
}
|
||||
}
|
||||
}, [handleIntersection])
|
||||
|
||||
return (
|
||||
<div ref={spinnerRef} className="flex justify-center py-4">
|
||||
<LaIcon name="Loader" className="size-6 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +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>
|
||||
}
|
||||
|
||||
export function TopicSections({
|
||||
topic,
|
||||
sections,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
linkRefs,
|
||||
containerRef,
|
||||
}: 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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +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) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveIndex(prevIndex => {
|
||||
const newIndex = (prevIndex + 1) % allLinks.length
|
||||
scrollToLink(newIndex)
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setActiveIndex(prevIndex => {
|
||||
const newIndex = (prevIndex - 1 + allLinks.length) % allLinks.length
|
||||
scrollToLink(newIndex)
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "Enter" && activeIndex !== -1) {
|
||||
const link = allLinks[activeIndex]
|
||||
if (link) {
|
||||
window.open(ensureUrlProtocol(link.url), "_blank")
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeIndex, allLinks, scrollToLink]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
return { activeIndex, setActiveIndex, containerRef, linkRefs }
|
||||
}
|
||||
31
web/components/routes/topics/header.tsx
Normal file
31
web/components/routes/topics/header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
|
||||
interface TopicHeaderProps {}
|
||||
|
||||
export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
|
||||
const { me } = useAccount()
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||
<HeaderTitle />
|
||||
<div className="flex flex-auto" />
|
||||
</ContentHeader>
|
||||
)
|
||||
})
|
||||
|
||||
TopicHeader.displayName = "TopicHeader"
|
||||
|
||||
const HeaderTitle: React.FC = () => (
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">Topics</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
14
web/components/routes/topics/hooks/use-column-styles.ts
Normal file
14
web/components/routes/topics/hooks/use-column-styles.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMedia } from "react-use"
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
157
web/components/routes/topics/list.tsx
Normal file
157
web/components/routes/topics/list.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useCallback, useEffect, useMemo } from "react"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||
import { TopicItem } from "./partials/topic-item"
|
||||
import { useMedia } from "react-use"
|
||||
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"
|
||||
|
||||
interface TopicListProps {
|
||||
activeItemIndex: number | null
|
||||
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
|
||||
disableEnterKey: boolean
|
||||
}
|
||||
|
||||
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> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => {
|
||||
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
|
||||
|
||||
if (!me) return null
|
||||
|
||||
return (
|
||||
<MainTopicList
|
||||
me={me}
|
||||
activeItemIndex={activeItemIndex}
|
||||
setActiveItemIndex={setActiveItemIndex}
|
||||
disableEnterKey={disableEnterKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const MainTopicList: React.FC<MainTopicListProps> = ({
|
||||
me,
|
||||
activeItemIndex,
|
||||
setActiveItemIndex,
|
||||
disableEnterKey
|
||||
}) => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||
const router = useRouter()
|
||||
|
||||
const personalTopics = 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 itemCount = personalTopics.length
|
||||
|
||||
const handleEnter = useCallback(
|
||||
(selectedTopic: Topic) => {
|
||||
router.push(`/${selectedTopic.name}`)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (isCommandPaletteOpen) return
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(prevIndex => {
|
||||
if (prevIndex === null) return 0
|
||||
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalTopics) {
|
||||
e.preventDefault()
|
||||
const selectedTopic = personalTopics[activeItemIndex]
|
||||
if (selectedTopic?.topic) handleEnter?.(selectedTopic.topic)
|
||||
}
|
||||
},
|
||||
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalTopics, handleEnter]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-t">
|
||||
{!isTablet && <ColumnHeader />}
|
||||
<TopicListItems personalTopics={personalTopics} activeItemIndex={activeItemIndex} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
interface TopicListItemsProps {
|
||||
personalTopics: PersonalTopic[] | null
|
||||
activeItemIndex: number | null
|
||||
}
|
||||
|
||||
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
|
||||
const setElementRef = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Primitive.div>
|
||||
)
|
||||
}
|
||||
158
web/components/routes/topics/partials/topic-item.tsx
Normal file
158
web/components/routes/topics/partials/topic-item.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
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"
|
||||
|
||||
interface TopicItemProps {
|
||||
topic: Topic
|
||||
learningState: LearningStateValue
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const TopicItem = React.forwardRef<HTMLDivElement, TopicItemProps>(({ topic, learningState, isActive }, ref) => {
|
||||
const columnStyles = useColumnStyles()
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(topicOpenPopoverForIdAtom)
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("relative block", "min-h-12 py-2 max-lg:px-5 sm:px-6", {
|
||||
"bg-muted-foreground/5": isActive,
|
||||
"hover:bg-muted/50": !isActive
|
||||
})}
|
||||
role="listitem"
|
||||
>
|
||||
<Link
|
||||
href={`/${topic.name}`}
|
||||
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()}
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
value={learningState}
|
||||
onSelect={handleLearningStateSelect}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Column.Wrapper>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
TopicItem.displayName = "TopicItem"
|
||||
109
web/components/ui/sheet.tsx
Normal file
109
web/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
export const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
)
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title ref={ref} className={cn("text-foreground text-lg font-semibold", className)} {...props} />
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
30
web/hooks/use-active-item-scroll.ts
Normal file
30
web/hooks/use-active-item-scroll.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef, useCallback } from "react"
|
||||
|
||||
type ElementRef<T extends HTMLElement> = T | null
|
||||
type ElementRefs<T extends HTMLElement> = ElementRef<T>[]
|
||||
|
||||
interface ActiveItemScrollOptions {
|
||||
activeIndex: number | null
|
||||
}
|
||||
|
||||
export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemScrollOptions) {
|
||||
const { activeIndex } = options
|
||||
const elementRefs = useRef<ElementRefs<T>>([])
|
||||
|
||||
const scrollActiveElementIntoView = useCallback((index: number) => {
|
||||
const activeElement = elementRefs.current[index]
|
||||
activeElement?.scrollIntoView({ block: "nearest" })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex !== null) {
|
||||
scrollActiveElementIntoView(activeIndex)
|
||||
}
|
||||
}, [activeIndex, scrollActiveElementIntoView])
|
||||
|
||||
const setElementRef = useCallback((element: ElementRef<T>, index: number) => {
|
||||
elementRefs.current[index] = element
|
||||
}, [])
|
||||
|
||||
return setElementRef
|
||||
}
|
||||
50
web/hooks/use-keyboard-manager.ts
Normal file
50
web/hooks/use-keyboard-manager.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { useEffect, useCallback } from "react"
|
||||
import { keyboardDisableSourcesAtom } from "@/store/keydown-manager"
|
||||
|
||||
const allowedKeys = ["Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"]
|
||||
|
||||
export function useKeyboardManager(sourceId: string) {
|
||||
const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (disableSources.has(sourceId)) {
|
||||
if (allowedKeys.includes(event.key)) {
|
||||
if (event.key === "Escape") {
|
||||
setDisableSources(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(sourceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} else {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true)
|
||||
}, [disableSources, sourceId, setDisableSources])
|
||||
|
||||
const disableKeydown = useCallback(
|
||||
(disable: boolean) => {
|
||||
console.log(`${sourceId} disable:`, disable)
|
||||
setDisableSources(prev => {
|
||||
const next = new Set(prev)
|
||||
if (disable) {
|
||||
next.add(sourceId)
|
||||
} else {
|
||||
next.delete(sourceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setDisableSources, sourceId]
|
||||
)
|
||||
|
||||
const isKeyboardDisabled = disableSources.has(sourceId)
|
||||
|
||||
return { disableKeydown, isKeyboardDisabled }
|
||||
}
|
||||
21
web/hooks/use-keydown-listener.ts
Normal file
21
web/hooks/use-keydown-listener.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { useEffect, useCallback } from "react"
|
||||
import { keyboardDisableSourcesAtom } from "@/store/keydown-manager"
|
||||
|
||||
export function useKeydownListener(callback: (event: KeyboardEvent) => void) {
|
||||
const disableSources = useAtomValue(keyboardDisableSourcesAtom)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (disableSources.size === 0) {
|
||||
callback(event)
|
||||
}
|
||||
},
|
||||
[disableSources, callback]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMemo } from "react"
|
||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
||||
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
|
||||
import { Account, AnonymousJazzAgent, ID } from "jazz-tools"
|
||||
import { Link, Topic } from "@/lib/schema"
|
||||
|
||||
const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>
|
||||
|
||||
export function useTopicData(topicName: string, me: Account | AnonymousJazzAgent | undefined) {
|
||||
const topicID = useMemo(() => me && Topic.findUnique({ topicName }, GLOBAL_GROUP_ID, me), [topicName, me])
|
||||
|
||||
const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [{ links: [] }] } })
|
||||
|
||||
return { topic }
|
||||
}
|
||||
@@ -55,11 +55,7 @@ export function getShortcutKey(key: string): ShortcutKeyResult {
|
||||
} else if (lowercaseKey === "alt") {
|
||||
return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
|
||||
} else if (lowercaseKey === "shift") {
|
||||
return { symbol: "⇧", readable: "Shift" }
|
||||
} else if (lowercaseKey === "control") {
|
||||
return { symbol: "⌃", readable: "Control" }
|
||||
} else if (lowercaseKey === "windows" && !isMacOS()) {
|
||||
return { symbol: "Win", readable: "Windows" }
|
||||
return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" }
|
||||
} else {
|
||||
return { symbol: key.toUpperCase(), readable: key }
|
||||
}
|
||||
@@ -68,21 +64,3 @@ export function getShortcutKey(key: string): ShortcutKeyResult {
|
||||
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
|
||||
return keys.map(key => getShortcutKey(key))
|
||||
}
|
||||
|
||||
export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] {
|
||||
if (shortcutName === "expandToolbar") {
|
||||
return isMacOS()
|
||||
? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")]
|
||||
: [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string {
|
||||
return shortcutKeys.map(key => key.symbol).join("")
|
||||
}
|
||||
|
||||
export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string {
|
||||
return shortcutKeys.map(key => key.readable).join(" + ")
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^5.4.1",
|
||||
"@clerk/nextjs": "^5.6.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@nothing-but/force-graph": "^0.9.4",
|
||||
"@nothing-but/force-graph": "^0.9.5",
|
||||
"@nothing-but/utils": "^0.16.0",
|
||||
"@omit/react-confirm-dialog": "^1.1.5",
|
||||
"@omit/react-fancy-switch": "^0.1.3",
|
||||
@@ -37,45 +37,45 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@sentry/nextjs": "^8.30.0",
|
||||
"@tanstack/react-virtual": "^3.10.7",
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/extension-blockquote": "^2.6.6",
|
||||
"@tiptap/extension-bold": "^2.6.6",
|
||||
"@tiptap/extension-bullet-list": "^2.6.6",
|
||||
"@tiptap/extension-code": "^2.6.6",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.6.6",
|
||||
"@tiptap/extension-color": "^2.6.6",
|
||||
"@tiptap/extension-document": "^2.6.6",
|
||||
"@tiptap/extension-dropcursor": "^2.6.6",
|
||||
"@tiptap/extension-focus": "^2.6.6",
|
||||
"@tiptap/extension-gapcursor": "^2.6.6",
|
||||
"@tiptap/extension-hard-break": "^2.6.6",
|
||||
"@tiptap/extension-heading": "^2.6.6",
|
||||
"@tiptap/extension-history": "^2.6.6",
|
||||
"@tiptap/extension-horizontal-rule": "^2.6.6",
|
||||
"@tiptap/extension-image": "^2.6.6",
|
||||
"@tiptap/extension-italic": "^2.6.6",
|
||||
"@tiptap/extension-link": "^2.6.6",
|
||||
"@tiptap/extension-list-item": "^2.6.6",
|
||||
"@tiptap/extension-ordered-list": "^2.6.6",
|
||||
"@tiptap/extension-paragraph": "^2.6.6",
|
||||
"@tiptap/extension-placeholder": "^2.6.6",
|
||||
"@tiptap/extension-strike": "^2.6.6",
|
||||
"@tiptap/extension-task-item": "^2.6.6",
|
||||
"@tiptap/extension-task-list": "^2.6.6",
|
||||
"@tiptap/extension-text": "^2.6.6",
|
||||
"@tiptap/extension-typography": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6",
|
||||
"@tiptap/react": "^2.6.6",
|
||||
"@tiptap/starter-kit": "^2.6.6",
|
||||
"@tiptap/suggestion": "^2.6.6",
|
||||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"@tiptap/core": "^2.7.2",
|
||||
"@tiptap/extension-blockquote": "^2.7.2",
|
||||
"@tiptap/extension-bold": "^2.7.2",
|
||||
"@tiptap/extension-bullet-list": "^2.7.2",
|
||||
"@tiptap/extension-code": "^2.7.2",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.7.2",
|
||||
"@tiptap/extension-color": "^2.7.2",
|
||||
"@tiptap/extension-document": "^2.7.2",
|
||||
"@tiptap/extension-dropcursor": "^2.7.2",
|
||||
"@tiptap/extension-focus": "^2.7.2",
|
||||
"@tiptap/extension-gapcursor": "^2.7.2",
|
||||
"@tiptap/extension-hard-break": "^2.7.2",
|
||||
"@tiptap/extension-heading": "^2.7.2",
|
||||
"@tiptap/extension-history": "^2.7.2",
|
||||
"@tiptap/extension-horizontal-rule": "^2.7.2",
|
||||
"@tiptap/extension-image": "^2.7.2",
|
||||
"@tiptap/extension-italic": "^2.7.2",
|
||||
"@tiptap/extension-link": "^2.7.2",
|
||||
"@tiptap/extension-list-item": "^2.7.2",
|
||||
"@tiptap/extension-ordered-list": "^2.7.2",
|
||||
"@tiptap/extension-paragraph": "^2.7.2",
|
||||
"@tiptap/extension-placeholder": "^2.7.2",
|
||||
"@tiptap/extension-strike": "^2.7.2",
|
||||
"@tiptap/extension-task-item": "^2.7.2",
|
||||
"@tiptap/extension-task-list": "^2.7.2",
|
||||
"@tiptap/extension-text": "^2.7.2",
|
||||
"@tiptap/extension-typography": "^2.7.2",
|
||||
"@tiptap/pm": "^2.7.2",
|
||||
"@tiptap/react": "^2.7.2",
|
||||
"@tiptap/starter-kit": "^2.7.2",
|
||||
"@tiptap/suggestion": "^2.7.2",
|
||||
"axios": "^1.7.7",
|
||||
"cheerio": "1.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^11.5.4",
|
||||
"framer-motion": "^11.5.5",
|
||||
"geist": "^1.3.1",
|
||||
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
|
||||
"jazz-react": "0.7.35-guest-auth.5",
|
||||
@@ -84,7 +84,7 @@
|
||||
"jotai": "^2.9.3",
|
||||
"lowlight": "^3.1.0",
|
||||
"lucide-react": "^0.429.0",
|
||||
"next": "14.2.5",
|
||||
"next": "14.2.10",
|
||||
"next-themes": "^0.3.0",
|
||||
"nuqs": "^1.19.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -99,27 +99,27 @@
|
||||
"streaming-markdown": "^0.0.14",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.2",
|
||||
"vaul": "^0.9.4",
|
||||
"zod": "^3.23.8",
|
||||
"zsa": "^0.6.0",
|
||||
"zsa-react": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ronin/learn-anything": "^0.0.0-3451954511456",
|
||||
"@ronin/learn-anything": "^0.0.0-3452357373461",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/react": "^18.3.7",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.45",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"tailwindcss": "^3.4.12",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.2"
|
||||
|
||||
3
web/store/keydown-manager.ts
Normal file
3
web/store/keydown-manager.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai"
|
||||
|
||||
export const keyboardDisableSourcesAtom = atom<Set<string>>(new Set<string>())
|
||||
Reference in New Issue
Block a user