Merge branch 'main' into tasks

This commit is contained in:
Nikita
2024-09-19 19:38:31 +03:00
56 changed files with 1889 additions and 1257 deletions

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
export default function Page() {
return <TopicRoute />
}

View File

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

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

View File

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

View File

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

View File

@@ -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("__")

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,93 @@
import React, { useRef, useCallback } from "react"
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
import { Link as LinkSchema, Section as SectionSchema, Topic } from "@/lib/schema"
import { LinkItem } from "./partials/link-item"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
export type FlattenedItem = { type: "link"; data: LinkSchema | null } | { type: "section"; data: SectionSchema | null }
interface TopicDetailListProps {
items: FlattenedItem[]
topic: Topic
activeIndex: number
setActiveIndex: (index: number) => void
}
export function TopicDetailList({ items, topic, activeIndex, setActiveIndex }: TopicDetailListProps) {
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
const personalLinks = !me || me._type === "Anonymous" ? undefined : me.root.personalLinks
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 5
})
const renderItem = useCallback(
(virtualRow: VirtualItem) => {
const item = items[virtualRow.index]
if (item.type === "section") {
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="flex flex-col"
>
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
<p className="text-foreground text-sm font-medium">{item.data?.title}</p>
<div className="flex-1 border-b" />
</div>
</div>
)
}
if (item.data?.id) {
return (
<LinkItem
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
topic={topic}
link={item.data as LinkSchema}
isActive={activeIndex === virtualRow.index}
index={virtualRow.index}
setActiveIndex={setActiveIndex}
personalLinks={personalLinks}
/>
)
}
return null
},
[items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks]
)
return (
<div ref={parentRef} className="flex-1 overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative"
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`
}}
>
{virtualizer.getVirtualItems().map(renderItem)}
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { useAccount } from "@/lib/providers/jazz-provider"
interface TopicHeaderProps {}
export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
const { me } = useAccount()
if (!me) return null
return (
<ContentHeader className="px-6 py-4 max-lg:px-4">
<HeaderTitle />
<div className="flex flex-auto" />
</ContentHeader>
)
})
TopicHeader.displayName = "TopicHeader"
const HeaderTitle: React.FC = () => (
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left font-bold lg:text-xl">Topics</span>
</div>
</div>
)

View File

@@ -0,0 +1,14 @@
import { useMedia } from "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" }
}
}

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai"
export const keyboardDisableSourcesAtom = atom<Set<string>>(new Set<string>())