diff --git a/package.json b/package.json index eb489b5d..3d94d0f8 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,14 @@ "web" ], "dependencies": { - "@clerk/themes": "^2.1.27", - "@tauri-apps/cli": "^2.0.0-rc.12", + "@clerk/themes": "^2.1.30", + "@tauri-apps/cli": "^2.0.0-rc.16", "@tauri-apps/plugin-fs": "^2.0.0-rc.2", "jazz-nodejs": "0.7.35-guest-auth.5", "react-icons": "^5.3.0" }, "devDependencies": { - "bun-types": "^1.1.27" + "bun-types": "^1.1.28" }, "prettier": { "plugins": [ diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 4b11bfbc..3b897096 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -1,19 +1,27 @@ -"use client" - +import type { Viewport } from "next" import { Sidebar } from "@/components/custom/sidebar/sidebar" import { CommandPalette } from "@/components/custom/command-palette/command-palette" -import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" +import { Shortcut } from "@/components/custom/Shortcut/shortcut" +import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler" + +export const viewport: Viewport = { + width: "device-width, shrink-to-fit=no", + maximumScale: 1, + userScalable: false +} export default function PageLayout({ children }: { children: React.ReactNode }) { - const { me } = useAccountOrGuest() - return (
+ + - {me._type !== "Anonymous" && } + + -
+
{children}
diff --git a/web/app/(pages)/topics/page.tsx b/web/app/(pages)/topics/page.tsx new file mode 100644 index 00000000..6251415e --- /dev/null +++ b/web/app/(pages)/topics/page.tsx @@ -0,0 +1,5 @@ +import { TopicRoute } from "@/components/routes/topics/TopicRoute" + +export default function Page() { + return +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 785a5550..871c2b75 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next" +import type { Metadata, Viewport } from "next" import { cn } from "@/lib/utils" import { ThemeProvider } from "@/lib/providers/theme-provider" import "./globals.css" @@ -10,7 +10,13 @@ import { DeepLinkProvider } from "@/lib/providers/deep-link-provider" import { GeistMono, GeistSans } from "./fonts" import { JazzAndAuth } from "@/lib/providers/jazz-provider" import { TooltipProvider } from "@/components/ui/tooltip" -import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" + +export const viewport: Viewport = { + width: "device-width", + height: "device-height", + initialScale: 1, + viewportFit: "cover" +} export const metadata: Metadata = { title: "Learn Anything", @@ -43,7 +49,7 @@ export default function RootLayout({ {children} - + diff --git a/web/components/custom/Shortcut/shortcut.tsx b/web/components/custom/Shortcut/shortcut.tsx new file mode 100644 index 00000000..a8918077 --- /dev/null +++ b/web/components/custom/Shortcut/shortcut.tsx @@ -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 }) => ( + +) + +const ShortcutItem: React.FC = ({ label, keys, then }) => ( +
+
+ {label} +
+
+ + + {keys.map((key, index) => ( + + ))} + {then && ( + <> + then + {then.map((key, index) => ( + + ))} + + )} + + +
+
+) + +const ShortcutSection: React.FC = ({ title, shortcuts }) => ( +
+

{title}

+
+ {shortcuts.map((shortcut, index) => ( + + ))} +
+
+) + +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 ( + + + + +
+ Keyboard Shortcuts + Quickly navigate around the app + +
+ + + + Close + +
+ +
+
+ + setSearchQuery(e.target.value)} + /> + +
+ +
+
+
+ {filteredShortcuts.map((section, index) => ( + + ))} +
+
+
+
+
+
+ ) +} diff --git a/web/components/routes/page/partials/column.tsx b/web/components/custom/column.tsx similarity index 100% rename from web/components/routes/page/partials/column.tsx rename to web/components/custom/column.tsx diff --git a/web/components/custom/command-palette/command-data.ts b/web/components/custom/command-palette/command-data.ts index ac3f4cb1..01221ec1 100644 --- a/web/components/custom/command-palette/command-data.ts +++ b/web/components/custom/command-palette/command-data.ts @@ -6,6 +6,7 @@ import { HTMLLikeElement } from "@/lib/utils" export type CommandAction = string | (() => void) export interface CommandItemType { + id?: string icon?: keyof typeof icons value: string label: HTMLLikeElement | string diff --git a/web/components/custom/command-palette/command-items.tsx b/web/components/custom/command-palette/command-items.tsx index 9555ad16..51f64d3a 100644 --- a/web/components/custom/command-palette/command-items.tsx +++ b/web/components/custom/command-palette/command-items.tsx @@ -11,14 +11,14 @@ export interface CommandItemProps extends Omit { } const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = React.memo(({ content }) => { - return <>{renderHTMLLikeElement(content)} + return {renderHTMLLikeElement(content)} }) HTMLLikeRenderer.displayName = "HTMLLikeRenderer" export const CommandItem: React.FC = React.memo( - ({ icon, label, action, payload, shortcut, handleAction }) => ( - handleAction(action, payload)}> + ({ icon, label, action, payload, shortcut, handleAction, ...item }) => ( + handleAction(action, payload)}> {icon && } {shortcut && {shortcut}} diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx index 4e2fd40c..11feb4dd 100644 --- a/web/components/custom/command-palette/command-palette.tsx +++ b/web/components/custom/command-palette/command-palette.tsx @@ -1,14 +1,17 @@ +"use client" + import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { Command } from "cmdk" import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" import { CommandGroup } from "./command-items" import { CommandAction, CommandItemType, createCommandGroups } from "./command-data" -import { useAccount } from "@/lib/providers/jazz-provider" +import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" import { searchSafeRegExp } from "@/lib/utils" import { GraphNode } from "@/components/routes/public/PublicHomeRoute" import { useCommandActions } from "./hooks/use-command-actions" import { atom, useAtom } from "jotai" +import { useKeydownListener } from "@/hooks/use-keydown-listener" const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default) @@ -18,6 +21,14 @@ const filterItems = (items: CommandItemType[], searchRegex: RegExp) => export const commandPaletteOpenAtom = atom(false) export function CommandPalette() { + const { me } = useAccountOrGuest() + + if (me._type === "Anonymous") return null + + return +} + +export function RealCommandPalette() { const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } }) const dialogRef = React.useRef(null) const [inputValue, setInputValue] = React.useState("") @@ -29,17 +40,17 @@ export function CommandPalette() { const raw_graph_data = React.use(graph_data_promise) as GraphNode[] - React.useEffect(() => { - const down = (e: KeyboardEvent) => { + const handleKeydown = React.useCallback( + (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen(prev => !prev) } - } + }, + [setOpen] + ) - document.addEventListener("keydown", down) - return () => document.removeEventListener("keydown", down) - }, [setOpen]) + useKeydownListener(handleKeydown) const bounce = React.useCallback(() => { if (dialogRef.current) { @@ -86,6 +97,7 @@ export function CommandPalette() { heading: "Personal Links", items: me?.root.personalLinks?.map(link => ({ + id: link?.id, icon: "Link" as const, value: link?.title || "Untitled", label: link?.title || "Untitled", @@ -100,6 +112,7 @@ export function CommandPalette() { heading: "Personal Pages", items: me?.root.personalPages?.map(page => ({ + id: page?.id, icon: "FileText" as const, value: page?.title || "Untitled", label: page?.title || "Untitled", @@ -116,11 +129,9 @@ export function CommandPalette() { if (activePage === "home") { if (!inputValue) { - // Only show items from the home object when there's no search input return commandGroups.home } - // When there's a search input, search across all categories const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics] return allGroups @@ -131,7 +142,6 @@ export function CommandPalette() { .filter(group => group.items.length > 0) } - // Handle other active pages (searchLinks, searchPages, etc.) switch (activePage) { case "searchLinks": return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }] @@ -184,7 +194,7 @@ export function CommandPalette() { const commandKey = React.useMemo(() => { return filteredCommands .map(group => { - const itemsKey = group.items.map(item => `${item.label}-${item.action}`).join("|") + const itemsKey = group.items.map(item => `${item.label}-${item.value}`).join("|") return `${group.heading}:${itemsKey}` }) .join("__") diff --git a/web/components/custom/command-palette/hooks/use-command-actions.ts b/web/components/custom/command-palette/hooks/use-command-actions.ts index 453e365c..589d95d6 100644 --- a/web/components/custom/command-palette/hooks/use-command-actions.ts +++ b/web/components/custom/command-palette/hooks/use-command-actions.ts @@ -3,11 +3,13 @@ import { ensureUrlProtocol } from "@/lib/utils" import { useTheme } from "next-themes" import { toast } from "sonner" import { useRouter } from "next/navigation" -import { LaAccount, PersonalPage } from "@/lib/schema" +import { LaAccount } from "@/lib/schema" +import { usePageActions } from "@/components/routes/page/hooks/use-page-actions" export const useCommandActions = () => { const { setTheme } = useTheme() const router = useRouter() + const { newPage } = usePageActions() const changeTheme = React.useCallback( (theme: string) => { @@ -35,19 +37,10 @@ export const useCommandActions = () => { const createNewPage = React.useCallback( (me: LaAccount) => { - try { - const newPersonalPage = PersonalPage.create( - { public: false, createdAt: new Date(), updatedAt: new Date() }, - { owner: me._owner } - ) - - me.root?.personalPages?.push(newPersonalPage) - router.push(`/pages/${newPersonalPage.id}`) - } catch (error) { - toast.error("Failed to create page") - } + const page = newPage(me) + router.push(`/pages/${page.id}`) }, - [router] + [router, newPage] ) return { diff --git a/web/components/custom/discordIcon.tsx b/web/components/custom/discordIcon.tsx new file mode 100644 index 00000000..9d172b4b --- /dev/null +++ b/web/components/custom/discordIcon.tsx @@ -0,0 +1,23 @@ +export const DiscordIcon = () => ( + + + + + +) diff --git a/web/components/custom/global-keydown-handler.tsx b/web/components/custom/global-keydown-handler.tsx new file mode 100644 index 00000000..527162cd --- /dev/null +++ b/web/components/custom/global-keydown-handler.tsx @@ -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([]) + 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 +} diff --git a/web/components/custom/learning-state-selector.tsx b/web/components/custom/learning-state-selector.tsx index c1b2e4a9..5b467b86 100644 --- a/web/components/custom/learning-state-selector.tsx +++ b/web/components/custom/learning-state-selector.tsx @@ -20,7 +20,7 @@ interface LearningStateSelectorProps { export const LearningStateSelector: React.FC = ({ showSearch = true, - defaultLabel = "Select state", + defaultLabel = "State", searchPlaceholder = "Search state...", value, onChange, diff --git a/web/components/custom/sidebar/partial/page-section.tsx b/web/components/custom/sidebar/partial/page-section.tsx index 4b9daf28..c5845c58 100644 --- a/web/components/custom/sidebar/partial/page-section.tsx +++ b/web/components/custom/sidebar/partial/page-section.tsx @@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils" import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page" import { Button } from "@/components/ui/button" import { LaIcon } from "@/components/custom/la-icon" -import { toast } from "sonner" import Link from "next/link" import { DropdownMenu, @@ -21,6 +20,7 @@ import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { icons } from "lucide-react" +import { usePageActions } from "@/components/routes/page/hooks/use-page-actions" type SortOption = "title" | "recent" type ShowOption = 5 | 10 | 15 | 20 | 0 @@ -101,20 +101,13 @@ const PageSectionHeader: React.FC = ({ pageCount, isActi const NewPageButton: React.FC = () => { const { me } = useAccount() const router = useRouter() + const { newPage } = usePageActions() if (!me) return null const handleClick = () => { - try { - const newPersonalPage = PersonalPage.create( - { public: false, createdAt: new Date(), updatedAt: new Date() }, - { owner: me._owner } - ) - me.root?.personalPages?.push(newPersonalPage) - router.push(`/pages/${newPersonalPage.id}`) - } catch (error) { - toast.error("Failed to create page") - } + const page = newPage(me) + router.push(`/pages/${page.id}`) } return ( diff --git a/web/components/custom/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx index 97ad4a7c..2396c7ac 100644 --- a/web/components/custom/sidebar/partial/profile-section.tsx +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -1,6 +1,14 @@ -import { LaIcon } from "@/components/custom/la-icon" -import { useState } from "react" +"use client" + +import { useEffect, useState } from "react" import { SignInButton, useAuth, useUser } from "@clerk/nextjs" +import { useAtom } from "jotai" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { icons } from "lucide-react" + +import { LaIcon } from "@/components/custom/la-icon" +import { DiscordIcon } from "@/components/custom/discordIcon" import { DropdownMenu, DropdownMenuContent, @@ -9,17 +17,25 @@ import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Avatar, AvatarImage } from "@/components/ui/avatar" -import Link from "next/link" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { usePathname } from "next/navigation" +import { cn } from "@/lib/utils" import { Feedback } from "./feedback" +import { showShortcutAtom } from "@/components/custom/Shortcut/shortcut" +import { ShortcutKey } from "@/components/minimal-tiptap/components/shortcut-key" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" export const ProfileSection: React.FC = () => { const { user, isSignedIn } = useUser() const { signOut } = useAuth() const [menuOpen, setMenuOpen] = useState(false) const pathname = usePathname() + const [, setShowShortcut] = useAtom(showShortcutAtom) + + const { disableKeydown } = useKeyboardManager("profileSection") + + useEffect(() => { + disableKeydown(menuOpen) + }, [menuOpen, disableKeydown]) if (!isSignedIn) { return ( @@ -37,78 +53,104 @@ export const ProfileSection: React.FC = () => { return (
-
- - - - - - - -
- - My profile -
- -
- - - -
- - Onboarding -
- -
- - - - -
- - Docs -
- -
- - - - -
- - GitHub -
- -
- - - signOut()}> -
- - Log out -
-
-
-
-
- +
) } + +interface ProfileDropdownProps { + user: any + menuOpen: boolean + setMenuOpen: (open: boolean) => void + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const ProfileDropdown: React.FC = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => ( +
+ + + + + + + + +
+) + +interface DropdownMenuItemsProps { + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const DropdownMenuItems: React.FC = ({ signOut, setShowShortcut }) => ( + <> + + setShowShortcut(true)}> + + Shortcut + + + + + + + + +
+ + Log out +
+ +
+
+
+ +) + +interface MenuLinkProps { + href: string + icon: keyof typeof icons | React.FC + text: string + iconClass?: string +} + +const MenuLink: React.FC = ({ href, icon, text, iconClass = "" }) => { + const IconComponent = typeof icon === "string" ? icons[icon] : icon + return ( + + +
+ + {text} +
+ +
+ ) +} + +export default ProfileSection diff --git a/web/components/la-editor/components/ui/shortcut.tsx b/web/components/la-editor/components/ui/shortcut.tsx index 978a2b4a..cdb2f976 100644 --- a/web/components/la-editor/components/ui/shortcut.tsx +++ b/web/components/la-editor/components/ui/shortcut.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { cn } from "@/lib/utils" -import { getShortcutKey } from "../../lib/utils" +import { getShortcutKey } from "@/lib/utils" export interface ShortcutKeyWrapperProps extends React.HTMLAttributes { ariaLabel: string @@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef(({ class {...props} ref={ref} > - {getShortcutKey(shortcut)} + {getShortcutKey(shortcut).symbol} ) }) diff --git a/web/components/la-editor/extensions/slash-command/menu-list.tsx b/web/components/la-editor/extensions/slash-command/menu-list.tsx index 10377d18..ebf92265 100644 --- a/web/components/la-editor/extensions/slash-command/menu-list.tsx +++ b/web/components/la-editor/extensions/slash-command/menu-list.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Command, MenuListProps } from "./types" -import { getShortcutKeys } from "../../lib/utils" +import { getShortcutKeys } from "@/lib/utils" import { Icon } from "../../components/ui/icon" import { PopoverWrapper } from "../../components/ui/popover-wrapper" import { Shortcut } from "../../components/ui/shortcut" @@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => { {command.label}
- + shortcut.readable) + .join(" + ")} + > {command.shortcuts.map(shortcut => ( ))} diff --git a/web/components/la-editor/lib/utils/index.ts b/web/components/la-editor/lib/utils/index.ts index 885f4fa4..60123d5b 100644 --- a/web/components/la-editor/lib/utils/index.ts +++ b/web/components/la-editor/lib/utils/index.ts @@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) { return "" } -export * from "./keyboard" -export * from "./platform" export * from "./isCustomNodeSelected" export * from "./isTextSelected" diff --git a/web/components/la-editor/lib/utils/keyboard.ts b/web/components/la-editor/lib/utils/keyboard.ts deleted file mode 100644 index 09739fc2..00000000 --- a/web/components/la-editor/lib/utils/keyboard.ts +++ /dev/null @@ -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 } diff --git a/web/components/la-editor/lib/utils/platform.ts b/web/components/la-editor/lib/utils/platform.ts deleted file mode 100644 index 2dffa98a..00000000 --- a/web/components/la-editor/lib/utils/platform.ts +++ /dev/null @@ -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 diff --git a/web/components/minimal-tiptap/components/shortcut-key.tsx b/web/components/minimal-tiptap/components/shortcut-key.tsx index 2691528d..e81bdd88 100644 --- a/web/components/minimal-tiptap/components/shortcut-key.tsx +++ b/web/components/minimal-tiptap/components/shortcut-key.tsx @@ -1,33 +1,33 @@ -import * as React from 'react' -import { cn } from '@/lib/utils' -import { getShortcutKey } from '../utils' +import * as React from "react" +import { cn } from "@/lib/utils" +import { getShortcutKey } from "@/lib/utils" export interface ShortcutKeyProps extends React.HTMLAttributes { - keys: string[] + keys: string[] } export const ShortcutKey = React.forwardRef(({ className, keys, ...props }, ref) => { - const modifiedKeys = keys.map(key => getShortcutKey(key)) - const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ') + const modifiedKeys = keys.map(key => getShortcutKey(key)) + const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ") - return ( - - {modifiedKeys.map(shortcut => ( - + {modifiedKeys.map(shortcut => ( + - {shortcut.symbol} - - ))} - - ) + className + )} + {...props} + ref={ref} + > + {shortcut.symbol} + + ))} + + ) }) -ShortcutKey.displayName = 'ShortcutKey' +ShortcutKey.displayName = "ShortcutKey" diff --git a/web/components/minimal-tiptap/components/toolbar-section.tsx b/web/components/minimal-tiptap/components/toolbar-section.tsx index e296fd17..9aacc01b 100644 --- a/web/components/minimal-tiptap/components/toolbar-section.tsx +++ b/web/components/minimal-tiptap/components/toolbar-section.tsx @@ -1,112 +1,112 @@ -import * as React from 'react' -import type { Editor } from '@tiptap/react' -import { cn } from '@/lib/utils' -import { CaretDownIcon } from '@radix-ui/react-icons' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { ToolbarButton } from './toolbar-button' -import { ShortcutKey } from './shortcut-key' -import { getShortcutKey } from '../utils' -import type { FormatAction } from '../types' -import type { VariantProps } from 'class-variance-authority' -import type { toggleVariants } from '@/components/ui/toggle' +import * as React from "react" +import type { Editor } from "@tiptap/react" +import { cn } from "@/lib/utils" +import { CaretDownIcon } from "@radix-ui/react-icons" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { ToolbarButton } from "./toolbar-button" +import { ShortcutKey } from "./shortcut-key" +import { getShortcutKey } from "@/lib/utils" +import type { FormatAction } from "../types" +import type { VariantProps } from "class-variance-authority" +import type { toggleVariants } from "@/components/ui/toggle" interface ToolbarSectionProps extends VariantProps { - editor: Editor - actions: FormatAction[] - activeActions?: string[] - mainActionCount?: number - dropdownIcon?: React.ReactNode - dropdownTooltip?: string - dropdownClassName?: string + editor: Editor + actions: FormatAction[] + activeActions?: string[] + mainActionCount?: number + dropdownIcon?: React.ReactNode + dropdownTooltip?: string + dropdownClassName?: string } export const ToolbarSection: React.FC = ({ - editor, - actions, - activeActions = actions.map(action => action.value), - mainActionCount = 0, - dropdownIcon, - dropdownTooltip = 'More options', - dropdownClassName = 'w-12', - size, - variant + editor, + actions, + activeActions = actions.map(action => action.value), + mainActionCount = 0, + dropdownIcon, + dropdownTooltip = "More options", + dropdownClassName = "w-12", + size, + variant }) => { - const { mainActions, dropdownActions } = React.useMemo(() => { - const sortedActions = actions - .filter(action => activeActions.includes(action.value)) - .sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value)) + const { mainActions, dropdownActions } = React.useMemo(() => { + const sortedActions = actions + .filter(action => activeActions.includes(action.value)) + .sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value)) - return { - mainActions: sortedActions.slice(0, mainActionCount), - dropdownActions: sortedActions.slice(mainActionCount) - } - }, [actions, activeActions, mainActionCount]) + return { + mainActions: sortedActions.slice(0, mainActionCount), + dropdownActions: sortedActions.slice(mainActionCount) + } + }, [actions, activeActions, mainActionCount]) - const renderToolbarButton = React.useCallback( - (action: FormatAction) => ( - action.action(editor)} - disabled={!action.canExecute(editor)} - isActive={action.isActive(editor)} - tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`} - aria-label={action.label} - size={size} - variant={variant} - > - {action.icon} - - ), - [editor, size, variant] - ) + const renderToolbarButton = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + isActive={action.isActive(editor)} + tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`} + aria-label={action.label} + size={size} + variant={variant} + > + {action.icon} + + ), + [editor, size, variant] + ) - const renderDropdownMenuItem = React.useCallback( - (action: FormatAction) => ( - action.action(editor)} - disabled={!action.canExecute(editor)} - className={cn('flex flex-row items-center justify-between gap-4', { - 'bg-accent': action.isActive(editor) - })} - aria-label={action.label} - > - {action.label} - - - ), - [editor] - ) + const renderDropdownMenuItem = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + className={cn("flex flex-row items-center justify-between gap-4", { + "bg-accent": action.isActive(editor) + })} + aria-label={action.label} + > + {action.label} + + + ), + [editor] + ) - const isDropdownActive = React.useMemo( - () => dropdownActions.some(action => action.isActive(editor)), - [dropdownActions, editor] - ) + const isDropdownActive = React.useMemo( + () => dropdownActions.some(action => action.isActive(editor)), + [dropdownActions, editor] + ) - return ( - <> - {mainActions.map(renderToolbarButton)} - {dropdownActions.length > 0 && ( - - - - {dropdownIcon || } - - - - {dropdownActions.map(renderDropdownMenuItem)} - - - )} - - ) + return ( + <> + {mainActions.map(renderToolbarButton)} + {dropdownActions.length > 0 && ( + + + + {dropdownIcon || } + + + + {dropdownActions.map(renderDropdownMenuItem)} + + + )} + + ) } export default ToolbarSection diff --git a/web/components/minimal-tiptap/utils.ts b/web/components/minimal-tiptap/utils.ts index d8772d84..d749a3cc 100644 --- a/web/components/minimal-tiptap/utils.ts +++ b/web/components/minimal-tiptap/utils.ts @@ -1,81 +1,14 @@ -import type { Editor } from '@tiptap/core' -import type { MinimalTiptapProps } from './minimal-tiptap' +import type { Editor } from "@tiptap/core" +import type { MinimalTiptapProps } from "./minimal-tiptap" -let isMac: boolean | undefined +export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) { + if (format === "json") { + return editor.getJSON() + } -interface Navigator { - userAgentData?: { - brands: { brand: string; version: string }[] - mobile: boolean - platform: string - getHighEntropyValues: (hints: string[]) => Promise<{ - platform: string - platformVersion: string - uaFullVersion: string - }> - } -} - -function getPlatform(): string { - const nav = navigator as Navigator - - if (nav.userAgentData) { - if (nav.userAgentData.platform) { - return nav.userAgentData.platform - } - - nav.userAgentData.getHighEntropyValues(['platform']).then(highEntropyValues => { - if (highEntropyValues.platform) { - return highEntropyValues.platform - } - }) - } - - if (typeof navigator.platform === 'string') { - return navigator.platform - } - - return '' -} - -export function isMacOS() { - if (isMac === undefined) { - isMac = getPlatform().toLowerCase().includes('mac') - } - - return isMac -} - -interface ShortcutKeyResult { - symbol: string - readable: string -} - -export function getShortcutKey(key: string): ShortcutKeyResult { - const lowercaseKey = key.toLowerCase() - if (lowercaseKey === 'mod') { - return isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' } - } else if (lowercaseKey === 'alt') { - return isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' } - } else if (lowercaseKey === 'shift') { - return isMacOS() ? { symbol: '⇧', readable: 'Shift' } : { symbol: 'Shift', readable: 'Shift' } - } else { - return { symbol: key, readable: key } - } -} - -export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { - return keys.map(key => getShortcutKey(key)) -} - -export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) { - if (format === 'json') { - return editor.getJSON() - } - - if (format === 'html') { - return editor.getText() ? editor.getHTML() : '' - } - - return editor.getText() + if (format === "html") { + return editor.getText() ? editor.getHTML() : "" + } + + return editor.getText() } diff --git a/web/components/routes/link/LinkRoute.tsx b/web/components/routes/link/LinkRoute.tsx index 62e5f8a9..bfbc424a 100644 --- a/web/components/routes/link/LinkRoute.tsx +++ b/web/components/routes/link/LinkRoute.tsx @@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs" import { atom, useAtom } from "jotai" import { LinkBottomBar } from "./bottom-bar" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" +import { useKey } from "react-use" export const isDeleteConfirmShownAtom = atom(false) export function LinkRoute(): React.ReactElement { - const [nuqsEditId] = useQueryState("editId") + const [nuqsEditId, setNuqsEditId] = useQueryState("editId") const [activeItemIndex, setActiveItemIndex] = useState(null) const [isInCreateMode] = useQueryState("create", parseAsBoolean) const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom) @@ -50,8 +51,13 @@ export function LinkRoute(): React.ReactElement { } }, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose]) + useKey("Escape", () => { + setDisableEnterKey(false) + setNuqsEditId(null) + }) + return ( -
+ <> -
+ ) } diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index b51e4190..df2165ab 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -1,9 +1,11 @@ +"use client" + import React, { useCallback, useEffect, useRef } from "react" import { motion, AnimatePresence } from "framer-motion" -import { icons } from "lucide-react" +import type { icons } from "lucide-react" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils" +import { cn, getShortcutKeys } from "@/lib/utils" import { LaIcon } from "@/components/custom/la-icon" import { useAtom } from "jotai" import { parseAsBoolean, useQueryState } from "nuqs" @@ -13,6 +15,7 @@ import { PersonalLink } from "@/lib/schema" import { ID } from "jazz-tools" import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form" import { useLinkActions } from "./hooks/use-link-actions" +import { useKeydownListener } from "@/hooks/use-keydown-listener" interface ToolbarButtonProps extends React.ComponentPropsWithoutRef { icon: keyof typeof icons @@ -21,9 +24,9 @@ interface ToolbarButtonProps extends React.ComponentPropsWithoutRef( - ({ icon, onClick, tooltip, ...props }, ref) => { + ({ icon, onClick, tooltip, className, ...props }, ref) => { const button = ( - ) @@ -116,28 +119,25 @@ export const LinkBottomBar: React.FC = () => { } } - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isCreateShortcut = isMacOS() - ? event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n" - : event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey) + const handleKeydown = useCallback( + (event: KeyboardEvent) => { + const isCreateShortcut = event.key === "c" if (isCreateShortcut) { event.preventDefault() handleCreateMode() } - } + }, + [handleCreateMode] + ) - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleCreateMode]) + useKeydownListener(handleKeydown) - const shortcutKeys = getSpecialShortcut("expandToolbar") - const shortcutText = formatShortcut(shortcutKeys) + const shortcutText = getShortcutKeys(["c"]) return ( @@ -145,7 +145,7 @@ export const LinkBottomBar: React.FC = () => { {editId && ( { {!editId && ( { s.symbol).join("")})`} ref={plusBtnRef} /> )} - {/* */} )} diff --git a/web/components/routes/link/header.tsx b/web/components/routes/link/header.tsx index 3fa3d863..93d14e7c 100644 --- a/web/components/routes/link/header.tsx +++ b/web/components/routes/link/header.tsx @@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => { return ( <> - +
diff --git a/web/components/routes/link/list.tsx b/web/components/routes/link/list.tsx index 67a0154a..4a4f1f49 100644 --- a/web/components/routes/link/list.tsx +++ b/web/components/routes/link/list.tsx @@ -24,6 +24,9 @@ import { commandPaletteOpenAtom } from "@/components/custom/command-palette/comm import { useConfirm } from "@omit/react-confirm-dialog" import { useLinkActions } from "./hooks/use-link-actions" import { isDeleteConfirmShownAtom } from "./LinkRoute" +import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" +import { useKeydownListener } from "@/hooks/use-keydown-listener" interface LinkListProps { activeItemIndex: number | null @@ -77,12 +80,6 @@ const LinkList: React.FC = ({ activeItemIndex, setActiveItemIndex }) ) - useKey("Escape", () => { - if (editId) { - setEditId(null) - } - }) - useKey( event => (event.metaKey || event.ctrlKey) && event.key === "Backspace", async () => { @@ -136,59 +133,52 @@ const LinkList: React.FC = ({ activeItemIndex, setActiveItemIndex }) }, []) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (isCommandPalettePpen || !me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return + const { isKeyboardDisabled } = useKeyboardManager("XComponent") - if (e.key === "ArrowUp" || e.key === "ArrowDown") { - e.preventDefault() - setActiveItemIndex(prevIndex => { - if (prevIndex === null) return 0 - const newIndex = - e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1) + useKeydownListener((e: KeyboardEvent) => { + if ( + isKeyboardDisabled || + isCommandPalettePpen || + !me?.root?.personalLinks || + sortedLinks.length === 0 || + editId !== null + ) + return - if (e.metaKey && sort === "manual") { - const linksArray = [...me.root.personalLinks] - const newLinks = arrayMove(linksArray, prevIndex, newIndex) + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault() + setActiveItemIndex(prevIndex => { + if (prevIndex === null) return 0 + const newIndex = + e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1) - while (me.root.personalLinks.length > 0) { - me.root.personalLinks.pop() - } + if (e.metaKey && sort === "manual") { + const linksArray = [...me.root.personalLinks] + const newLinks = arrayMove(linksArray, prevIndex, newIndex) - newLinks.forEach(link => { - if (link) { - me.root.personalLinks.push(link) - } - }) - - updateSequences(me.root.personalLinks) + while (me.root.personalLinks.length > 0) { + me.root.personalLinks.pop() } - return newIndex - }) - } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) { - e.preventDefault() - const activeLink = sortedLinks[activeItemIndex] - if (activeLink) { - setEditId(activeLink.id) + newLinks.forEach(link => { + if (link) { + me.root.personalLinks.push(link) + } + }) + + updateSequences(me.root.personalLinks) } + + return newIndex + }) + } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) { + e.preventDefault() + const activeLink = sortedLinks[activeItemIndex] + if (activeLink) { + setEditId(activeLink.id) } } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [ - me?.root?.personalLinks, - sortedLinks, - editId, - sort, - updateSequences, - isCommandPalettePpen, - activeItemIndex, - setEditId, - setActiveItemIndex, - disableEnterKey - ]) + }) const handleDragStart = useCallback( (event: DragStartEvent) => { @@ -245,9 +235,11 @@ const LinkList: React.FC = ({ activeItemIndex, setActiveItemIndex setDraggingId(null) } + const setElementRef = useActiveItemScroll({ activeIndex: activeItemIndex }) + return ( = ({ activeItemIndex, setActiveItemIndex isActive={activeItemIndex === index} setActiveItemIndex={setActiveItemIndex} index={index} + ref={el => setElementRef(el, index)} /> ) )} diff --git a/web/components/routes/link/partials/form/description-input.tsx b/web/components/routes/link/partials/form/description-input.tsx index 1ac7887d..9cc2ab7e 100644 --- a/web/components/routes/link/partials/form/description-input.tsx +++ b/web/components/routes/link/partials/form/description-input.tsx @@ -21,7 +21,7 @@ export const DescriptionInput: React.FC = () => { diff --git a/web/components/routes/link/partials/form/link-form.tsx b/web/components/routes/link/partials/form/link-form.tsx index e1f95ead..3fbce3d7 100644 --- a/web/components/routes/link/partials/form/link-form.tsx +++ b/web/components/routes/link/partials/form/link-form.tsx @@ -231,7 +231,7 @@ export const LinkForm: React.FC = ({ ( - {selectedTopic?.prettyName || "Select a topic"} + {selectedTopic?.prettyName || "Topic"} )} /> diff --git a/web/components/routes/link/partials/form/notes-section.tsx b/web/components/routes/link/partials/form/notes-section.tsx index ff3f5456..c14ff4f7 100644 --- a/web/components/routes/link/partials/form/notes-section.tsx +++ b/web/components/routes/link/partials/form/notes-section.tsx @@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => { diff --git a/web/components/routes/link/partials/link-item.tsx b/web/components/routes/link/partials/link-item.tsx index 1718896f..9a430d8b 100644 --- a/web/components/routes/link/partials/link-item.tsx +++ b/web/components/routes/link/partials/link-item.tsx @@ -15,7 +15,7 @@ import { cn, ensureUrlProtocol } from "@/lib/utils" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { linkOpenPopoverForIdAtom } from "@/store/link" -interface LinkItemProps { +interface LinkItemProps extends React.HTMLAttributes { personalLink: PersonalLink disabled?: boolean isEditing: boolean @@ -26,134 +26,138 @@ interface LinkItemProps { index: number } -export const LinkItem: React.FC = ({ - isEditing, - setEditId, - personalLink, - disabled = false, - isDragging, - isActive, - setActiveItemIndex, - index -}) => { - const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) +export const LinkItem = React.forwardRef( + ({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => { + const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) - const style = useMemo( - () => ({ - transform: CSS.Transform.toString(transform), - transition, - pointerEvents: isDragging ? "none" : "auto" - }), - [transform, transition, isDragging] - ) + const style = useMemo( + () => ({ + transform: CSS.Transform.toString(transform), + transition, + pointerEvents: isDragging ? "none" : "auto" + }), + [transform, transition, isDragging] + ) - const handleSuccess = useCallback(() => setEditId(null), [setEditId]) - const handleOnClose = useCallback(() => setEditId(null), [setEditId]) - const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id]) + const handleSuccess = useCallback(() => setEditId(null), [setEditId]) + const handleOnClose = useCallback(() => setEditId(null), [setEditId]) + const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id]) - const selectedLearningState = useMemo( - () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), - [personalLink.learningState] - ) + const selectedLearningState = useMemo( + () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), + [personalLink.learningState] + ) - const handleLearningStateSelect = useCallback( - (value: string) => { - const learningState = value as LearningStateValue - personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState - setOpenPopoverForId(null) - }, - [personalLink, setOpenPopoverForId] - ) + const handleLearningStateSelect = useCallback( + (value: string) => { + const learningState = value as LearningStateValue + personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState + setOpenPopoverForId(null) + }, + [personalLink, setOpenPopoverForId] + ) - if (isEditing) { - return {}} /> - } + if (isEditing) { + return ( + {}} /> + ) + } - return ( -
  • setActiveItemIndex(index)} - onBlur={() => setActiveItemIndex(null)} - className={cn( - "relative cursor-default outline-none", - "mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2", - { - "bg-muted-foreground/5": isActive, - "hover:bg-muted/50": !isActive - } - )} - onDoubleClick={handleRowDoubleClick} - > - setOpenPopoverForId(open ? personalLink.id : null)} - > - - - - e.preventDefault()} - > - - - - -
    - {personalLink.icon && ( - {personalLink.title} + return ( +
  • { + setNodeRef(node) + if (typeof ref === "function") { + ref(node) + } else if (ref) { + ref.current = node + } + }} + style={style as React.CSSProperties} + {...attributes} + {...listeners} + tabIndex={0} + onFocus={() => setActiveItemIndex(index)} + onBlur={() => setActiveItemIndex(null)} + className={cn( + "relative cursor-default outline-none", + "grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2", + { + "bg-muted-foreground/5": isActive, + "hover:bg-muted/50": !isActive + } )} + onDoubleClick={handleRowDoubleClick} + > + setOpenPopoverForId(open ? personalLink.id : null)} + > + + + + e.preventDefault()} + > + + + +
    -

    {personalLink.title}

    - {personalLink.url && ( -
    -
    + {personalLink.icon && ( + {personalLink.title} + )} +
    +

    {personalLink.title}

    + {personalLink.url && ( +
    +
    + )} +
    +
    + +
    + {personalLink.topic && ( + + {personalLink.topic.prettyName} + )}
    -
  • + + ) + } +) -
    - {personalLink.topic && ( - - {personalLink.topic.prettyName} - - )} -
    - - ) -} +LinkItem.displayName = "LinkItem" diff --git a/web/components/routes/page/detail/PageDetailRoute.tsx b/web/components/routes/page/detail/PageDetailRoute.tsx index 1e10b180..0c6467ff 100644 --- a/web/components/routes/page/detail/PageDetailRoute.tsx +++ b/web/components/routes/page/detail/PageDetailRoute.tsx @@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions" const TITLE_PLACEHOLDER = "Untitled" -const emptyPage = (page: PersonalPage): boolean => { +const isPageEmpty = (page: PersonalPage): boolean => { return (!page.title || page.title.trim() === "") && (!page.content || Object.keys(page.content).length === 0) } -export const DeleteEmptyPage = (currentPageId: string | null) => { +const useDeleteEmptyPage = (currentPageId: string | null) => { const router = useRouter() const { me } = useAccount({ root: { @@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => { }) useEffect(() => { - const handleRouteChange = () => { + return () => { if (!currentPageId || !me?.root?.personalPages) return const currentPage = me.root.personalPages.find(page => page?.id === currentPageId) - if (currentPage && emptyPage(currentPage)) { + if (currentPage && isPageEmpty(currentPage)) { const index = me.root.personalPages.findIndex(page => page?.id === currentPageId) if (index !== -1) { me.root.personalPages.splice(index, 1) } } } - - return () => { - handleRouteChange() - } }, [currentPageId, me, router]) } @@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) { const { deletePage } = usePageActions() const confirm = useConfirm() - DeleteEmptyPage(pageId) + // useDeleteEmptyPage(pageId) - const handleDelete = async () => { + const handleDelete = useCallback(async () => { const result = await confirm({ title: "Delete page", description: "Are you sure you want to delete this page?", @@ -78,7 +74,7 @@ export function PageDetailRoute({ pageId }: { pageId: string }) { deletePage(me, pageId as ID) router.push("/pages") } - } + }, [confirm, deletePage, me, pageId, router]) if (!page) return null @@ -130,30 +126,34 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const contentEditorRef = useRef(null) const isTitleInitialMount = useRef(true) const isContentInitialMount = useRef(true) + const isInitialFocusApplied = useRef(false) - const updatePageContent = (content: Content, model: PersonalPage) => { + const updatePageContent = useCallback((content: Content, model: PersonalPage) => { if (isContentInitialMount.current) { isContentInitialMount.current = false return } model.content = content model.updatedAt = new Date() - } + }, []) - const handleUpdateTitle = (editor: Editor) => { - if (isTitleInitialMount.current) { - isTitleInitialMount.current = false - return - } + const handleUpdateTitle = useCallback( + (editor: Editor) => { + if (isTitleInitialMount.current) { + isTitleInitialMount.current = false + return + } - const newTitle = editor.getText() - if (newTitle !== page.title) { - const slug = generateUniqueSlug(page.title?.toString() || "") - page.title = newTitle - page.slug = slug - page.updatedAt = new Date() - } - } + const newTitle = editor.getText() + if (newTitle !== page.title) { + const slug = generateUniqueSlug(page.title?.toString() || "") + page.title = newTitle + page.slug = slug + page.updatedAt = new Date() + } + }, + [page] + ) const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => { const editor = titleEditorRef.current @@ -201,7 +201,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const titleEditor = useEditor({ immediatelyRender: false, - autofocus: false, extensions: [ FocusClasses, Paragraph, @@ -246,12 +245,15 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { isTitleInitialMount.current = true isContentInitialMount.current = true - if (!page.title) { - titleEditor?.commands.focus() - } else { - contentEditorRef.current?.editor?.commands.focus() + if (!isInitialFocusApplied.current && titleEditor && contentEditorRef.current?.editor) { + isInitialFocusApplied.current = true + if (!page.title) { + titleEditor?.commands.focus() + } else { + contentEditorRef.current.editor.commands.focus() + } } - }, [page.title, titleEditor, contentEditorRef]) + }, [page.title, titleEditor]) return (
    diff --git a/web/components/routes/page/header.tsx b/web/components/routes/page/header.tsx index af1c1b22..93ea9664 100644 --- a/web/components/routes/page/header.tsx +++ b/web/components/routes/page/header.tsx @@ -1,54 +1,58 @@ "use client" import * as React from "react" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" import { LaIcon } from "@/components/custom/la-icon" import { useAccount } from "@/lib/providers/jazz-provider" -import { useRouter } from "next/navigation" -import { PersonalPage } from "@/lib/schema" -import { toast } from "sonner" +import { usePageActions } from "./hooks/use-page-actions" -export const PageHeader = React.memo(() => { +interface PageHeaderProps {} + +export const PageHeader: React.FC = React.memo(() => { const { me } = useAccount() const router = useRouter() + const { newPage } = usePageActions() if (!me) return null - const handleClick = () => { - try { - const newPersonalPage = PersonalPage.create( - { public: false, createdAt: new Date(), updatedAt: new Date() }, - { owner: me._owner } - ) - me.root?.personalPages?.push(newPersonalPage) - router.push(`/pages/${newPersonalPage.id}`) - } catch (error) { - toast.error("Failed to create page") - } + const handleNewPageClick = () => { + const page = newPage(me) + router.push(`/pages/${page.id}`) } return ( - -
    - -
    - Pages -
    -
    - -
    - -
    -
    - -
    -
    + + +
    + ) }) PageHeader.displayName = "PageHeader" + +const HeaderTitle: React.FC = () => ( +
    + +
    + Pages +
    +
    +) + +interface NewPageButtonProps { + onClick: () => void +} + +const NewPageButton: React.FC = ({ onClick }) => ( +
    +
    + +
    +
    +) diff --git a/web/components/routes/page/hooks/use-keyboard-navigation.ts b/web/components/routes/page/hooks/use-keyboard-navigation.ts deleted file mode 100644 index 3878b653..00000000 --- a/web/components/routes/page/hooks/use-keyboard-navigation.ts +++ /dev/null @@ -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> - isCommandPaletteOpen: boolean - disableEnterKey: boolean - onEnter?: (selectedPage: PersonalPage) => void -} - -export const useKeyboardNavigation = ({ - personalPages, - activeItemIndex, - setActiveItemIndex, - isCommandPaletteOpen, - disableEnterKey, - onEnter -}: UseKeyboardNavigationProps) => { - const listRef = useRef(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 } -} diff --git a/web/components/routes/page/hooks/use-page-actions.ts b/web/components/routes/page/hooks/use-page-actions.ts index 986ea3d8..65bc7276 100644 --- a/web/components/routes/page/hooks/use-page-actions.ts +++ b/web/components/routes/page/hooks/use-page-actions.ts @@ -4,6 +4,15 @@ import { LaAccount, PersonalPage } from "@/lib/schema" import { ID } from "jazz-tools" export const usePageActions = () => { + const newPage = useCallback((me: LaAccount): PersonalPage => { + const newPersonalPage = PersonalPage.create( + { public: false, createdAt: new Date(), updatedAt: new Date() }, + { owner: me._owner } + ) + me.root?.personalPages?.push(newPersonalPage) + return newPersonalPage + }, []) + const deletePage = useCallback((me: LaAccount, pageId: ID): void => { if (!me.root?.personalPages) return @@ -32,5 +41,5 @@ export const usePageActions = () => { } }, []) - return { deletePage } + return { newPage, deletePage } } diff --git a/web/components/routes/page/list.tsx b/web/components/routes/page/list.tsx index fb525661..62be86d2 100644 --- a/web/components/routes/page/list.tsx +++ b/web/components/routes/page/list.tsx @@ -1,15 +1,15 @@ -import React, { useMemo, useCallback } from "react" +import React, { useMemo, useCallback, useEffect } from "react" import { Primitive } from "@radix-ui/react-primitive" import { useAccount } from "@/lib/providers/jazz-provider" import { useAtom } from "jotai" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { PageItem } from "./partials/page-item" -import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation" import { useMedia } from "react-use" -import { Column } from "./partials/column" import { useColumnStyles } from "./hooks/use-column-styles" import { PersonalPage, PersonalPageLists } from "@/lib/schema" import { useRouter } from "next/navigation" +import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" +import { Column } from "@/components/custom/column" interface PageListProps { activeItemIndex: number | null @@ -23,6 +23,7 @@ export const PageList: React.FC = ({ activeItemIndex, setActiveIt const { me } = useAccount({ root: { personalPages: [] } }) const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages]) const router = useRouter() + const itemCount = personalPages?.length || 0 const handleEnter = useCallback( (selectedPage: PersonalPage) => { @@ -31,24 +32,35 @@ export const PageList: React.FC = ({ activeItemIndex, setActiveIt [router] ) - const { listRef, setItemRef } = useKeyboardNavigation({ - personalPages, - activeItemIndex, - setActiveItemIndex, - isCommandPaletteOpen, - disableEnterKey, - onEnter: handleEnter - }) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isCommandPaletteOpen) return + + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault() + setActiveItemIndex(prevIndex => { + if (prevIndex === null) return 0 + const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount + return newIndex + }) + } else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) { + e.preventDefault() + const selectedPage = personalPages[activeItemIndex] + if (selectedPage) handleEnter?.(selectedPage) + } + }, + [itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, handleEnter] + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) return ( -
    +
    {!isTablet && } - +
    ) } @@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => { } interface PageListItemsProps { - listRef: React.RefObject - setItemRef: (el: HTMLAnchorElement | null, index: number) => void personalPages?: PersonalPageLists | null activeItemIndex: number | null } -const PageListItems: React.FC = ({ listRef, setItemRef, personalPages, activeItemIndex }) => ( - - {personalPages?.map( - (page, index) => - page?.id && ( - setItemRef(el, index)} - page={page} - isActive={index === activeItemIndex} - /> - ) - )} - -) +const PageListItems: React.FC = ({ personalPages, activeItemIndex }) => { + const setElementRef = useActiveItemScroll({ activeIndex: activeItemIndex }) + + return ( + + {personalPages?.map( + (page, index) => + page?.id && ( + setElementRef(el, index)} + page={page} + isActive={index === activeItemIndex} + /> + ) + )} + + ) +} diff --git a/web/components/routes/page/partials/page-item.tsx b/web/components/routes/page/partials/page-item.tsx index 8b8488b7..002a01e1 100644 --- a/web/components/routes/page/partials/page-item.tsx +++ b/web/components/routes/page/partials/page-item.tsx @@ -3,10 +3,10 @@ import Link from "next/link" import { cn } from "@/lib/utils" import { PersonalPage } from "@/lib/schema" import { Badge } from "@/components/ui/badge" -import { Column } from "./column" import { useMedia } from "react-use" import { useColumnStyles } from "../hooks/use-column-styles" import { format } from "date-fns" +import { Column } from "@/components/custom/column" interface PageItemProps { page: PersonalPage @@ -21,14 +21,10 @@ export const PageItem = React.forwardRef(({ pa @@ -38,14 +34,9 @@ export const PageItem = React.forwardRef(({ pa {!isTablet && ( - <> - {/* - {page.slug} - */} - - {page.topic && {page.topic.prettyName}} - - + + {page.topic && {page.topic.prettyName}} + )} diff --git a/web/components/routes/topics/TopicRoute.tsx b/web/components/routes/topics/TopicRoute.tsx new file mode 100644 index 00000000..b0ce6356 --- /dev/null +++ b/web/components/routes/topics/TopicRoute.tsx @@ -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(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 ( +
    + + +
    + ) +} diff --git a/web/components/routes/topics/detail/TopicDetailRoute.tsx b/web/components/routes/topics/detail/TopicDetailRoute.tsx index 1bd5f42c..310ae004 100644 --- a/web/components/routes/topics/detail/TopicDetailRoute.tsx +++ b/web/components/routes/topics/detail/TopicDetailRoute.tsx @@ -1,12 +1,17 @@ "use client" -import React, { useMemo, useRef } from "react" +import React, { useMemo, useState } from "react" import { TopicDetailHeader } from "./Header" -import { TopicSections } from "./partials/topic-sections" +import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" +import { Topic } from "@/lib/schema" +import { TopicDetailList } from "./list" import { atom } from "jotai" -import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" -import { useTopicData } from "@/hooks/use-topic-data" +import { Skeleton } from "@/components/ui/skeleton" +import { GraphNode } from "../../public/PublicHomeRoute" +import { LaIcon } from "@/components/custom/la-icon" +const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default) interface TopicDetailRouteProps { topicName: string } @@ -14,27 +19,71 @@ interface TopicDetailRouteProps { export const openPopoverForIdAtom = atom(null) export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) { - const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) - const { topic } = useTopicData(topicName, me) - // const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks) - const linksRefDummy = useRef<(HTMLLIElement | null)[]>([]) - const containerRefDummy = useRef(null) + const raw_graph_data = React.use(graph_data_promise) as GraphNode[] - if (!topic || !me) { - return null + const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) + const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } }) + const [activeIndex, setActiveIndex] = useState(-1) + + const topicExists = raw_graph_data.find(node => node.name === topicName) + + if (!topicExists) { + return + } + + 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 } return ( -
    + <> - {}} - linkRefs={linksRefDummy} - containerRef={containerRefDummy} - /> + + + ) +} + +function NotFoundPlaceholder() { + return ( +
    +
    + + Topic not found +
    + There is no topic with the given identifier.
    ) } + +function TopicDetailSkeleton() { + return ( + <> +
    +
    + + +
    + +
    + +
    + {[...Array(10)].map((_, index) => ( +
    + +
    + + +
    +
    + ))} +
    + + ) +} diff --git a/web/components/routes/topics/detail/list.tsx b/web/components/routes/topics/detail/list.tsx new file mode 100644 index 00000000..30a4dcd6 --- /dev/null +++ b/web/components/routes/topics/detail/list.tsx @@ -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(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 ( +
    +
    +

    {item.data?.title}

    +
    +
    +
    + ) + } + + if (item.data?.id) { + return ( + + ) + } + + return null + }, + [items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks] + ) + + return ( +
    +
    +
    + {virtualizer.getVirtualItems().map(renderItem)} +
    +
    +
    + ) +} diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx index 0c99fd01..77964333 100644 --- a/web/components/routes/topics/detail/partials/link-item.tsx +++ b/web/components/routes/topics/detail/partials/link-item.tsx @@ -10,198 +10,199 @@ import { Button } from "@/components/ui/button" import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector" import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils" -import { Link as LinkSchema, PersonalLink, Topic } from "@/lib/schema" +import { Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic } from "@/lib/schema" import { openPopoverForIdAtom } from "../TopicDetailRoute" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { useAccountOrGuest } from "@/lib/providers/jazz-provider" import { useClerk } from "@clerk/nextjs" -interface LinkItemProps { +interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> { topic: Topic link: LinkSchema isActive: boolean index: number setActiveIndex: (index: number) => void + personalLinks?: PersonalLinkLists } export const LinkItem = React.memo( - React.forwardRef(({ topic, link, isActive, index, setActiveIndex }, ref) => { - const clerk = useClerk() - const pathname = usePathname() - const router = useRouter() - const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom) - const [isPopoverOpen, setIsPopoverOpen] = useState(false) + React.forwardRef( + ({ topic, link, isActive, index, setActiveIndex, className, personalLinks, ...props }, ref) => { + const clerk = useClerk() + const pathname = usePathname() + const router = useRouter() + const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const { me } = useAccountOrGuest() - const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const personalLink = useMemo(() => { + return personalLinks?.find(pl => pl?.link?.id === link.id) + }, [personalLinks, link.id]) - const personalLinks = useMemo(() => { - if (!me || me._type === "Anonymous") return undefined - return me?.root?.personalLinks || [] - }, [me]) + const selectedLearningState = useMemo(() => { + return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState) + }, [personalLink?.learningState]) - const personalLink = useMemo(() => { - return personalLinks?.find(pl => pl?.link?.id === link.id) - }, [personalLinks, link.id]) + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + setActiveIndex(index) + }, + [index, setActiveIndex] + ) - const selectedLearningState = useMemo(() => { - return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState) - }, [personalLink?.learningState]) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - setActiveIndex(index) - }, - [index, setActiveIndex] - ) - - const handleSelectLearningState = useCallback( - (learningState: LearningStateValue) => { - if (!personalLinks || !me || me?._type === "Anonymous") { - return clerk.redirectToSignIn({ - redirectUrl: pathname - }) - } - - const defaultToast = { - duration: 5000, - position: "bottom-right" as const, - closeButton: true, - action: { - label: "Go to list", - onClick: () => router.push("/links") + const handleSelectLearningState = useCallback( + (learningState: LearningStateValue) => { + if (!personalLinks || !me || me?._type === "Anonymous") { + return clerk.redirectToSignIn({ + redirectUrl: pathname + }) } - } - if (personalLink) { - if (personalLink.learningState === learningState) { - personalLink.learningState = undefined - toast.error("Link learning state removed", defaultToast) + const defaultToast = { + duration: 5000, + position: "bottom-right" as const, + closeButton: true, + action: { + label: "Go to list", + onClick: () => router.push("/links") + } + } + + if (personalLink) { + if (personalLink.learningState === learningState) { + personalLink.learningState = undefined + toast.error("Link learning state removed", defaultToast) + } else { + personalLink.learningState = learningState + toast.success("Link learning state updated", defaultToast) + } } else { - personalLink.learningState = learningState - toast.success("Link learning state updated", defaultToast) + const slug = generateUniqueSlug(link.title) + const newPersonalLink = PersonalLink.create( + { + url: link.url, + title: link.title, + slug, + link, + learningState, + sequence: personalLinks.length + 1, + completed: false, + topic, + createdAt: new Date(), + updatedAt: new Date() + }, + { owner: me } + ) + + personalLinks.push(newPersonalLink) + + toast.success("Link added.", { + ...defaultToast, + description: `${link.title} has been added to your personal link.` + }) } - } else { - const slug = generateUniqueSlug(link.title) - const newPersonalLink = PersonalLink.create( + + setOpenPopoverForId(null) + setIsPopoverOpen(false) + }, + [personalLink, personalLinks, me, link, router, topic, setOpenPopoverForId, clerk, pathname] + ) + + const handlePopoverOpenChange = useCallback( + (open: boolean) => { + setIsPopoverOpen(open) + setOpenPopoverForId(open ? link.id : null) + }, + [link.id, setOpenPopoverForId] + ) + + return ( +
    { - setIsPopoverOpen(open) - setOpenPopoverForId(open ? link.id : null) - }, - [link.id, setOpenPopoverForId] - ) - - return ( -
  • -
    -
    - - - - - e.preventDefault()} - > - handleSelectLearningState(value as LearningStateValue)} - /> - - - -
    -
    -

    - {link.title} -

    - -
    -
    -
  • - ) - }) + ) + } + ) ) LinkItem.displayName = "LinkItem" diff --git a/web/components/routes/topics/detail/partials/section.tsx b/web/components/routes/topics/detail/partials/section.tsx deleted file mode 100644 index 7f816432..00000000 --- a/web/components/routes/topics/detail/partials/section.tsx +++ /dev/null @@ -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 ( -
    -
    -

    {section.title}

    -
    -
    - -
    - {linksToLoad?.map((link, index) => - link?.url ? ( - { - linkRefs.current[startIndex + index] = el - }} - /> - ) : ( - - ) - )} - {section.links?.length && section.links?.length > nLinksToLoad && ( - setNLinksToLoad(n => n + 10)} /> - )} -
    -
    - ) -} - -const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => { - const spinnerRef = useRef(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 ( -
    - -
    - ) -} diff --git a/web/components/routes/topics/detail/partials/topic-sections.tsx b/web/components/routes/topics/detail/partials/topic-sections.tsx deleted file mode 100644 index a8b6ed5f..00000000 --- a/web/components/routes/topics/detail/partials/topic-sections.tsx +++ /dev/null @@ -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 -} - -export function TopicSections({ - topic, - sections, - activeIndex, - setActiveIndex, - linkRefs, - containerRef, -}: TopicSectionsProps) { - return ( -
    -
    -
    - {sections?.map( - (section, sectionIndex) => - section?.id && ( -
    acc + (s?.links?.length || 0), 0)} - linkRefs={linkRefs} - /> - ) - )} -
    -
    -
    - ) -} diff --git a/web/components/routes/topics/detail/use-link-navigation.ts b/web/components/routes/topics/detail/use-link-navigation.ts deleted file mode 100644 index 5e72054b..00000000 --- a/web/components/routes/topics/detail/use-link-navigation.ts +++ /dev/null @@ -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(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 } -} diff --git a/web/components/routes/topics/header.tsx b/web/components/routes/topics/header.tsx new file mode 100644 index 00000000..9b949313 --- /dev/null +++ b/web/components/routes/topics/header.tsx @@ -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 = React.memo(() => { + const { me } = useAccount() + + if (!me) return null + + return ( + + +
    + + ) +}) + +TopicHeader.displayName = "TopicHeader" + +const HeaderTitle: React.FC = () => ( +
    + +
    + Topics +
    +
    +) diff --git a/web/components/routes/topics/hooks/use-column-styles.ts b/web/components/routes/topics/hooks/use-column-styles.ts new file mode 100644 index 00000000..7cecc98b --- /dev/null +++ b/web/components/routes/topics/hooks/use-column-styles.ts @@ -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" } + } +} diff --git a/web/components/routes/topics/list.tsx b/web/components/routes/topics/list.tsx new file mode 100644 index 00000000..dc96124b --- /dev/null +++ b/web/components/routes/topics/list.tsx @@ -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> + 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(null) + +export const TopicList: React.FC = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => { + const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } }) + + if (!me) return null + + return ( + + ) +} + +export const MainTopicList: React.FC = ({ + 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 ( +
    + {!isTablet && } + +
    + ) +} + +export const ColumnHeader: React.FC = () => { + const columnStyles = useColumnStyles() + + return ( +
    + + Name + + + State + +
    + ) +} + +interface TopicListItemsProps { + personalTopics: PersonalTopic[] | null + activeItemIndex: number | null +} + +const TopicListItems: React.FC = ({ personalTopics, activeItemIndex }) => { + const setElementRef = useActiveItemScroll({ activeIndex: activeItemIndex }) + + return ( + + {personalTopics?.map( + (pt, index) => + pt.topic?.id && ( + setElementRef(el, index)} + topic={pt.topic} + learningState={pt.learningState} + isActive={index === activeItemIndex} + /> + ) + )} + + ) +} diff --git a/web/components/routes/topics/partials/topic-item.tsx b/web/components/routes/topics/partials/topic-item.tsx new file mode 100644 index 00000000..ffef13d8 --- /dev/null +++ b/web/components/routes/topics/partials/topic-item.tsx @@ -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(({ 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 = { + 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 ( +
    + + + {topic.prettyName} + + + + setOpenPopoverForId(open ? topic.id : null)} + > + + + + e.stopPropagation()} + onCloseAutoFocus={e => e.preventDefault()} + > + + + + + +
    + ) +}) + +TopicItem.displayName = "TopicItem" diff --git a/web/components/ui/sheet.tsx b/web/components/ui/sheet.tsx new file mode 100644 index 00000000..3b5c5dc7 --- /dev/null +++ b/web/components/ui/sheet.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, 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, + VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>( + ({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + + ) +) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
    +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription +} diff --git a/web/hooks/use-active-item-scroll.ts b/web/hooks/use-active-item-scroll.ts new file mode 100644 index 00000000..81f01fee --- /dev/null +++ b/web/hooks/use-active-item-scroll.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, useCallback } from "react" + +type ElementRef = T | null +type ElementRefs = ElementRef[] + +interface ActiveItemScrollOptions { + activeIndex: number | null +} + +export function useActiveItemScroll(options: ActiveItemScrollOptions) { + const { activeIndex } = options + const elementRefs = useRef>([]) + + 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, index: number) => { + elementRefs.current[index] = element + }, []) + + return setElementRef +} diff --git a/web/hooks/use-keyboard-manager.ts b/web/hooks/use-keyboard-manager.ts new file mode 100644 index 00000000..f73d0994 --- /dev/null +++ b/web/hooks/use-keyboard-manager.ts @@ -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 } +} diff --git a/web/hooks/use-keydown-listener.ts b/web/hooks/use-keydown-listener.ts new file mode 100644 index 00000000..888f140e --- /dev/null +++ b/web/hooks/use-keydown-listener.ts @@ -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]) +} diff --git a/web/hooks/use-topic-data.ts b/web/hooks/use-topic-data.ts deleted file mode 100644 index 8280762b..00000000 --- a/web/hooks/use-topic-data.ts +++ /dev/null @@ -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 - -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 } -} diff --git a/web/lib/utils/keyboard.ts b/web/lib/utils/keyboard.ts index faaaf7ce..50255db7 100644 --- a/web/lib/utils/keyboard.ts +++ b/web/lib/utils/keyboard.ts @@ -55,11 +55,7 @@ export function getShortcutKey(key: string): ShortcutKeyResult { } else if (lowercaseKey === "alt") { return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" } } else if (lowercaseKey === "shift") { - return { symbol: "⇧", readable: "Shift" } - } else if (lowercaseKey === "control") { - return { symbol: "⌃", readable: "Control" } - } else if (lowercaseKey === "windows" && !isMacOS()) { - return { symbol: "Win", readable: "Windows" } + return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" } } else { return { symbol: key.toUpperCase(), readable: key } } @@ -68,21 +64,3 @@ export function getShortcutKey(key: string): ShortcutKeyResult { export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { return keys.map(key => getShortcutKey(key)) } - -export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] { - if (shortcutName === "expandToolbar") { - return isMacOS() - ? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")] - : [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")] - } - - return [] -} - -export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string { - return shortcutKeys.map(key => key.symbol).join("") -} - -export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string { - return shortcutKeys.map(key => key.readable).join(" + ") -} diff --git a/web/package.json b/web/package.json index 278aa010..67037938 100644 --- a/web/package.json +++ b/web/package.json @@ -9,11 +9,11 @@ "test": "jest" }, "dependencies": { - "@clerk/nextjs": "^5.4.1", + "@clerk/nextjs": "^5.6.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@hookform/resolvers": "^3.9.0", - "@nothing-but/force-graph": "^0.9.4", + "@nothing-but/force-graph": "^0.9.5", "@nothing-but/utils": "^0.16.0", "@omit/react-confirm-dialog": "^1.1.5", "@omit/react-fancy-switch": "^0.1.3", @@ -37,45 +37,45 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@sentry/nextjs": "^8.30.0", - "@tanstack/react-virtual": "^3.10.7", - "@tiptap/core": "^2.6.6", - "@tiptap/extension-blockquote": "^2.6.6", - "@tiptap/extension-bold": "^2.6.6", - "@tiptap/extension-bullet-list": "^2.6.6", - "@tiptap/extension-code": "^2.6.6", - "@tiptap/extension-code-block-lowlight": "^2.6.6", - "@tiptap/extension-color": "^2.6.6", - "@tiptap/extension-document": "^2.6.6", - "@tiptap/extension-dropcursor": "^2.6.6", - "@tiptap/extension-focus": "^2.6.6", - "@tiptap/extension-gapcursor": "^2.6.6", - "@tiptap/extension-hard-break": "^2.6.6", - "@tiptap/extension-heading": "^2.6.6", - "@tiptap/extension-history": "^2.6.6", - "@tiptap/extension-horizontal-rule": "^2.6.6", - "@tiptap/extension-image": "^2.6.6", - "@tiptap/extension-italic": "^2.6.6", - "@tiptap/extension-link": "^2.6.6", - "@tiptap/extension-list-item": "^2.6.6", - "@tiptap/extension-ordered-list": "^2.6.6", - "@tiptap/extension-paragraph": "^2.6.6", - "@tiptap/extension-placeholder": "^2.6.6", - "@tiptap/extension-strike": "^2.6.6", - "@tiptap/extension-task-item": "^2.6.6", - "@tiptap/extension-task-list": "^2.6.6", - "@tiptap/extension-text": "^2.6.6", - "@tiptap/extension-typography": "^2.6.6", - "@tiptap/pm": "^2.6.6", - "@tiptap/react": "^2.6.6", - "@tiptap/starter-kit": "^2.6.6", - "@tiptap/suggestion": "^2.6.6", + "@tanstack/react-virtual": "^3.10.8", + "@tiptap/core": "^2.7.2", + "@tiptap/extension-blockquote": "^2.7.2", + "@tiptap/extension-bold": "^2.7.2", + "@tiptap/extension-bullet-list": "^2.7.2", + "@tiptap/extension-code": "^2.7.2", + "@tiptap/extension-code-block-lowlight": "^2.7.2", + "@tiptap/extension-color": "^2.7.2", + "@tiptap/extension-document": "^2.7.2", + "@tiptap/extension-dropcursor": "^2.7.2", + "@tiptap/extension-focus": "^2.7.2", + "@tiptap/extension-gapcursor": "^2.7.2", + "@tiptap/extension-hard-break": "^2.7.2", + "@tiptap/extension-heading": "^2.7.2", + "@tiptap/extension-history": "^2.7.2", + "@tiptap/extension-horizontal-rule": "^2.7.2", + "@tiptap/extension-image": "^2.7.2", + "@tiptap/extension-italic": "^2.7.2", + "@tiptap/extension-link": "^2.7.2", + "@tiptap/extension-list-item": "^2.7.2", + "@tiptap/extension-ordered-list": "^2.7.2", + "@tiptap/extension-paragraph": "^2.7.2", + "@tiptap/extension-placeholder": "^2.7.2", + "@tiptap/extension-strike": "^2.7.2", + "@tiptap/extension-task-item": "^2.7.2", + "@tiptap/extension-task-list": "^2.7.2", + "@tiptap/extension-text": "^2.7.2", + "@tiptap/extension-typography": "^2.7.2", + "@tiptap/pm": "^2.7.2", + "@tiptap/react": "^2.7.2", + "@tiptap/starter-kit": "^2.7.2", + "@tiptap/suggestion": "^2.7.2", "axios": "^1.7.7", "cheerio": "1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", - "framer-motion": "^11.5.4", + "framer-motion": "^11.5.5", "geist": "^1.3.1", "jazz-browser-auth-clerk": "0.7.35-guest-auth.5", "jazz-react": "0.7.35-guest-auth.5", @@ -84,7 +84,7 @@ "jotai": "^2.9.3", "lowlight": "^3.1.0", "lucide-react": "^0.429.0", - "next": "14.2.5", + "next": "14.2.10", "next-themes": "^0.3.0", "nuqs": "^1.19.1", "react": "^18.3.1", @@ -99,27 +99,27 @@ "streaming-markdown": "^0.0.14", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.2", + "vaul": "^0.9.4", "zod": "^3.23.8", "zsa": "^0.6.0", "zsa-react": "^0.2.2" }, "devDependencies": { - "@ronin/learn-anything": "^0.0.0-3451954511456", + "@ronin/learn-anything": "^0.0.0-3452357373461", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", - "@types/jest": "^29.5.12", - "@types/node": "^22.5.4", - "@types/react": "^18.3.5", + "@types/jest": "^29.5.13", + "@types/node": "^22.5.5", + "@types/react": "^18.3.7", "@types/react-dom": "^18.3.0", "dotenv": "^16.4.5", - "eslint": "^8.57.0", + "eslint": "^8.57.1", "eslint-config-next": "14.2.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "postcss": "^8.4.45", + "postcss": "^8.4.47", "prettier-plugin-tailwindcss": "^0.6.6", - "tailwindcss": "^3.4.10", + "tailwindcss": "^3.4.12", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.6.2" diff --git a/web/store/keydown-manager.ts b/web/store/keydown-manager.ts new file mode 100644 index 00000000..d6617dc1 --- /dev/null +++ b/web/store/keydown-manager.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai" + +export const keyboardDisableSourcesAtom = atom>(new Set())