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"
|
"web"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/themes": "^2.1.27",
|
"@clerk/themes": "^2.1.30",
|
||||||
"@tauri-apps/cli": "^2.0.0-rc.12",
|
"@tauri-apps/cli": "^2.0.0-rc.16",
|
||||||
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
||||||
"jazz-nodejs": "0.7.35-guest-auth.5",
|
"jazz-nodejs": "0.7.35-guest-auth.5",
|
||||||
"react-icons": "^5.3.0"
|
"react-icons": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "^1.1.27"
|
"bun-types": "^1.1.28"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
"use client"
|
import type { Viewport } from "next"
|
||||||
|
|
||||||
import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
||||||
import { CommandPalette } from "@/components/custom/command-palette/command-palette"
|
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 }) {
|
export default function PageLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { me } = useAccountOrGuest()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
||||||
<Sidebar />
|
<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">
|
<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}
|
{children}
|
||||||
</main>
|
</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 { cn } from "@/lib/utils"
|
||||||
import { ThemeProvider } from "@/lib/providers/theme-provider"
|
import { ThemeProvider } from "@/lib/providers/theme-provider"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
@@ -10,7 +10,13 @@ import { DeepLinkProvider } from "@/lib/providers/deep-link-provider"
|
|||||||
import { GeistMono, GeistSans } from "./fonts"
|
import { GeistMono, GeistSans } from "./fonts"
|
||||||
import { JazzAndAuth } from "@/lib/providers/jazz-provider"
|
import { JazzAndAuth } from "@/lib/providers/jazz-provider"
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Learn Anything",
|
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)}>
|
<body className={cn("h-full w-full font-sans antialiased", GeistSans.variable, GeistMono.variable)}>
|
||||||
<Providers>
|
<Providers>
|
||||||
{children}
|
{children}
|
||||||
<LearnAnythingOnboarding />
|
|
||||||
<Toaster expand={false} />
|
<Toaster expand={false} />
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</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 type CommandAction = string | (() => void)
|
||||||
|
|
||||||
export interface CommandItemType {
|
export interface CommandItemType {
|
||||||
|
id?: string
|
||||||
icon?: keyof typeof icons
|
icon?: keyof typeof icons
|
||||||
value: string
|
value: string
|
||||||
label: HTMLLikeElement | 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 }) => {
|
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"
|
HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
|
||||||
|
|
||||||
export const CommandItem: React.FC<CommandItemProps> = React.memo(
|
export const CommandItem: React.FC<CommandItemProps> = React.memo(
|
||||||
({ icon, label, action, payload, shortcut, handleAction }) => (
|
({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
|
||||||
<Command.Item onSelect={() => handleAction(action, payload)}>
|
<Command.Item value={`${item.id}-${item.value}`} onSelect={() => handleAction(action, payload)}>
|
||||||
{icon && <LaIcon name={icon} />}
|
{icon && <LaIcon name={icon} />}
|
||||||
<HTMLLikeRenderer content={label} />
|
<HTMLLikeRenderer content={label} />
|
||||||
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import { Command } from "cmdk"
|
import { Command } from "cmdk"
|
||||||
import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
import { CommandGroup } from "./command-items"
|
import { CommandGroup } from "./command-items"
|
||||||
import { CommandAction, CommandItemType, createCommandGroups } from "./command-data"
|
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 { searchSafeRegExp } from "@/lib/utils"
|
||||||
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
|
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
|
||||||
import { useCommandActions } from "./hooks/use-command-actions"
|
import { useCommandActions } from "./hooks/use-command-actions"
|
||||||
import { atom, useAtom } from "jotai"
|
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)
|
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 const commandPaletteOpenAtom = atom(false)
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
|
const { me } = useAccountOrGuest()
|
||||||
|
|
||||||
|
if (me._type === "Anonymous") return null
|
||||||
|
|
||||||
|
return <RealCommandPalette />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RealCommandPalette() {
|
||||||
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
|
||||||
const dialogRef = React.useRef<HTMLDivElement | null>(null)
|
const dialogRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const [inputValue, setInputValue] = React.useState("")
|
const [inputValue, setInputValue] = React.useState("")
|
||||||
@@ -29,17 +40,17 @@ export function CommandPalette() {
|
|||||||
|
|
||||||
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||||
|
|
||||||
React.useEffect(() => {
|
const handleKeydown = React.useCallback(
|
||||||
const down = (e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setOpen(prev => !prev)
|
setOpen(prev => !prev)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[setOpen]
|
||||||
|
)
|
||||||
|
|
||||||
document.addEventListener("keydown", down)
|
useKeydownListener(handleKeydown)
|
||||||
return () => document.removeEventListener("keydown", down)
|
|
||||||
}, [setOpen])
|
|
||||||
|
|
||||||
const bounce = React.useCallback(() => {
|
const bounce = React.useCallback(() => {
|
||||||
if (dialogRef.current) {
|
if (dialogRef.current) {
|
||||||
@@ -86,6 +97,7 @@ export function CommandPalette() {
|
|||||||
heading: "Personal Links",
|
heading: "Personal Links",
|
||||||
items:
|
items:
|
||||||
me?.root.personalLinks?.map(link => ({
|
me?.root.personalLinks?.map(link => ({
|
||||||
|
id: link?.id,
|
||||||
icon: "Link" as const,
|
icon: "Link" as const,
|
||||||
value: link?.title || "Untitled",
|
value: link?.title || "Untitled",
|
||||||
label: link?.title || "Untitled",
|
label: link?.title || "Untitled",
|
||||||
@@ -100,6 +112,7 @@ export function CommandPalette() {
|
|||||||
heading: "Personal Pages",
|
heading: "Personal Pages",
|
||||||
items:
|
items:
|
||||||
me?.root.personalPages?.map(page => ({
|
me?.root.personalPages?.map(page => ({
|
||||||
|
id: page?.id,
|
||||||
icon: "FileText" as const,
|
icon: "FileText" as const,
|
||||||
value: page?.title || "Untitled",
|
value: page?.title || "Untitled",
|
||||||
label: page?.title || "Untitled",
|
label: page?.title || "Untitled",
|
||||||
@@ -116,11 +129,9 @@ export function CommandPalette() {
|
|||||||
|
|
||||||
if (activePage === "home") {
|
if (activePage === "home") {
|
||||||
if (!inputValue) {
|
if (!inputValue) {
|
||||||
// Only show items from the home object when there's no search input
|
|
||||||
return commandGroups.home
|
return commandGroups.home
|
||||||
}
|
}
|
||||||
|
|
||||||
// When there's a search input, search across all categories
|
|
||||||
const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics]
|
const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics]
|
||||||
|
|
||||||
return allGroups
|
return allGroups
|
||||||
@@ -131,7 +142,6 @@ export function CommandPalette() {
|
|||||||
.filter(group => group.items.length > 0)
|
.filter(group => group.items.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle other active pages (searchLinks, searchPages, etc.)
|
|
||||||
switch (activePage) {
|
switch (activePage) {
|
||||||
case "searchLinks":
|
case "searchLinks":
|
||||||
return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }]
|
return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }]
|
||||||
@@ -184,7 +194,7 @@ export function CommandPalette() {
|
|||||||
const commandKey = React.useMemo(() => {
|
const commandKey = React.useMemo(() => {
|
||||||
return filteredCommands
|
return filteredCommands
|
||||||
.map(group => {
|
.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}`
|
return `${group.heading}:${itemsKey}`
|
||||||
})
|
})
|
||||||
.join("__")
|
.join("__")
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { ensureUrlProtocol } from "@/lib/utils"
|
|||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
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 = () => {
|
export const useCommandActions = () => {
|
||||||
const { setTheme } = useTheme()
|
const { setTheme } = useTheme()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { newPage } = usePageActions()
|
||||||
|
|
||||||
const changeTheme = React.useCallback(
|
const changeTheme = React.useCallback(
|
||||||
(theme: string) => {
|
(theme: string) => {
|
||||||
@@ -35,19 +37,10 @@ export const useCommandActions = () => {
|
|||||||
|
|
||||||
const createNewPage = React.useCallback(
|
const createNewPage = React.useCallback(
|
||||||
(me: LaAccount) => {
|
(me: LaAccount) => {
|
||||||
try {
|
const page = newPage(me)
|
||||||
const newPersonalPage = PersonalPage.create(
|
router.push(`/pages/${page.id}`)
|
||||||
{ 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")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[router]
|
[router, newPage]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
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> = ({
|
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||||
showSearch = true,
|
showSearch = true,
|
||||||
defaultLabel = "Select state",
|
defaultLabel = "State",
|
||||||
searchPlaceholder = "Search state...",
|
searchPlaceholder = "Search state...",
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils"
|
|||||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { toast } from "sonner"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { icons } from "lucide-react"
|
import { icons } from "lucide-react"
|
||||||
|
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
|
||||||
|
|
||||||
type SortOption = "title" | "recent"
|
type SortOption = "title" | "recent"
|
||||||
type ShowOption = 5 | 10 | 15 | 20 | 0
|
type ShowOption = 5 | 10 | 15 | 20 | 0
|
||||||
@@ -101,20 +101,13 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActi
|
|||||||
const NewPageButton: React.FC = () => {
|
const NewPageButton: React.FC = () => {
|
||||||
const { me } = useAccount()
|
const { me } = useAccount()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { newPage } = usePageActions()
|
||||||
|
|
||||||
if (!me) return null
|
if (!me) return null
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
try {
|
const page = newPage(me)
|
||||||
const newPersonalPage = PersonalPage.create(
|
router.push(`/pages/${page.id}`)
|
||||||
{ 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { LaIcon } from "@/components/custom/la-icon"
|
"use client"
|
||||||
import { useState } from "react"
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { SignInButton, useAuth, useUser } from "@clerk/nextjs"
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -9,17 +17,25 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarImage } from "@/components/ui/avatar"
|
||||||
import Link from "next/link"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { usePathname } from "next/navigation"
|
import { cn } from "@/lib/utils"
|
||||||
import { Feedback } from "./feedback"
|
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 = () => {
|
export const ProfileSection: React.FC = () => {
|
||||||
const { user, isSignedIn } = useUser()
|
const { user, isSignedIn } = useUser()
|
||||||
const { signOut } = useAuth()
|
const { signOut } = useAuth()
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [, setShowShortcut] = useAtom(showShortcutAtom)
|
||||||
|
|
||||||
|
const { disableKeydown } = useKeyboardManager("profileSection")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
disableKeydown(menuOpen)
|
||||||
|
}, [menuOpen, disableKeydown])
|
||||||
|
|
||||||
if (!isSignedIn) {
|
if (!isSignedIn) {
|
||||||
return (
|
return (
|
||||||
@@ -37,78 +53,104 @@ export const ProfileSection: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
|
<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 h-10 min-w-full items-center">
|
||||||
<div className="flex min-w-0">
|
<ProfileDropdown
|
||||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
user={user}
|
||||||
<DropdownMenuTrigger asChild>
|
menuOpen={menuOpen}
|
||||||
<Button
|
setMenuOpen={setMenuOpen}
|
||||||
variant="ghost"
|
signOut={signOut}
|
||||||
aria-label="Profile"
|
setShowShortcut={setShowShortcut}
|
||||||
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>
|
|
||||||
|
|
||||||
<Feedback />
|
<Feedback />
|
||||||
</div>
|
</div>
|
||||||
</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 * as React from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getShortcutKey } from "../../lib/utils"
|
import { getShortcutKey } from "@/lib/utils"
|
||||||
|
|
||||||
export interface ShortcutKeyWrapperProps extends React.HTMLAttributes<HTMLSpanElement> {
|
export interface ShortcutKeyWrapperProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
ariaLabel: string
|
ariaLabel: string
|
||||||
@@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ class
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{getShortcutKey(shortcut)}
|
{getShortcutKey(shortcut).symbol}
|
||||||
</kbd>
|
</kbd>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
import { Command, MenuListProps } from "./types"
|
import { Command, MenuListProps } from "./types"
|
||||||
import { getShortcutKeys } from "../../lib/utils"
|
import { getShortcutKeys } from "@/lib/utils"
|
||||||
import { Icon } from "../../components/ui/icon"
|
import { Icon } from "../../components/ui/icon"
|
||||||
import { PopoverWrapper } from "../../components/ui/popover-wrapper"
|
import { PopoverWrapper } from "../../components/ui/popover-wrapper"
|
||||||
import { Shortcut } from "../../components/ui/shortcut"
|
import { Shortcut } from "../../components/ui/shortcut"
|
||||||
@@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => {
|
|||||||
<Icon name={command.iconName} />
|
<Icon name={command.iconName} />
|
||||||
<span className="truncate text-sm">{command.label}</span>
|
<span className="truncate text-sm">{command.label}</span>
|
||||||
<div className="flex flex-auto flex-row"></div>
|
<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 => (
|
{command.shortcuts.map(shortcut => (
|
||||||
<Shortcut.Key shortcut={shortcut} key={shortcut} />
|
<Shortcut.Key shortcut={shortcut} key={shortcut} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from "./keyboard"
|
|
||||||
export * from "./platform"
|
|
||||||
export * from "./isCustomNodeSelected"
|
export * from "./isCustomNodeSelected"
|
||||||
export * from "./isTextSelected"
|
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 * as React from "react"
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
import { getShortcutKey } from '../utils'
|
import { getShortcutKey } from "@/lib/utils"
|
||||||
|
|
||||||
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
|
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
keys: string[]
|
keys: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
|
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
|
||||||
const modifiedKeys = keys.map(key => getShortcutKey(key))
|
const modifiedKeys = keys.map(key => getShortcutKey(key))
|
||||||
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ')
|
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span aria-label={ariaLabel} className={cn('inline-flex items-center gap-0.5', className)} {...props} ref={ref}>
|
<span aria-label={ariaLabel} className={cn("inline-flex items-center gap-0.5", className)} {...props} ref={ref}>
|
||||||
{modifiedKeys.map(shortcut => (
|
{modifiedKeys.map(shortcut => (
|
||||||
<kbd
|
<kbd
|
||||||
key={shortcut.symbol}
|
key={shortcut.symbol}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]',
|
"inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]",
|
||||||
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{shortcut.symbol}
|
{shortcut.symbol}
|
||||||
</kbd>
|
</kbd>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
ShortcutKey.displayName = 'ShortcutKey'
|
ShortcutKey.displayName = "ShortcutKey"
|
||||||
|
|||||||
@@ -1,112 +1,112 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import type { Editor } from '@tiptap/react'
|
import type { Editor } from "@tiptap/react"
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
import { CaretDownIcon } from '@radix-ui/react-icons'
|
import { CaretDownIcon } from "@radix-ui/react-icons"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { ToolbarButton } from './toolbar-button'
|
import { ToolbarButton } from "./toolbar-button"
|
||||||
import { ShortcutKey } from './shortcut-key'
|
import { ShortcutKey } from "./shortcut-key"
|
||||||
import { getShortcutKey } from '../utils'
|
import { getShortcutKey } from "@/lib/utils"
|
||||||
import type { FormatAction } from '../types'
|
import type { FormatAction } from "../types"
|
||||||
import type { VariantProps } from 'class-variance-authority'
|
import type { VariantProps } from "class-variance-authority"
|
||||||
import type { toggleVariants } from '@/components/ui/toggle'
|
import type { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
interface ToolbarSectionProps extends VariantProps<typeof toggleVariants> {
|
interface ToolbarSectionProps extends VariantProps<typeof toggleVariants> {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
actions: FormatAction[]
|
actions: FormatAction[]
|
||||||
activeActions?: string[]
|
activeActions?: string[]
|
||||||
mainActionCount?: number
|
mainActionCount?: number
|
||||||
dropdownIcon?: React.ReactNode
|
dropdownIcon?: React.ReactNode
|
||||||
dropdownTooltip?: string
|
dropdownTooltip?: string
|
||||||
dropdownClassName?: string
|
dropdownClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
|
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
|
||||||
editor,
|
editor,
|
||||||
actions,
|
actions,
|
||||||
activeActions = actions.map(action => action.value),
|
activeActions = actions.map(action => action.value),
|
||||||
mainActionCount = 0,
|
mainActionCount = 0,
|
||||||
dropdownIcon,
|
dropdownIcon,
|
||||||
dropdownTooltip = 'More options',
|
dropdownTooltip = "More options",
|
||||||
dropdownClassName = 'w-12',
|
dropdownClassName = "w-12",
|
||||||
size,
|
size,
|
||||||
variant
|
variant
|
||||||
}) => {
|
}) => {
|
||||||
const { mainActions, dropdownActions } = React.useMemo(() => {
|
const { mainActions, dropdownActions } = React.useMemo(() => {
|
||||||
const sortedActions = actions
|
const sortedActions = actions
|
||||||
.filter(action => activeActions.includes(action.value))
|
.filter(action => activeActions.includes(action.value))
|
||||||
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
|
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mainActions: sortedActions.slice(0, mainActionCount),
|
mainActions: sortedActions.slice(0, mainActionCount),
|
||||||
dropdownActions: sortedActions.slice(mainActionCount)
|
dropdownActions: sortedActions.slice(mainActionCount)
|
||||||
}
|
}
|
||||||
}, [actions, activeActions, mainActionCount])
|
}, [actions, activeActions, mainActionCount])
|
||||||
|
|
||||||
const renderToolbarButton = React.useCallback(
|
const renderToolbarButton = React.useCallback(
|
||||||
(action: FormatAction) => (
|
(action: FormatAction) => (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
key={action.label}
|
key={action.label}
|
||||||
onClick={() => action.action(editor)}
|
onClick={() => action.action(editor)}
|
||||||
disabled={!action.canExecute(editor)}
|
disabled={!action.canExecute(editor)}
|
||||||
isActive={action.isActive(editor)}
|
isActive={action.isActive(editor)}
|
||||||
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`}
|
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`}
|
||||||
aria-label={action.label}
|
aria-label={action.label}
|
||||||
size={size}
|
size={size}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
),
|
),
|
||||||
[editor, size, variant]
|
[editor, size, variant]
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderDropdownMenuItem = React.useCallback(
|
const renderDropdownMenuItem = React.useCallback(
|
||||||
(action: FormatAction) => (
|
(action: FormatAction) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={action.label}
|
key={action.label}
|
||||||
onClick={() => action.action(editor)}
|
onClick={() => action.action(editor)}
|
||||||
disabled={!action.canExecute(editor)}
|
disabled={!action.canExecute(editor)}
|
||||||
className={cn('flex flex-row items-center justify-between gap-4', {
|
className={cn("flex flex-row items-center justify-between gap-4", {
|
||||||
'bg-accent': action.isActive(editor)
|
"bg-accent": action.isActive(editor)
|
||||||
})}
|
})}
|
||||||
aria-label={action.label}
|
aria-label={action.label}
|
||||||
>
|
>
|
||||||
<span className="grow">{action.label}</span>
|
<span className="grow">{action.label}</span>
|
||||||
<ShortcutKey keys={action.shortcuts} />
|
<ShortcutKey keys={action.shortcuts} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
),
|
),
|
||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDropdownActive = React.useMemo(
|
const isDropdownActive = React.useMemo(
|
||||||
() => dropdownActions.some(action => action.isActive(editor)),
|
() => dropdownActions.some(action => action.isActive(editor)),
|
||||||
[dropdownActions, editor]
|
[dropdownActions, editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mainActions.map(renderToolbarButton)}
|
{mainActions.map(renderToolbarButton)}
|
||||||
{dropdownActions.length > 0 && (
|
{dropdownActions.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
isActive={isDropdownActive}
|
isActive={isDropdownActive}
|
||||||
tooltip={dropdownTooltip}
|
tooltip={dropdownTooltip}
|
||||||
aria-label={dropdownTooltip}
|
aria-label={dropdownTooltip}
|
||||||
className={cn(dropdownClassName)}
|
className={cn(dropdownClassName)}
|
||||||
size={size}
|
size={size}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
>
|
>
|
||||||
{dropdownIcon || <CaretDownIcon className="size-5" />}
|
{dropdownIcon || <CaretDownIcon className="size-5" />}
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-full">
|
<DropdownMenuContent align="start" className="w-full">
|
||||||
{dropdownActions.map(renderDropdownMenuItem)}
|
{dropdownActions.map(renderDropdownMenuItem)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ToolbarSection
|
export default ToolbarSection
|
||||||
|
|||||||
@@ -1,81 +1,14 @@
|
|||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from "@tiptap/core"
|
||||||
import type { MinimalTiptapProps } from './minimal-tiptap'
|
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 {
|
if (format === "html") {
|
||||||
userAgentData?: {
|
return editor.getText() ? editor.getHTML() : ""
|
||||||
brands: { brand: string; version: string }[]
|
}
|
||||||
mobile: boolean
|
|
||||||
platform: string
|
return editor.getText()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs"
|
|||||||
import { atom, useAtom } from "jotai"
|
import { atom, useAtom } from "jotai"
|
||||||
import { LinkBottomBar } from "./bottom-bar"
|
import { LinkBottomBar } from "./bottom-bar"
|
||||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||||
|
import { useKey } from "react-use"
|
||||||
|
|
||||||
export const isDeleteConfirmShownAtom = atom(false)
|
export const isDeleteConfirmShownAtom = atom(false)
|
||||||
|
|
||||||
export function LinkRoute(): React.ReactElement {
|
export function LinkRoute(): React.ReactElement {
|
||||||
const [nuqsEditId] = useQueryState("editId")
|
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
|
||||||
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
||||||
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
|
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
|
||||||
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||||
@@ -50,8 +51,13 @@ export function LinkRoute(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
|
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
|
||||||
|
|
||||||
|
useKey("Escape", () => {
|
||||||
|
setDisableEnterKey(false)
|
||||||
|
setNuqsEditId(null)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
<>
|
||||||
<LinkHeader />
|
<LinkHeader />
|
||||||
<LinkManage />
|
<LinkManage />
|
||||||
<LinkList
|
<LinkList
|
||||||
@@ -61,6 +67,6 @@ export function LinkRoute(): React.ReactElement {
|
|||||||
disableEnterKey={disableEnterKey}
|
disableEnterKey={disableEnterKey}
|
||||||
/>
|
/>
|
||||||
<LinkBottomBar />
|
<LinkBottomBar />
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef } from "react"
|
import React, { useCallback, useEffect, useRef } from "react"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { icons } from "lucide-react"
|
import type { icons } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
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 { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||||
@@ -13,6 +15,7 @@ import { PersonalLink } from "@/lib/schema"
|
|||||||
import { ID } from "jazz-tools"
|
import { ID } from "jazz-tools"
|
||||||
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
|
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
|
||||||
import { useLinkActions } from "./hooks/use-link-actions"
|
import { useLinkActions } from "./hooks/use-link-actions"
|
||||||
|
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||||
|
|
||||||
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||||
icon: keyof typeof icons
|
icon: keyof typeof icons
|
||||||
@@ -21,9 +24,9 @@ interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Butto
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||||
({ icon, onClick, tooltip, ...props }, ref) => {
|
({ icon, onClick, tooltip, className, ...props }, ref) => {
|
||||||
const button = (
|
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} />
|
<LaIcon name={icon} />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -116,28 +119,25 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const handleKeydown = useCallback(
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
const isCreateShortcut = isMacOS()
|
const isCreateShortcut = event.key === "c"
|
||||||
? event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n"
|
|
||||||
: event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)
|
|
||||||
|
|
||||||
if (isCreateShortcut) {
|
if (isCreateShortcut) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleCreateMode()
|
handleCreateMode()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[handleCreateMode]
|
||||||
|
)
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
useKeydownListener(handleKeydown)
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
||||||
}, [handleCreateMode])
|
|
||||||
|
|
||||||
const shortcutKeys = getSpecialShortcut("expandToolbar")
|
const shortcutText = getShortcutKeys(["c"])
|
||||||
const shortcutText = formatShortcut(shortcutKeys)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<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 }}
|
animate={{ y: 0 }}
|
||||||
initial={{ y: "100%" }}
|
initial={{ y: "100%" }}
|
||||||
>
|
>
|
||||||
@@ -145,7 +145,7 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
{editId && (
|
{editId && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="expanded"
|
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 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 20 }}
|
exit={{ opacity: 0, y: 20 }}
|
||||||
@@ -165,7 +165,7 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
{!editId && (
|
{!editId && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="collapsed"
|
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 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
@@ -176,11 +176,10 @@ export const LinkBottomBar: React.FC = () => {
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={"Plus"}
|
icon={"Plus"}
|
||||||
onClick={handleCreateMode}
|
onClick={handleCreateMode}
|
||||||
tooltip={`New Link (${shortcutText})`}
|
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
|
||||||
ref={plusBtnRef}
|
ref={plusBtnRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* <ToolbarButton icon={"Ellipsis"} ref={plusMoreBtnRef} /> */}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||||
<SidebarToggleButton />
|
<SidebarToggleButton />
|
||||||
<div className="flex min-h-0 items-center">
|
<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 { useConfirm } from "@omit/react-confirm-dialog"
|
||||||
import { useLinkActions } from "./hooks/use-link-actions"
|
import { useLinkActions } from "./hooks/use-link-actions"
|
||||||
import { isDeleteConfirmShownAtom } from "./LinkRoute"
|
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 {
|
interface LinkListProps {
|
||||||
activeItemIndex: number | null
|
activeItemIndex: number | null
|
||||||
@@ -77,12 +80,6 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
useKey("Escape", () => {
|
|
||||||
if (editId) {
|
|
||||||
setEditId(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useKey(
|
useKey(
|
||||||
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
|
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
|
||||||
async () => {
|
async () => {
|
||||||
@@ -136,59 +133,52 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (isCommandPalettePpen || !me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
|
|
||||||
|
|
||||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
useKeydownListener((e: KeyboardEvent) => {
|
||||||
e.preventDefault()
|
if (
|
||||||
setActiveItemIndex(prevIndex => {
|
isKeyboardDisabled ||
|
||||||
if (prevIndex === null) return 0
|
isCommandPalettePpen ||
|
||||||
const newIndex =
|
!me?.root?.personalLinks ||
|
||||||
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
|
sortedLinks.length === 0 ||
|
||||||
|
editId !== null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if (e.metaKey && sort === "manual") {
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||||
const linksArray = [...me.root.personalLinks]
|
e.preventDefault()
|
||||||
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
|
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) {
|
if (e.metaKey && sort === "manual") {
|
||||||
me.root.personalLinks.pop()
|
const linksArray = [...me.root.personalLinks]
|
||||||
}
|
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
|
||||||
|
|
||||||
newLinks.forEach(link => {
|
while (me.root.personalLinks.length > 0) {
|
||||||
if (link) {
|
me.root.personalLinks.pop()
|
||||||
me.root.personalLinks.push(link)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
updateSequences(me.root.personalLinks)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newIndex
|
newLinks.forEach(link => {
|
||||||
})
|
if (link) {
|
||||||
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
|
me.root.personalLinks.push(link)
|
||||||
e.preventDefault()
|
}
|
||||||
const activeLink = sortedLinks[activeItemIndex]
|
})
|
||||||
if (activeLink) {
|
|
||||||
setEditId(activeLink.id)
|
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(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
@@ -245,9 +235,11 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
setDraggingId(null)
|
setDraggingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Primitive.div
|
<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}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -271,6 +263,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
isActive={activeItemIndex === index}
|
isActive={activeItemIndex === index}
|
||||||
setActiveItemIndex={setActiveItemIndex}
|
setActiveItemIndex={setActiveItemIndex}
|
||||||
index={index}
|
index={index}
|
||||||
|
ref={el => setElementRef(el, index)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
|
|||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
{...field}
|
{...field}
|
||||||
autoComplete="off"
|
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"
|
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>
|
</FormControl>
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
<TopicSelector
|
<TopicSelector
|
||||||
{...field}
|
{...field}
|
||||||
renderSelectedText={() => (
|
renderSelectedText={() => (
|
||||||
<span className="truncate">{selectedTopic?.prettyName || "Select a topic"}</span>
|
<span className="truncate">{selectedTopic?.prettyName || "Topic"}</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
autoComplete="off"
|
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")}
|
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 { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||||
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
||||||
|
|
||||||
interface LinkItemProps {
|
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||||
personalLink: PersonalLink
|
personalLink: PersonalLink
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
@@ -26,134 +26,138 @@ interface LinkItemProps {
|
|||||||
index: number
|
index: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkItem: React.FC<LinkItemProps> = ({
|
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
|
||||||
isEditing,
|
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => {
|
||||||
setEditId,
|
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||||
personalLink,
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||||
disabled = false,
|
|
||||||
isDragging,
|
|
||||||
isActive,
|
|
||||||
setActiveItemIndex,
|
|
||||||
index
|
|
||||||
}) => {
|
|
||||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
|
||||||
|
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
pointerEvents: isDragging ? "none" : "auto"
|
pointerEvents: isDragging ? "none" : "auto"
|
||||||
}),
|
}),
|
||||||
[transform, transition, isDragging]
|
[transform, transition, isDragging]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||||
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
||||||
|
|
||||||
const selectedLearningState = useMemo(
|
const selectedLearningState = useMemo(
|
||||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||||
[personalLink.learningState]
|
[personalLink.learningState]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleLearningStateSelect = useCallback(
|
const handleLearningStateSelect = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const learningState = value as LearningStateValue
|
const learningState = value as LearningStateValue
|
||||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||||
setOpenPopoverForId(null)
|
setOpenPopoverForId(null)
|
||||||
},
|
},
|
||||||
[personalLink, setOpenPopoverForId]
|
[personalLink, setOpenPopoverForId]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
return (
|
||||||
}
|
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
ref={setNodeRef}
|
ref={node => {
|
||||||
style={style as React.CSSProperties}
|
setNodeRef(node)
|
||||||
{...attributes}
|
if (typeof ref === "function") {
|
||||||
{...listeners}
|
ref(node)
|
||||||
tabIndex={0}
|
} else if (ref) {
|
||||||
onFocus={() => setActiveItemIndex(index)}
|
ref.current = node
|
||||||
onBlur={() => setActiveItemIndex(null)}
|
}
|
||||||
className={cn(
|
}}
|
||||||
"relative cursor-default outline-none",
|
style={style as React.CSSProperties}
|
||||||
"mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2",
|
{...attributes}
|
||||||
{
|
{...listeners}
|
||||||
"bg-muted-foreground/5": isActive,
|
tabIndex={0}
|
||||||
"hover:bg-muted/50": !isActive
|
onFocus={() => setActiveItemIndex(index)}
|
||||||
}
|
onBlur={() => setActiveItemIndex(null)}
|
||||||
)}
|
className={cn(
|
||||||
onDoubleClick={handleRowDoubleClick}
|
"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",
|
||||||
<Popover
|
{
|
||||||
open={openPopoverForId === personalLink.id}
|
"bg-muted-foreground/5": isActive,
|
||||||
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
|
"hover:bg-muted/50": !isActive
|
||||||
>
|
}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
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">
|
<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.icon && (
|
||||||
{personalLink.url && (
|
<Image
|
||||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
src={personalLink.icon}
|
||||||
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
|
alt={personalLink.title}
|
||||||
<Link
|
className="size-5 shrink-0 rounded-full"
|
||||||
href={ensureUrlProtocol(personalLink.url)}
|
width={16}
|
||||||
passHref
|
height={16}
|
||||||
prefetch={false}
|
/>
|
||||||
target="_blank"
|
)}
|
||||||
onClick={e => e.stopPropagation()}
|
<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">
|
||||||
className="hover:text-primary truncate text-xs"
|
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
|
||||||
>
|
{personalLink.url && (
|
||||||
{personalLink.url}
|
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
||||||
</Link>
|
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-end">
|
LinkItem.displayName = "LinkItem"
|
||||||
{personalLink.topic && (
|
|
||||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
|
||||||
{personalLink.topic.prettyName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions"
|
|||||||
|
|
||||||
const TITLE_PLACEHOLDER = "Untitled"
|
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)
|
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 router = useRouter()
|
||||||
const { me } = useAccount({
|
const { me } = useAccount({
|
||||||
root: {
|
root: {
|
||||||
@@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChange = () => {
|
return () => {
|
||||||
if (!currentPageId || !me?.root?.personalPages) return
|
if (!currentPageId || !me?.root?.personalPages) return
|
||||||
|
|
||||||
const currentPage = me.root.personalPages.find(page => page?.id === currentPageId)
|
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)
|
const index = me.root.personalPages.findIndex(page => page?.id === currentPageId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
me.root.personalPages.splice(index, 1)
|
me.root.personalPages.splice(index, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
|
||||||
handleRouteChange()
|
|
||||||
}
|
|
||||||
}, [currentPageId, me, router])
|
}, [currentPageId, me, router])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
|
|||||||
const { deletePage } = usePageActions()
|
const { deletePage } = usePageActions()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
|
||||||
DeleteEmptyPage(pageId)
|
// useDeleteEmptyPage(pageId)
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
const result = await confirm({
|
const result = await confirm({
|
||||||
title: "Delete page",
|
title: "Delete page",
|
||||||
description: "Are you sure you want to delete this 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>)
|
deletePage(me, pageId as ID<PersonalPage>)
|
||||||
router.push("/pages")
|
router.push("/pages")
|
||||||
}
|
}
|
||||||
}
|
}, [confirm, deletePage, me, pageId, router])
|
||||||
|
|
||||||
if (!page) return null
|
if (!page) return null
|
||||||
|
|
||||||
@@ -130,30 +126,34 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
const contentEditorRef = useRef<LAEditorRef>(null)
|
const contentEditorRef = useRef<LAEditorRef>(null)
|
||||||
const isTitleInitialMount = useRef(true)
|
const isTitleInitialMount = useRef(true)
|
||||||
const isContentInitialMount = 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) {
|
if (isContentInitialMount.current) {
|
||||||
isContentInitialMount.current = false
|
isContentInitialMount.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
model.content = content
|
model.content = content
|
||||||
model.updatedAt = new Date()
|
model.updatedAt = new Date()
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleUpdateTitle = (editor: Editor) => {
|
const handleUpdateTitle = useCallback(
|
||||||
if (isTitleInitialMount.current) {
|
(editor: Editor) => {
|
||||||
isTitleInitialMount.current = false
|
if (isTitleInitialMount.current) {
|
||||||
return
|
isTitleInitialMount.current = false
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const newTitle = editor.getText()
|
const newTitle = editor.getText()
|
||||||
if (newTitle !== page.title) {
|
if (newTitle !== page.title) {
|
||||||
const slug = generateUniqueSlug(page.title?.toString() || "")
|
const slug = generateUniqueSlug(page.title?.toString() || "")
|
||||||
page.title = newTitle
|
page.title = newTitle
|
||||||
page.slug = slug
|
page.slug = slug
|
||||||
page.updatedAt = new Date()
|
page.updatedAt = new Date()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[page]
|
||||||
|
)
|
||||||
|
|
||||||
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||||
const editor = titleEditorRef.current
|
const editor = titleEditorRef.current
|
||||||
@@ -201,7 +201,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
autofocus: false,
|
|
||||||
extensions: [
|
extensions: [
|
||||||
FocusClasses,
|
FocusClasses,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
@@ -246,12 +245,15 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
isTitleInitialMount.current = true
|
isTitleInitialMount.current = true
|
||||||
isContentInitialMount.current = true
|
isContentInitialMount.current = true
|
||||||
|
|
||||||
if (!page.title) {
|
if (!isInitialFocusApplied.current && titleEditor && contentEditorRef.current?.editor) {
|
||||||
titleEditor?.commands.focus()
|
isInitialFocusApplied.current = true
|
||||||
} else {
|
if (!page.title) {
|
||||||
contentEditorRef.current?.editor?.commands.focus()
|
titleEditor?.commands.focus()
|
||||||
|
} else {
|
||||||
|
contentEditorRef.current.editor.commands.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [page.title, titleEditor, contentEditorRef])
|
}, [page.title, titleEditor])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
|
|||||||
@@ -1,54 +1,58 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||||
import { LaIcon } from "@/components/custom/la-icon"
|
import { LaIcon } from "@/components/custom/la-icon"
|
||||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
import { useRouter } from "next/navigation"
|
import { usePageActions } from "./hooks/use-page-actions"
|
||||||
import { PersonalPage } from "@/lib/schema"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export const PageHeader = React.memo(() => {
|
interface PageHeaderProps {}
|
||||||
|
|
||||||
|
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
|
||||||
const { me } = useAccount()
|
const { me } = useAccount()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { newPage } = usePageActions()
|
||||||
|
|
||||||
if (!me) return null
|
if (!me) return null
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleNewPageClick = () => {
|
||||||
try {
|
const page = newPage(me)
|
||||||
const newPersonalPage = PersonalPage.create(
|
router.push(`/pages/${page.id}`)
|
||||||
{ 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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
<ContentHeader className="px-6 py-4 max-lg:px-4">
|
||||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
<HeaderTitle />
|
||||||
<SidebarToggleButton />
|
<div className="flex flex-auto" />
|
||||||
<div className="flex min-h-0 items-center">
|
<NewPageButton onClick={handleNewPageClick} />
|
||||||
<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>
|
</ContentHeader>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
PageHeader.displayName = "PageHeader"
|
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"
|
import { ID } from "jazz-tools"
|
||||||
|
|
||||||
export const usePageActions = () => {
|
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 => {
|
const deletePage = useCallback((me: LaAccount, pageId: ID<PersonalPage>): void => {
|
||||||
if (!me.root?.personalPages) return
|
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 { Primitive } from "@radix-ui/react-primitive"
|
||||||
import { useAccount } from "@/lib/providers/jazz-provider"
|
import { useAccount } from "@/lib/providers/jazz-provider"
|
||||||
import { useAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||||
import { PageItem } from "./partials/page-item"
|
import { PageItem } from "./partials/page-item"
|
||||||
import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation"
|
|
||||||
import { useMedia } from "react-use"
|
import { useMedia } from "react-use"
|
||||||
import { Column } from "./partials/column"
|
|
||||||
import { useColumnStyles } from "./hooks/use-column-styles"
|
import { useColumnStyles } from "./hooks/use-column-styles"
|
||||||
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
|
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||||
|
import { Column } from "@/components/custom/column"
|
||||||
|
|
||||||
interface PageListProps {
|
interface PageListProps {
|
||||||
activeItemIndex: number | null
|
activeItemIndex: number | null
|
||||||
@@ -23,6 +23,7 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
|
|||||||
const { me } = useAccount({ root: { personalPages: [] } })
|
const { me } = useAccount({ root: { personalPages: [] } })
|
||||||
const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
|
const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const itemCount = personalPages?.length || 0
|
||||||
|
|
||||||
const handleEnter = useCallback(
|
const handleEnter = useCallback(
|
||||||
(selectedPage: PersonalPage) => {
|
(selectedPage: PersonalPage) => {
|
||||||
@@ -31,24 +32,35 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
|
|||||||
[router]
|
[router]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { listRef, setItemRef } = useKeyboardNavigation({
|
const handleKeyDown = useCallback(
|
||||||
personalPages,
|
(e: KeyboardEvent) => {
|
||||||
activeItemIndex,
|
if (isCommandPaletteOpen) return
|
||||||
setActiveItemIndex,
|
|
||||||
isCommandPaletteOpen,
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||||
disableEnterKey,
|
e.preventDefault()
|
||||||
onEnter: handleEnter
|
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 (
|
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 />}
|
{!isTablet && <ColumnHeader />}
|
||||||
<PageListItems
|
<PageListItems personalPages={personalPages} activeItemIndex={activeItemIndex} />
|
||||||
listRef={listRef}
|
|
||||||
setItemRef={setItemRef}
|
|
||||||
personalPages={personalPages}
|
|
||||||
activeItemIndex={activeItemIndex}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PageListItemsProps {
|
interface PageListItemsProps {
|
||||||
listRef: React.RefObject<HTMLDivElement>
|
|
||||||
setItemRef: (el: HTMLAnchorElement | null, index: number) => void
|
|
||||||
personalPages?: PersonalPageLists | null
|
personalPages?: PersonalPageLists | null
|
||||||
activeItemIndex: number | null
|
activeItemIndex: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageListItems: React.FC<PageListItemsProps> = ({ listRef, setItemRef, personalPages, activeItemIndex }) => (
|
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
|
||||||
<Primitive.div
|
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
|
||||||
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]"
|
return (
|
||||||
tabIndex={-1}
|
<Primitive.div
|
||||||
role="list"
|
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||||
>
|
tabIndex={-1}
|
||||||
{personalPages?.map(
|
role="list"
|
||||||
(page, index) =>
|
>
|
||||||
page?.id && (
|
{personalPages?.map(
|
||||||
<PageItem
|
(page, index) =>
|
||||||
key={page.id}
|
page?.id && (
|
||||||
ref={(el: HTMLAnchorElement | null) => setItemRef(el, index)}
|
<PageItem
|
||||||
page={page}
|
key={page.id}
|
||||||
isActive={index === activeItemIndex}
|
ref={el => setElementRef(el, index)}
|
||||||
/>
|
page={page}
|
||||||
)
|
isActive={index === activeItemIndex}
|
||||||
)}
|
/>
|
||||||
</Primitive.div>
|
)
|
||||||
)
|
)}
|
||||||
|
</Primitive.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import Link from "next/link"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PersonalPage } from "@/lib/schema"
|
import { PersonalPage } from "@/lib/schema"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Column } from "./column"
|
|
||||||
import { useMedia } from "react-use"
|
import { useMedia } from "react-use"
|
||||||
import { useColumnStyles } from "../hooks/use-column-styles"
|
import { useColumnStyles } from "../hooks/use-column-styles"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
import { Column } from "@/components/custom/column"
|
||||||
|
|
||||||
interface PageItemProps {
|
interface PageItemProps {
|
||||||
page: PersonalPage
|
page: PersonalPage
|
||||||
@@ -21,14 +21,10 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
|
|||||||
<Link
|
<Link
|
||||||
ref={ref}
|
ref={ref}
|
||||||
tabIndex={isActive ? 0 : -1}
|
tabIndex={isActive ? 0 : -1}
|
||||||
className={cn(
|
className={cn("relative block cursor-default outline-none", "min-h-12 py-2 max-lg:px-4 sm:px-6", {
|
||||||
"relative block cursor-default rounded-lg outline-none",
|
"bg-muted-foreground/5": isActive,
|
||||||
"h-12 items-center gap-x-2 py-2 max-lg:px-4 sm:px-6",
|
"hover:bg-muted/50": !isActive
|
||||||
{
|
})}
|
||||||
"bg-muted-foreground/10": isActive,
|
|
||||||
"hover:bg-muted/50": !isActive
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
href={`/pages/${page.id}`}
|
href={`/pages/${page.id}`}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
>
|
>
|
||||||
@@ -38,14 +34,9 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
|
|||||||
</Column.Wrapper>
|
</Column.Wrapper>
|
||||||
|
|
||||||
{!isTablet && (
|
{!isTablet && (
|
||||||
<>
|
<Column.Wrapper style={columnStyles.topic}>
|
||||||
{/* <Column.Wrapper style={columnStyles.content}>
|
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
|
||||||
<Column.Text className="text-[13px]">{page.slug}</Column.Text>
|
</Column.Wrapper>
|
||||||
</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">
|
<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"
|
"use client"
|
||||||
|
|
||||||
import React, { useMemo, useRef } from "react"
|
import React, { useMemo, useState } from "react"
|
||||||
import { TopicDetailHeader } from "./Header"
|
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 { atom } from "jotai"
|
||||||
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useTopicData } from "@/hooks/use-topic-data"
|
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 {
|
interface TopicDetailRouteProps {
|
||||||
topicName: string
|
topicName: string
|
||||||
}
|
}
|
||||||
@@ -14,27 +19,71 @@ interface TopicDetailRouteProps {
|
|||||||
export const openPopoverForIdAtom = atom<string | null>(null)
|
export const openPopoverForIdAtom = atom<string | null>(null)
|
||||||
|
|
||||||
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
|
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
|
||||||
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||||
const { topic } = useTopicData(topicName, me)
|
|
||||||
// const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks)
|
|
||||||
const linksRefDummy = useRef<(HTMLLIElement | null)[]>([])
|
|
||||||
const containerRefDummy = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
if (!topic || !me) {
|
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
|
||||||
return null
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-auto flex-col">
|
<>
|
||||||
<TopicDetailHeader topic={topic} />
|
<TopicDetailHeader topic={topic} />
|
||||||
<TopicSections
|
<TopicDetailList items={flattenedItems} topic={topic} activeIndex={activeIndex} setActiveIndex={setActiveIndex} />
|
||||||
topic={topic}
|
</>
|
||||||
sections={topic.latestGlobalGuide?.sections}
|
)
|
||||||
activeIndex={0}
|
}
|
||||||
setActiveIndex={() => {}}
|
|
||||||
linkRefs={linksRefDummy}
|
function NotFoundPlaceholder() {
|
||||||
containerRef={containerRefDummy}
|
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>
|
</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 { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
|
||||||
|
|
||||||
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
|
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 { openPopoverForIdAtom } from "../TopicDetailRoute"
|
||||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||||
import { useClerk } from "@clerk/nextjs"
|
import { useClerk } from "@clerk/nextjs"
|
||||||
|
|
||||||
interface LinkItemProps {
|
interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||||
topic: Topic
|
topic: Topic
|
||||||
link: LinkSchema
|
link: LinkSchema
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
index: number
|
index: number
|
||||||
setActiveIndex: (index: number) => void
|
setActiveIndex: (index: number) => void
|
||||||
|
personalLinks?: PersonalLinkLists
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkItem = React.memo(
|
export const LinkItem = React.memo(
|
||||||
React.forwardRef<HTMLLIElement, LinkItemProps>(({ topic, link, isActive, index, setActiveIndex }, ref) => {
|
React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||||
const clerk = useClerk()
|
({ topic, link, isActive, index, setActiveIndex, className, personalLinks, ...props }, ref) => {
|
||||||
const pathname = usePathname()
|
const clerk = useClerk()
|
||||||
const router = useRouter()
|
const pathname = usePathname()
|
||||||
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
|
const router = useRouter()
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
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(() => {
|
const selectedLearningState = useMemo(() => {
|
||||||
if (!me || me._type === "Anonymous") return undefined
|
return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
|
||||||
return me?.root?.personalLinks || []
|
}, [personalLink?.learningState])
|
||||||
}, [me])
|
|
||||||
|
|
||||||
const personalLink = useMemo(() => {
|
const handleClick = useCallback(
|
||||||
return personalLinks?.find(pl => pl?.link?.id === link.id)
|
(e: React.MouseEvent) => {
|
||||||
}, [personalLinks, link.id])
|
e.preventDefault()
|
||||||
|
setActiveIndex(index)
|
||||||
|
},
|
||||||
|
[index, setActiveIndex]
|
||||||
|
)
|
||||||
|
|
||||||
const selectedLearningState = useMemo(() => {
|
const handleSelectLearningState = useCallback(
|
||||||
return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
|
(learningState: LearningStateValue) => {
|
||||||
}, [personalLink?.learningState])
|
if (!personalLinks || !me || me?._type === "Anonymous") {
|
||||||
|
return clerk.redirectToSignIn({
|
||||||
const handleClick = useCallback(
|
redirectUrl: pathname
|
||||||
(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")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (personalLink) {
|
const defaultToast = {
|
||||||
if (personalLink.learningState === learningState) {
|
duration: 5000,
|
||||||
personalLink.learningState = undefined
|
position: "bottom-right" as const,
|
||||||
toast.error("Link learning state removed", defaultToast)
|
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 {
|
} else {
|
||||||
personalLink.learningState = learningState
|
const slug = generateUniqueSlug(link.title)
|
||||||
toast.success("Link learning state updated", defaultToast)
|
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)
|
setOpenPopoverForId(null)
|
||||||
const newPersonalLink = PersonalLink.create(
|
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,
|
"bg-muted-foreground/10": isActive,
|
||||||
title: link.title,
|
"hover:bg-muted/50": !isActive
|
||||||
slug,
|
|
||||||
link,
|
|
||||||
learningState,
|
|
||||||
sequence: personalLinks.length + 1,
|
|
||||||
completed: false,
|
|
||||||
topic,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date()
|
|
||||||
},
|
},
|
||||||
{ owner: me }
|
className
|
||||||
)
|
)}
|
||||||
|
{...props}
|
||||||
personalLinks.push(newPersonalLink)
|
>
|
||||||
|
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
|
||||||
toast.success("Link added.", {
|
<div className="flex min-w-0 items-center gap-x-4">
|
||||||
...defaultToast,
|
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
|
||||||
description: `${link.title} has been added to your personal link.`
|
<PopoverTrigger asChild>
|
||||||
})
|
<Button
|
||||||
}
|
size="sm"
|
||||||
|
type="button"
|
||||||
setOpenPopoverForId(null)
|
role="combobox"
|
||||||
setIsPopoverOpen(false)
|
variant="secondary"
|
||||||
},
|
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
|
||||||
[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"
|
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
className="text-muted-foreground hover:text-primary text-xs"
|
|
||||||
>
|
>
|
||||||
<span className="xl:truncate">{link.url}</span>
|
{selectedLearningState?.icon ? (
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-x-4"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
)
|
||||||
)
|
}
|
||||||
})
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
LinkItem.displayName = "LinkItem"
|
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") {
|
} else if (lowercaseKey === "alt") {
|
||||||
return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
|
return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
|
||||||
} else if (lowercaseKey === "shift") {
|
} else if (lowercaseKey === "shift") {
|
||||||
return { symbol: "⇧", readable: "Shift" }
|
return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" }
|
||||||
} else if (lowercaseKey === "control") {
|
|
||||||
return { symbol: "⌃", readable: "Control" }
|
|
||||||
} else if (lowercaseKey === "windows" && !isMacOS()) {
|
|
||||||
return { symbol: "Win", readable: "Windows" }
|
|
||||||
} else {
|
} else {
|
||||||
return { symbol: key.toUpperCase(), readable: key }
|
return { symbol: key.toUpperCase(), readable: key }
|
||||||
}
|
}
|
||||||
@@ -68,21 +64,3 @@ export function getShortcutKey(key: string): ShortcutKeyResult {
|
|||||||
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
|
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
|
||||||
return keys.map(key => getShortcutKey(key))
|
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"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/nextjs": "^5.4.1",
|
"@clerk/nextjs": "^5.6.0",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@hookform/resolvers": "^3.9.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",
|
"@nothing-but/utils": "^0.16.0",
|
||||||
"@omit/react-confirm-dialog": "^1.1.5",
|
"@omit/react-confirm-dialog": "^1.1.5",
|
||||||
"@omit/react-fancy-switch": "^0.1.3",
|
"@omit/react-fancy-switch": "^0.1.3",
|
||||||
@@ -37,45 +37,45 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@sentry/nextjs": "^8.30.0",
|
"@sentry/nextjs": "^8.30.0",
|
||||||
"@tanstack/react-virtual": "^3.10.7",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"@tiptap/core": "^2.6.6",
|
"@tiptap/core": "^2.7.2",
|
||||||
"@tiptap/extension-blockquote": "^2.6.6",
|
"@tiptap/extension-blockquote": "^2.7.2",
|
||||||
"@tiptap/extension-bold": "^2.6.6",
|
"@tiptap/extension-bold": "^2.7.2",
|
||||||
"@tiptap/extension-bullet-list": "^2.6.6",
|
"@tiptap/extension-bullet-list": "^2.7.2",
|
||||||
"@tiptap/extension-code": "^2.6.6",
|
"@tiptap/extension-code": "^2.7.2",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.6.6",
|
"@tiptap/extension-code-block-lowlight": "^2.7.2",
|
||||||
"@tiptap/extension-color": "^2.6.6",
|
"@tiptap/extension-color": "^2.7.2",
|
||||||
"@tiptap/extension-document": "^2.6.6",
|
"@tiptap/extension-document": "^2.7.2",
|
||||||
"@tiptap/extension-dropcursor": "^2.6.6",
|
"@tiptap/extension-dropcursor": "^2.7.2",
|
||||||
"@tiptap/extension-focus": "^2.6.6",
|
"@tiptap/extension-focus": "^2.7.2",
|
||||||
"@tiptap/extension-gapcursor": "^2.6.6",
|
"@tiptap/extension-gapcursor": "^2.7.2",
|
||||||
"@tiptap/extension-hard-break": "^2.6.6",
|
"@tiptap/extension-hard-break": "^2.7.2",
|
||||||
"@tiptap/extension-heading": "^2.6.6",
|
"@tiptap/extension-heading": "^2.7.2",
|
||||||
"@tiptap/extension-history": "^2.6.6",
|
"@tiptap/extension-history": "^2.7.2",
|
||||||
"@tiptap/extension-horizontal-rule": "^2.6.6",
|
"@tiptap/extension-horizontal-rule": "^2.7.2",
|
||||||
"@tiptap/extension-image": "^2.6.6",
|
"@tiptap/extension-image": "^2.7.2",
|
||||||
"@tiptap/extension-italic": "^2.6.6",
|
"@tiptap/extension-italic": "^2.7.2",
|
||||||
"@tiptap/extension-link": "^2.6.6",
|
"@tiptap/extension-link": "^2.7.2",
|
||||||
"@tiptap/extension-list-item": "^2.6.6",
|
"@tiptap/extension-list-item": "^2.7.2",
|
||||||
"@tiptap/extension-ordered-list": "^2.6.6",
|
"@tiptap/extension-ordered-list": "^2.7.2",
|
||||||
"@tiptap/extension-paragraph": "^2.6.6",
|
"@tiptap/extension-paragraph": "^2.7.2",
|
||||||
"@tiptap/extension-placeholder": "^2.6.6",
|
"@tiptap/extension-placeholder": "^2.7.2",
|
||||||
"@tiptap/extension-strike": "^2.6.6",
|
"@tiptap/extension-strike": "^2.7.2",
|
||||||
"@tiptap/extension-task-item": "^2.6.6",
|
"@tiptap/extension-task-item": "^2.7.2",
|
||||||
"@tiptap/extension-task-list": "^2.6.6",
|
"@tiptap/extension-task-list": "^2.7.2",
|
||||||
"@tiptap/extension-text": "^2.6.6",
|
"@tiptap/extension-text": "^2.7.2",
|
||||||
"@tiptap/extension-typography": "^2.6.6",
|
"@tiptap/extension-typography": "^2.7.2",
|
||||||
"@tiptap/pm": "^2.6.6",
|
"@tiptap/pm": "^2.7.2",
|
||||||
"@tiptap/react": "^2.6.6",
|
"@tiptap/react": "^2.7.2",
|
||||||
"@tiptap/starter-kit": "^2.6.6",
|
"@tiptap/starter-kit": "^2.7.2",
|
||||||
"@tiptap/suggestion": "^2.6.6",
|
"@tiptap/suggestion": "^2.7.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"cheerio": "1.0.0",
|
"cheerio": "1.0.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.5",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
|
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
|
||||||
"jazz-react": "0.7.35-guest-auth.5",
|
"jazz-react": "0.7.35-guest-auth.5",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"jotai": "^2.9.3",
|
"jotai": "^2.9.3",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"lucide-react": "^0.429.0",
|
"lucide-react": "^0.429.0",
|
||||||
"next": "14.2.5",
|
"next": "14.2.10",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"nuqs": "^1.19.1",
|
"nuqs": "^1.19.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -99,27 +99,27 @@
|
|||||||
"streaming-markdown": "^0.0.14",
|
"streaming-markdown": "^0.0.14",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.2",
|
"vaul": "^0.9.4",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zsa": "^0.6.0",
|
"zsa": "^0.6.0",
|
||||||
"zsa-react": "^0.2.2"
|
"zsa-react": "^0.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ronin/learn-anything": "^0.0.0-3451954511456",
|
"@ronin/learn-anything": "^0.0.0-3452357373461",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@testing-library/jest-dom": "^6.5.0",
|
||||||
"@testing-library/react": "^16.0.1",
|
"@testing-library/react": "^16.0.1",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.5",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.7",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8.4.45",
|
"postcss": "^8.4.47",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.12",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.6.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