diff --git a/web/app/(pages)/page.tsx b/web/app/(pages)/page.tsx index 5b0fdb1d..daa5ee79 100644 --- a/web/app/(pages)/page.tsx +++ b/web/app/(pages)/page.tsx @@ -1,5 +1,5 @@ -import AuthHomeRoute from "@/components/routes/AuthHomeRoute" +import { LinkRoute } from "@/components/routes/link/LinkRoute" export default function HomePage() { - return + return } diff --git a/web/app/(pages)/pages/[id]/page.tsx b/web/app/(pages)/pages/[id]/page.tsx index a235ccb3..974b86c1 100644 --- a/web/app/(pages)/pages/[id]/page.tsx +++ b/web/app/(pages)/pages/[id]/page.tsx @@ -1,5 +1,5 @@ -import { DetailPageWrapper } from "@/components/routes/page/detail/wrapper" +import { PageDetailRoute } from "@/components/routes/page/detail/PageDetailRoute" export default function DetailPage({ params }: { params: { id: string } }) { - return + return } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 9b6d88b5..065719cf 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next" import { Inter as FontSans } from "next/font/google" -import { Inter } from "next/font/google" import { cn } from "@/lib/utils" import { ThemeProvider } from "@/lib/providers/theme-provider" import "./globals.css" @@ -32,7 +31,7 @@ export default function RootLayout({ {children} - + diff --git a/web/components/LinkOptions.tsx b/web/components/LinkOptions.tsx deleted file mode 100644 index 51f12518..00000000 --- a/web/components/LinkOptions.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { LaIcon } from "@/components/custom/la-icon" - -export default function LinkOptions() { - const buttonClass = - "block w-full flex flex-row items-center px-4 py-2 rounded-lg text-left text-sm hover:bg-gray-700/20" - - return ( -
-
- - - -
-
- ) -} diff --git a/web/components/custom/learning-state-selector.tsx b/web/components/custom/learning-state-selector.tsx new file mode 100644 index 00000000..c1b2e4a9 --- /dev/null +++ b/web/components/custom/learning-state-selector.tsx @@ -0,0 +1,107 @@ +import React, { useMemo } from "react" +import { useAtom } from "jotai" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" +import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" +import { linkLearningStateSelectorAtom } from "@/store/link" +import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface LearningStateSelectorProps { + showSearch?: boolean + defaultLabel?: string + searchPlaceholder?: string + value?: string + onChange: (value: LearningStateValue) => void + className?: string +} + +export const LearningStateSelector: React.FC = ({ + showSearch = true, + defaultLabel = "Select state", + searchPlaceholder = "Search state...", + value, + onChange, + className +}) => { + const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) + const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value]) + + const handleSelect = (selectedValue: string) => { + onChange(selectedValue as LearningStateValue) + setIsLearningStateSelectorOpen(false) + } + + return ( + + + + + e.preventDefault()} + > + + + + ) +} + +interface LearningStateSelectorContentProps { + showSearch: boolean + searchPlaceholder: string + value?: string + onSelect: (value: string) => void +} + +export const LearningStateSelectorContent: React.FC = ({ + showSearch, + searchPlaceholder, + value, + onSelect +}) => { + return ( + + {showSearch && } + + + + {LEARNING_STATES.map(ls => ( + + + {ls.label} + + + ))} + + + + + ) +} diff --git a/web/components/custom/sidebar/partial/page-section.tsx b/web/components/custom/sidebar/partial/page-section.tsx index e9762231..97dd1185 100644 --- a/web/components/custom/sidebar/partial/page-section.tsx +++ b/web/components/custom/sidebar/partial/page-section.tsx @@ -1,212 +1,253 @@ -import { z } from "zod" +import React from "react" import { useAtom } from "jotai" -import { useState } from "react" -import { useForm } from "react-hook-form" import { usePathname, useRouter } from "next/navigation" import { useAccount } from "@/lib/providers/jazz-provider" -import { cn, generateUniqueSlug } from "@/lib/utils" +import { cn } from "@/lib/utils" import { atomWithStorage } from "jotai/utils" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form" import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page" -import { zodResolver } from "@hookform/resolvers/zod" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { LaIcon } from "../../la-icon" +import { LaIcon } from "@/components/custom/la-icon" import { toast } from "sonner" import Link from "next/link" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { icons } from "lucide-react" -const pageSortAtom = atomWithStorage("pageSort", "title") -const createPageSchema = z.object({ - title: z.string({ message: "Please enter a valid title" }).min(1, { message: "Please enter a valid title" }) -}) +type SortOption = "title" | "recent" +type ShowOption = 5 | 10 | 15 | 20 | 0 -type PageFormValues = z.infer +interface Option { + label: string + value: T +} + +const SORTS: Option[] = [ + { label: "Title", value: "title" }, + { label: "Last edited", value: "recent" } +] + +const SHOWS: Option[] = [ + { label: "5 items", value: 5 }, + { label: "10 items", value: 10 }, + { label: "15 items", value: 15 }, + { label: "20 items", value: 20 }, + { label: "All", value: 0 } +] + +const pageSortAtom = atomWithStorage("pageSort", "title") +const pageShowAtom = atomWithStorage("pageShow", 5) export const PageSection: React.FC = () => { - const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom) - - const { me } = useAccount({ - root: { personalPages: [] } - }) - + const { me } = useAccount({ root: { personalPages: [] } }) const pageCount = me?.root.personalPages?.length || 0 - const sortedPages = (filter: string) => { - setPagesSorted(filter) - } - return ( -
-
- -
- - -
-
- - {me?.root.personalPages && } +
+ + {me?.root.personalPages && }
) } -const PageList: React.FC<{ personalPages: PersonalPageLists; sortBy: string }> = ({ personalPages, sortBy }) => { - const pathname = usePathname() +interface PageSectionHeaderProps { + pageCount: number +} - const sortedPages = [...personalPages] - .sort((a, b) => { - if (sortBy === "title") { - return (a?.title || "").localeCompare(b?.title || "") - } else if (sortBy === "latest") { - return ((b as any)?.createdAt?.getTime?.() ?? 0) - ((a as any)?.createdAt?.getTime?.() ?? 0) - } - return 0 - }) - .slice(0, 6) - - return ( -
- {sortedPages.map( - page => - page?.id && ( -
-
- -
- -

{page.title}

-
- -
-
- ) - )} +const PageSectionHeader: React.FC = ({ pageCount }) => ( +
+ +
+ +
- ) -} +
+) -interface ShowAllFormProps { - filteredPages: (filter: string) => void -} -const ShowAllForm: React.FC = ({ filteredPages }) => { - const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom) - - const handleSort = (newSort: string) => { - setPagesSorted(newSort.toLowerCase()) - filteredPages(newSort.toLowerCase()) - } - - return ( - - - - - - handleSort("title")}> - Title - {pagesSorted === "title" && } - - handleSort("manual")}> - Manual - {pagesSorted === "manual" && } - - - - ) -} - -const CreatePageForm: React.FC = () => { - const [open, setOpen] = useState(false) +const NewPageButton: React.FC = () => { const { me } = useAccount() - const route = useRouter() + const router = useRouter() - const form = useForm({ - resolver: zodResolver(createPageSchema), - defaultValues: { - title: "" - } - }) - - const onSubmit = (values: PageFormValues) => { + const handleClick = () => { try { - const personalPages = me?.root?.personalPages?.toJSON() || [] - const slug = generateUniqueSlug(personalPages, values.title) - const newPersonalPage = PersonalPage.create( - { - title: values.title, - slug: slug, - content: "" - }, + { public: false, createdAt: new Date(), updatedAt: new Date() }, { owner: me._owner } ) - me.root?.personalPages?.push(newPersonalPage) - - form.reset() - setOpen(false) - - route.push(`/pages/${newPersonalPage.id}`) + router.push(`/pages/${newPersonalPage.id}`) } catch (error) { - console.error(error) toast.error("Failed to create page") } } return ( - - - - - -
- - ( - - New page - - - - - - )} - /> - - - - -
-
+ ) } + +interface PageListProps { + personalPages: PersonalPageLists +} + +const PageList: React.FC = ({ personalPages }) => { + const pathname = usePathname() + + const [sortCriteria] = useAtom(pageSortAtom) + const [showCount] = useAtom(pageShowAtom) + + const sortedPages = [...personalPages] + .sort((a, b) => { + switch (sortCriteria) { + case "title": + return (a?.title ?? "").localeCompare(b?.title ?? "") + case "recent": + return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0) + default: + return 0 + } + }) + .slice(0, showCount === 0 ? personalPages.length : showCount) + + return ( +
+ {sortedPages.map( + page => page?.id && + )} +
+ ) +} + +interface PageListItemProps { + page: PersonalPage + isActive: boolean +} + +const PageListItem: React.FC = ({ page, isActive }) => ( +
+
+ +
+ +

{page.title || "Untitled"}

+
+ +
+
+) + +interface SubMenuProps { + icon: keyof typeof icons + label: string + options: Option[] + currentValue: T + onSelect: (value: T) => void +} + +const SubMenu = ({ icon, label, options, currentValue, onSelect }: SubMenuProps) => ( + + + + + {label} + + + + {options.find(option => option.value === currentValue)?.label} + + + + + + + {options.map(option => ( + onSelect(option.value)}> + {option.label} + {currentValue === option.value && } + + ))} + + + +) + +const ShowAllForm: React.FC = () => { + const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom) + const [pagesShow, setPagesShow] = useAtom(pageShowAtom) + + return ( + + + + + + + + icon="ArrowUpDown" + label="Sort" + options={SORTS} + currentValue={pagesSorted} + onSelect={setPagesSorted} + /> + + icon="Hash" + label="Show" + options={SHOWS} + currentValue={pagesShow} + onSelect={setPagesShow} + /> + + + + ) +} + +export default PageSection diff --git a/web/components/custom/sidebar/partial/topic-section.tsx b/web/components/custom/sidebar/partial/topic-section.tsx index 3e35de07..b6bd04d1 100644 --- a/web/components/custom/sidebar/partial/topic-section.tsx +++ b/web/components/custom/sidebar/partial/topic-section.tsx @@ -1,66 +1,136 @@ -import { useState, useRef } from "react" +import React from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { useAccount } from "@/lib/providers/jazz-provider" +import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { LaIcon } from "@/components/custom/la-icon" -import { SidebarItem } from "../sidebar" +import { ListOfTopics } from "@/lib/schema" +import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" -// const TOPICS = ["Nix", "Javascript", "Kubernetes", "Figma", "Hiring", "Java", "IOS", "Design"] - -export const TopicSection = () => { - const [selectedStatus, setSelectedStatus] = useState(null) - const sectionRef = useRef(null) - - const learningOptions = [ - { - text: "To Learn", - icon: , - color: "text-black dark:text-white" - }, - { - text: "Learning", - icon: , - color: "text-[#D29752]" - }, - { text: "Learned", icon: , color: "text-[#708F51]" } - ] - - const statusSelect = (status: string) => { - setSelectedStatus(prevStatus => (prevStatus === status ? null : status)) - } - - const topicCounts = { - "To Learn": 2, - Learning: 5, - Learned: 3, - get total() { - return this["To Learn"] + this.Learning + this.Learned +export const TopicSection: React.FC = () => { + const { me } = useAccount({ + root: { + topicsWantToLearn: [], + topicsLearning: [], + topicsLearned: [] } - } + }) + + const topicCount = + (me?.root.topicsWantToLearn?.length || 0) + + (me?.root.topicsLearning?.length || 0) + + (me?.root.topicsLearned?.length || 0) + + if (!me) return null return ( -
-
- Topics {topicCounts.total} - -
-
- {learningOptions.map(option => ( - - ))} +
+ + +
+ ) +} + +interface TopicSectionHeaderProps { + topicCount: number +} + +const TopicSectionHeader: React.FC = ({ topicCount }) => ( +
+ +
+) + +interface ListProps { + topicsWantToLearn: ListOfTopics + topicsLearning: ListOfTopics + topicsLearned: ListOfTopics +} + +const List: React.FC = ({ topicsWantToLearn, topicsLearning, topicsLearned }) => { + const pathname = usePathname() + + return ( +
+ + + +
+ ) +} + +interface ListItemProps { + label: string + value: LearningStateValue + href: string + count: number + isActive: boolean +} + +const ListItem: React.FC = ({ label, value, href, count, isActive }) => { + const le = LEARNING_STATES.find(l => l.value === value) + + if (!le) return null + + return ( +
+
+ +
+ +

{label}

+
+ + + {count > 0 && ( + {count} + )}
) } + +export default TopicSection diff --git a/web/components/custom/sidebar/sidebar.tsx b/web/components/custom/sidebar/sidebar.tsx index 22336d93..bfccd464 100644 --- a/web/components/custom/sidebar/sidebar.tsx +++ b/web/components/custom/sidebar/sidebar.tsx @@ -47,7 +47,7 @@ interface SidebarItemProps { children?: React.ReactNode } -export const SidebarItem: React.FC = React.memo(({ label, url, icon, onClick, children }) => { +const SidebarItem: React.FC = React.memo(({ label, url, icon, onClick, children }) => { const pathname = usePathname() const isActive = pathname === url @@ -70,6 +70,8 @@ export const SidebarItem: React.FC = React.memo(({ label, url, ) }) +SidebarItem.displayName = "SidebarItem" + const LogoAndSearch: React.FC = React.memo(() => { const pathname = usePathname() return ( @@ -103,6 +105,8 @@ const LogoAndSearch: React.FC = React.memo(() => { ) }) +LogoAndSearch.displayName = "LogoAndSearch" + const SidebarContent: React.FC = React.memo(() => { return ( <> @@ -121,7 +125,9 @@ const SidebarContent: React.FC = React.memo(() => { ) }) -export const Sidebar: React.FC = () => { +SidebarContent.displayName = "SidebarContent" + +const Sidebar: React.FC = () => { const isTablet = useMedia("(max-width: 1024px)") const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet) @@ -175,4 +181,6 @@ export const Sidebar: React.FC = () => { ) } -export default Sidebar +Sidebar.displayName = "Sidebar" + +export { Sidebar, SidebarItem, SidebarContext } diff --git a/web/components/la-editor/styles/partials/placeholder.css b/web/components/la-editor/styles/partials/placeholder.css index 30fb78b8..e3448388 100644 --- a/web/components/la-editor/styles/partials/placeholder.css +++ b/web/components/la-editor/styles/partials/placeholder.css @@ -2,7 +2,7 @@ @apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)]; } -.la-editor .ProseMirror.ProseMirror-focused > p.has-focus.is-empty::before { +.la-editor:not(.no-command) .ProseMirror.ProseMirror-focused > p.has-focus.is-empty::before { content: "Type / for commands..."; } diff --git a/web/components/routes/AuthHomeRoute.tsx b/web/components/routes/AuthHomeRoute.tsx deleted file mode 100644 index 2a27c1b5..00000000 --- a/web/components/routes/AuthHomeRoute.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client" - -import { LinkHeader } from "@/components/routes/link/header" -import { LinkList } from "@/components/routes/link/list" -import { LinkManage } from "@/components/routes/link/form/manage" -import { useAtom } from "jotai" -import { linkEditIdAtom } from "@/store/link" - -export default function AuthHomeRoute() { - const [editId] = useAtom(linkEditIdAtom) - - return ( -
- - - -
- ) -} diff --git a/web/components/routes/force-graph.tsx b/web/components/routes/force-graph.tsx index 5944450e..1e53efd8 100644 --- a/web/components/routes/force-graph.tsx +++ b/web/components/routes/force-graph.tsx @@ -12,7 +12,7 @@ export default function ForceGraph() { const graph = useMemo(() => { return globalGroup?.root.topicGraph?.map( - topic => + (topic: { name: string; prettyName: string; connectedTopics: Array<{ name?: string }> }) => ({ name: topic.name, prettyName: topic.prettyName, diff --git a/web/components/routes/link/AuthHomeRoute.tsx b/web/components/routes/link/AuthHomeRoute.tsx deleted file mode 100644 index dca1a1bd..00000000 --- a/web/components/routes/link/AuthHomeRoute.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client" - -import { LinkHeader } from "@/components/routes/link/header" -import { LinkList } from "@/components/routes/link/list" -import { LinkManage } from "@/components/routes/link/form/manage" -import { useAtom } from "jotai" -import { linkEditIdAtom } from "@/store/link" - -export function AuthHomeRoute() { - const [editId] = useAtom(linkEditIdAtom) - - return ( -
- - - -
- ) -} diff --git a/web/components/routes/link/LinkRoute.tsx b/web/components/routes/link/LinkRoute.tsx new file mode 100644 index 00000000..1505b45d --- /dev/null +++ b/web/components/routes/link/LinkRoute.tsx @@ -0,0 +1,28 @@ +"use client" + +import { LinkHeader } from "@/components/routes/link/header" +import { LinkList } from "@/components/routes/link/list" +import { LinkManage } from "@/components/routes/link/manage" +import { useQueryState } from "nuqs" +import { useEffect } from "react" +import { useAtom } from "jotai" +import { linkEditIdAtom } from "@/store/link" +import { LinkBottomBar } from "./bottom-bar" + +export function LinkRoute() { + const [, setEditId] = useAtom(linkEditIdAtom) + const [nuqsEditId] = useQueryState("editId") + + useEffect(() => { + setEditId(nuqsEditId) + }, [nuqsEditId, setEditId]) + + return ( +
+ + + + +
+ ) +} diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx new file mode 100644 index 00000000..b5f8bbca --- /dev/null +++ b/web/components/routes/link/bottom-bar.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useRef } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { icons } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" +import { useAtom } from "jotai" +import { linkShowCreateAtom } from "@/store/link" +import { useQueryState } from "nuqs" +import { useConfirm } from "@omit/react-confirm-dialog" +import { useAccount, useCoState } from "@/lib/providers/jazz-provider" +import { PersonalLink } from "@/lib/schema" +import { ID } from "jazz-tools" +import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form" +import { toast } from "sonner" + +interface ToolbarButtonProps { + icon: keyof typeof icons + onClick?: (e: React.MouseEvent) => void + tooltip?: string +} + +const ToolbarButton = React.forwardRef(({ icon, onClick, tooltip }, ref) => { + const button = ( + + ) + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ) + } + + return button +}) + +ToolbarButton.displayName = "ToolbarButton" + +export const LinkBottomBar: React.FC = () => { + const [editId, setEditId] = useQueryState("editId") + const [, setGlobalLinkFormExceptionRefsAtom] = useAtom(globalLinkFormExceptionRefsAtom) + const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom) + + const { me } = useAccount({ root: { personalLinks: [] } }) + const personalLink = useCoState(PersonalLink, editId as ID) + + const cancelBtnRef = useRef(null) + const confirmBtnRef = useRef(null) + + const deleteBtnRef = useRef(null) + const editMoreBtnRef = useRef(null) + const plusBtnRef = useRef(null) + const plusMoreBtnRef = useRef(null) + + const confirm = useConfirm() + + useEffect(() => { + setGlobalLinkFormExceptionRefsAtom([ + deleteBtnRef, + editMoreBtnRef, + cancelBtnRef, + confirmBtnRef, + plusBtnRef, + plusMoreBtnRef + ]) + }, [setGlobalLinkFormExceptionRefsAtom]) + + const handleDelete = async (e: React.MouseEvent) => { + if (!personalLink) return + + const result = await confirm({ + title: `Delete "${personalLink.title}"?`, + description: "This action cannot be undone.", + alertDialogTitle: { + className: "text-base" + }, + customActions(onConfirm, onCancel) { + return ( +
+ + +
+ ) + } + }) + + if (result) { + if (!me?.root.personalLinks) return + + const index = me.root.personalLinks.findIndex(item => item?.id === personalLink.id) + if (index === -1) { + console.error("Delete operation fail", { index, personalLink }) + return + } + + toast.success("Link deleted.", { + position: "bottom-right", + description: ( + + {personalLink.title} has been deleted. + + ) + }) + + me.root.personalLinks.splice(index, 1) + setEditId(null) + } + } + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isMacOS()) { + if (event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n") { + event.preventDefault() + setShowCreate(true) + } + } else { + // For Windows, we'll use Ctrl + Win + N + // Note: The Windows key is not directly detectable in most browsers + if (event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)) { + event.preventDefault() + setShowCreate(true) + } + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [setShowCreate]) + + const shortcutKeys = getSpecialShortcut("expandToolbar") + const shortcutText = formatShortcut(shortcutKeys) + + return ( + + + {editId && ( + + setEditId(null)} /> + + + + )} + + {!editId && ( + + {showCreate && setShowCreate(true)} />} + {!showCreate && ( + setShowCreate(true)} + tooltip={`New Link (${shortcutText})`} + ref={plusBtnRef} + /> + )} + + + )} + + + ) +} + +LinkBottomBar.displayName = "LinkBottomBar" + +export default LinkBottomBar diff --git a/web/components/routes/link/form/index.ts b/web/components/routes/link/form/index.ts deleted file mode 100644 index eb598794..00000000 --- a/web/components/routes/link/form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./manage" diff --git a/web/components/routes/link/form/manage.tsx b/web/components/routes/link/form/manage.tsx deleted file mode 100644 index 9baca58e..00000000 --- a/web/components/routes/link/form/manage.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { linkEditIdAtom, linkShowCreateAtom } from "@/store/link" -import { useAtom } from "jotai" -import React, { useEffect, useRef, useState } from "react" -import { useKey } from "react-use" -import { globalLinkFormExceptionRefsAtom, LinkForm } from "./link-form" -import { LaIcon } from "@/components/custom/la-icon" -import LinkOptions from "@/components/LinkOptions" -// import { FloatingButton } from "./partial/floating-button" - -const LinkManage: React.FC = () => { - const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom) - const [editId, setEditId] = useAtom(linkEditIdAtom) - const [, setGlobalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom) - - const [showOptions, setShowOptions] = useState(false) - - const optionsRef = useRef(null) - const buttonRef = useRef(null) - - const toggleForm = (event: React.MouseEvent) => { - event.stopPropagation() - if (showCreate) return - setShowCreate(prev => !prev) - } - - const clickOptionsButton = (e: React.MouseEvent) => { - e.preventDefault() - setShowOptions(prev => !prev) - } - - const handleFormClose = () => { - setShowCreate(false) - } - - const handleFormFail = () => {} - - // wipes the data from the form when the form is closed - React.useEffect(() => { - if (!showCreate) { - setEditId(null) - } - }, [showCreate, setEditId]) - - useKey("Escape", handleFormClose) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (optionsRef.current && !optionsRef.current.contains(event.target as Node)) { - setShowOptions(false) - } - } - - if (showOptions) { - document.addEventListener("mousedown", handleClickOutside) - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, [showOptions]) - - /* - * This code means that when link form is opened, these refs will be added as an exception to the click outside handler - */ - React.useEffect(() => { - setGlobalExceptionRefs([optionsRef, buttonRef]) - }, [setGlobalExceptionRefs]) - - return ( - <> - {showCreate && } -
-
- -
- {showOptions && } - -
-
-
- - ) -} - -LinkManage.displayName = "LinkManage" - -export { LinkManage } - -/* */ diff --git a/web/components/routes/link/form/partial/floating-button.tsx b/web/components/routes/link/form/partial/floating-button.tsx deleted file mode 100644 index deca89d4..00000000 --- a/web/components/routes/link/form/partial/floating-button.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { forwardRef } from "react" -import { cn } from "@/lib/utils" -import { LaIcon } from "@/components/custom/la-icon" -import { Button, ButtonProps } from "@/components/ui/button" - -interface FloatingButtonProps extends ButtonProps { - isOpen: boolean -} - -export const FloatingButton = forwardRef( - ({ isOpen, className, ...props }, ref) => ( - - ) -) - -FloatingButton.displayName = "FloatingButton" - -export default FloatingButton diff --git a/web/components/routes/link/form/partial/learning-state-selector.tsx b/web/components/routes/link/form/partial/learning-state-selector.tsx deleted file mode 100644 index 4c0945b0..00000000 --- a/web/components/routes/link/form/partial/learning-state-selector.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Button } from "@/components/ui/button" -import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" -import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { ScrollArea } from "@/components/ui/scroll-area" -import { useFormContext } from "react-hook-form" -import { cn } from "@/lib/utils" -import { LaIcon } from "@/components/custom/la-icon" -import { useAtom } from "jotai" -import { linkLearningStateSelectorAtom } from "@/store/link" -import { useMemo } from "react" -import { LinkFormValues } from "../schema" -import { LEARNING_STATES } from "@/lib/constants" - -export const LearningStateSelector: React.FC = () => { - const [islearningStateSelectorOpen, setIslearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) - const form = useFormContext() - - const selectedLearningState = useMemo( - () => LEARNING_STATES.find(ls => ls.value === form.getValues("learningState")), - [form] - ) - - return ( - ( - - Topic - - - - - - - e.preventDefault()} - > - - - - - - {LEARNING_STATES.map(ls => ( - { - field.onChange(value) - setIslearningStateSelectorOpen(false) - }} - > - - {ls.label} - - - ))} - - - - - - - - )} - /> - ) -} diff --git a/web/components/routes/link/header.tsx b/web/components/routes/link/header.tsx index 91095dd0..717c61a7 100644 --- a/web/components/routes/link/header.tsx +++ b/web/components/routes/link/header.tsx @@ -88,6 +88,8 @@ const LearningTab = React.memo(() => { ) }) +LearningTab.displayName = "LearningTab" + const FilterAndSort = React.memo(() => { const [sort, setSort] = useAtom(linkSortAtom) const [sortOpen, setSortOpen] = React.useState(false) diff --git a/web/components/routes/link/list-item.tsx b/web/components/routes/link/list-item.tsx deleted file mode 100644 index a58bea36..00000000 --- a/web/components/routes/link/list-item.tsx +++ /dev/null @@ -1,255 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { PersonalLink } from "@/lib/schema/personal-link" -import { cn } from "@/lib/utils" -import { useSortable } from "@dnd-kit/sortable" -import { CSS } from "@dnd-kit/utilities" -import { ConfirmOptions } from "@omit/react-confirm-dialog" -import { LinkIcon, Trash2Icon } from "lucide-react" -import Image from "next/image" -import Link from "next/link" -import * as React from "react" -import { LinkForm } from "./form/link-form" -import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { ScrollArea } from "@/components/ui/scroll-area" -import { LaIcon } from "@/components/custom/la-icon" -import { LEARNING_STATES } from "@/lib/constants" -import { Badge } from "@/components/ui/badge" - -interface ListItemProps { - confirm: (options: ConfirmOptions) => Promise - personalLink: PersonalLink - disabled?: boolean - isEditing: boolean - setEditId: (id: string | null) => void - isDragging: boolean - isFocused: boolean - setFocusedId: (id: string | null) => void - registerRef: (id: string, ref: HTMLLIElement | null) => void - onDelete?: (personalLink: PersonalLink) => void - showDeleteIconForLinkId: string | null - setShowDeleteIconForLinkId: (id: string | null) => void -} - -export const ListItem: React.FC = ({ - confirm, - isEditing, - setEditId, - personalLink, - disabled = false, - isDragging, - isFocused, - setFocusedId, - registerRef, - onDelete, - showDeleteIconForLinkId, - setShowDeleteIconForLinkId -}) => { - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - pointerEvents: isDragging ? "none" : "auto" - } - - const refCallback = React.useCallback( - (node: HTMLLIElement | null) => { - setNodeRef(node) - registerRef(personalLink.id, node) - }, - [setNodeRef, registerRef, personalLink.id] - ) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - setEditId(personalLink.id) - } - } - - const handleSuccess = () => { - setEditId(null) - } - - const handleOnClose = () => { - setEditId(null) - } - - const handleOnFail = () => {} - - // const handleRowClick = () => { - // setShowDeleteIconForLinkId(personalLink.id) - // } - - const handleRowDoubleClick = () => { - setEditId(personalLink.id) - } - - const handleDelete = async (e: React.MouseEvent, personalLink: PersonalLink) => { - e.stopPropagation() - - const result = await confirm({ - title: `Delete "${personalLink.title}"?`, - description: "This action cannot be undone.", - alertDialogTitle: { - className: "text-base" - }, - customActions: (onConfirm, onCancel) => ( - <> - - - - ) - }) - - if (result) { - onDelete?.(personalLink) - } - } - - const selectedLearningState = LEARNING_STATES.find(ls => ls.value === personalLink.learningState) - - if (isEditing) { - return ( - - ) - } - - return ( -
  • setFocusedId(personalLink.id)} - onBlur={() => { - setFocusedId(null) - }} - onKeyDown={handleKeyDown} - className={cn("hover:bg-muted/50 relative flex h-14 cursor-default items-center outline-none xl:h-11", { - "bg-muted/50": isFocused - })} - // onClick={handleRowClick} - onDoubleClick={handleRowDoubleClick} - > -
    -
    - {/* e.stopPropagation()} - onCheckedChange={() => { - personalLink.completed = !personalLink.completed - }} - className="border-muted-foreground border" - /> */} - - - - - e.preventDefault()} - > - - - - - - {LEARNING_STATES.map(ls => ( - { - personalLink.learningState = value as "wantToLearn" | "learning" | "learned" | undefined - }} - > - - {ls.label} - - - ))} - - - - - - - {personalLink.icon && ( - {personalLink.title} - )} -
    -
    -

    - {personalLink.title} -

    - {personalLink.url && ( -
    -
    - )} -
    -
    -
    - -
    - {personalLink.topic && {personalLink.topic.prettyName}} - {showDeleteIconForLinkId === personalLink.id && ( - - )} -
    -
    -
  • - ) -} diff --git a/web/components/routes/link/list.tsx b/web/components/routes/link/list.tsx index 8f586ea6..0173a8cb 100644 --- a/web/components/routes/link/list.tsx +++ b/web/components/routes/link/list.tsx @@ -7,45 +7,54 @@ import { PointerSensor, useSensor, useSensors, - DragEndEvent + DragEndEvent, + DragStartEvent, + UniqueIdentifier } from "@dnd-kit/core" import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable" import { useAccount } from "@/lib/providers/jazz-provider" import { PersonalLinkLists } from "@/lib/schema/personal-link" -import { PersonalLink } from "@/lib/schema/personal-link" import { useAtom } from "jotai" -import { linkEditIdAtom, linkSortAtom } from "@/store/link" +import { linkSortAtom } from "@/store/link" import { useKey } from "react-use" -import { useConfirm } from "@omit/react-confirm-dialog" -import { ListItem } from "./list-item" -import { useRef, useState, useCallback, useEffect } from "react" +import { LinkItem } from "./partials/link-item" +import { useRef, useState, useCallback, useEffect, useMemo } from "react" import { learningStateAtom } from "./header" +import { useQueryState } from "nuqs" -const LinkList = () => { +interface LinkListProps {} + +const LinkList: React.FC = () => { + const [editId, setEditId] = useQueryState("editId") const [activeLearningState] = useAtom(learningStateAtom) - const confirm = useConfirm() + const { me } = useAccount({ root: { personalLinks: [] } }) - const personalLinks = me?.root?.personalLinks || [] + const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks]) - const [editId, setEditId] = useAtom(linkEditIdAtom) const [sort] = useAtom(linkSortAtom) const [focusedId, setFocusedId] = useState(null) - const [draggingId, setDraggingId] = useState(null) + const [draggingId, setDraggingId] = useState(null) const linkRefs = useRef<{ [key: string]: HTMLLIElement | null }>({}) - const [showDeleteIconForLinkId, setShowDeleteIconForLinkId] = useState(null) - let filteredLinks = personalLinks.filter(link => { - if (activeLearningState === "all") return true - if (!link?.learningState) return false - return link.learningState === activeLearningState - }) - let sortedLinks = - sort === "title" && filteredLinks - ? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || "")) - : filteredLinks - sortedLinks = sortedLinks || [] + const filteredLinks = useMemo( + () => + personalLinks.filter(link => { + if (activeLearningState === "all") return true + if (!link?.learningState) return false + return link.learningState === activeLearningState + }), + [personalLinks, activeLearningState] + ) + + const sortedLinks = useMemo( + () => + sort === "title" + ? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || "")) + : filteredLinks, + [filteredLinks, sort] + ) const sensors = useSensors( useSensor(PointerSensor, { @@ -68,6 +77,14 @@ const LinkList = () => { } }) + const updateSequences = useCallback((links: PersonalLinkLists) => { + links.forEach((link, index) => { + if (link) { + link.sequence = index + } + }) + }, []) + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return @@ -120,21 +137,16 @@ const LinkList = () => { window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort]) + }, [me?.root?.personalLinks, sortedLinks, focusedId, editId, sort, updateSequences]) - const updateSequences = (links: PersonalLinkLists) => { - links.forEach((link, index) => { - if (link) { - link.sequence = index - } - }) - } - - const handleDragStart = (event: any) => { - if (sort !== "manual") return - const { active } = event - setDraggingId(active.id) - } + const handleDragStart = useCallback( + (event: DragStartEvent) => { + if (sort !== "manual") return + const { active } = event + setDraggingId(active.id) + }, + [sort] + ) const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event @@ -181,20 +193,8 @@ const LinkList = () => { setDraggingId(null) } - const handleDelete = (linkItem: PersonalLink) => { - if (!me?.root?.personalLinks) return - - const index = me.root.personalLinks.findIndex(item => item?.id === linkItem.id) - if (index === -1) { - console.error("Delete operation fail", { index, linkItem }) - return - } - - me.root.personalLinks.splice(index, 1) - } - return ( -
    +
    { {sortedLinks.map( linkItem => linkItem && ( - { isDragging={draggingId === linkItem.id} isFocused={focusedId === linkItem.id} setFocusedId={setFocusedId} - onDelete={handleDelete} - showDeleteIconForLinkId={showDeleteIconForLinkId} - setShowDeleteIconForLinkId={setShowDeleteIconForLinkId} /> ) )} diff --git a/web/components/routes/link/manage.tsx b/web/components/routes/link/manage.tsx new file mode 100644 index 00000000..7c15da06 --- /dev/null +++ b/web/components/routes/link/manage.tsx @@ -0,0 +1,38 @@ +"use client" + +import React from "react" +import { linkShowCreateAtom } from "@/store/link" +import { useAtom } from "jotai" +import { useKey } from "react-use" +import { LinkForm } from "./partials/form/link-form" +import { motion, AnimatePresence } from "framer-motion" + +interface LinkManageProps {} + +const LinkManage: React.FC = () => { + const [showCreate, setShowCreate] = useAtom(linkShowCreateAtom) + + const handleFormClose = () => setShowCreate(false) + const handleFormFail = () => {} + + useKey("Escape", handleFormClose) + + return ( + + {showCreate && ( + + + + )} + + ) +} + +LinkManage.displayName = "LinkManage" + +export { LinkManage } diff --git a/web/components/routes/link/form/partial/description-input.tsx b/web/components/routes/link/partials/form/description-input.tsx similarity index 95% rename from web/components/routes/link/form/partial/description-input.tsx rename to web/components/routes/link/partials/form/description-input.tsx index d38c9aac..1ac7887d 100644 --- a/web/components/routes/link/form/partial/description-input.tsx +++ b/web/components/routes/link/partials/form/description-input.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useFormContext } from "react-hook-form" import { FormField, FormItem, FormControl, FormLabel } from "@/components/ui/form" import { TextareaAutosize } from "@/components/custom/textarea-autosize" -import { LinkFormValues } from "../schema" +import { LinkFormValues } from "./schema" interface DescriptionInputProps {} diff --git a/web/components/routes/link/form/link-form.tsx b/web/components/routes/link/partials/form/link-form.tsx similarity index 85% rename from web/components/routes/link/form/link-form.tsx rename to web/components/routes/link/partials/form/link-form.tsx index f6739692..2233cde6 100644 --- a/web/components/routes/link/form/link-form.tsx +++ b/web/components/routes/link/partials/form/link-form.tsx @@ -8,17 +8,19 @@ import { createLinkSchema, LinkFormValues } from "./schema" import { cn, generateUniqueSlug } from "@/lib/utils" import { Form } from "@/components/ui/form" import { Button } from "@/components/ui/button" -import { UrlInput } from "./partial/url-input" -import { UrlBadge } from "./partial/url-badge" -import { TitleInput } from "./partial/title-input" -import { NotesSection } from "./partial/notes-section" -import { TopicSelector } from "./partial/topic-selector" -import { DescriptionInput } from "./partial/description-input" -import { LearningStateSelector } from "./partial/learning-state-selector" +import { UrlInput } from "./url-input" +import { UrlBadge } from "./url-badge" +import { TitleInput } from "./title-input" +import { NotesSection } from "./notes-section" +import { TopicSelector } from "./topic-selector" +import { DescriptionInput } from "./description-input" import { atom, useAtom } from "jotai" import { linkLearningStateSelectorAtom, linkTopicSelectorAtom } from "@/store/link" +import { FormField, FormItem, FormLabel } from "@/components/ui/form" +import { LearningStateSelector } from "@/components/custom/learning-state-selector" export const globalLinkFormExceptionRefsAtom = atom[]>([]) + interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> { onClose?: () => void onSuccess?: () => void @@ -34,7 +36,7 @@ const defaultValues: Partial = { description: "", completed: false, notes: "", - learningState: "wantToLearn", + learningState: undefined, topic: null } @@ -45,7 +47,7 @@ export const LinkForm: React.FC = ({ onClose, exceptionsRefs = [] }) => { - const [selectedTopic, setSelectedTopic] = React.useState(null) + const [selectedTopic, setSelectedTopic] = React.useState() const [istopicSelectorOpen] = useAtom(linkTopicSelectorAtom) const [islearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) const [globalExceptionRefs] = useAtom(globalLinkFormExceptionRefsAtom) @@ -134,12 +136,19 @@ export const LinkForm: React.FC = ({ const onSubmit = (values: LinkFormValues) => { if (isFetching) return + try { const personalLinks = me.root?.personalLinks?.toJSON() || [] const slug = generateUniqueSlug(personalLinks, values.title) if (selectedLink) { - selectedLink.applyDiff({ ...values, slug, topic: selectedTopic }) + const { topic, ...diffValues } = values + + if (!selectedTopic) { + selectedLink.applyDiff({ ...diffValues, slug, updatedAt: new Date() }) + } else { + selectedLink.applyDiff({ ...values, slug, topic: selectedTopic }) + } } else { const newPersonalLink = PersonalLink.create( { @@ -188,7 +197,23 @@ export const LinkForm: React.FC = ({ {urlFetched && }
    - + ( + + Topic + { + // toggle, if already selected set undefined + form.setValue("learningState", field.value === value ? undefined : value) + }} + showSearch={false} + /> + + )} + /> setSelectedTopic(topic)} />
    diff --git a/web/components/routes/link/form/partial/notes-section.tsx b/web/components/routes/link/partials/form/notes-section.tsx similarity index 93% rename from web/components/routes/link/form/partial/notes-section.tsx rename to web/components/routes/link/partials/form/notes-section.tsx index 4f0033c5..5b9da4b9 100644 --- a/web/components/routes/link/form/partial/notes-section.tsx +++ b/web/components/routes/link/partials/form/notes-section.tsx @@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form" import { Input } from "@/components/ui/input" import { cn } from "@/lib/utils" import { LaIcon } from "@/components/custom/la-icon" -import { LinkFormValues } from "../schema" +import { LinkFormValues } from "./schema" export const NotesSection: React.FC = () => { const form = useFormContext() @@ -18,7 +18,7 @@ export const NotesSection: React.FC = () => { <>
    -
    void + isDragging: boolean + isFocused: boolean + setFocusedId: (id: string | null) => void + registerRef: (id: string, ref: HTMLLIElement | null) => void +} + +export const LinkItem: React.FC = ({ + isEditing, + setEditId, + personalLink, + disabled = false, + isDragging, + isFocused, + setFocusedId, + registerRef +}) => { + const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) + + const style = useMemo( + () => ({ + transform: CSS.Transform.toString(transform), + transition, + pointerEvents: isDragging ? "none" : "auto" + }), + [transform, transition, isDragging] + ) + + const refCallback = useCallback( + (node: HTMLLIElement | null) => { + setNodeRef(node) + registerRef(personalLink.id, node) + }, + [setNodeRef, registerRef, personalLink.id] + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + 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 handleLearningStateSelect = useCallback( + (value: string) => { + const learningState = value as LearningStateValue + personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState + setOpenPopoverForId(null) + }, + [personalLink, setOpenPopoverForId] + ) + + if (isEditing) { + return {}} /> + } + + return ( +
  • setFocusedId(personalLink.id)} + onBlur={() => setFocusedId(null)} + onKeyDown={handleKeyDown} + className={cn("relative flex h-14 cursor-default items-center outline-none xl:h-11", { + "bg-muted-foreground/10": isFocused, + "hover:bg-muted/50": !isFocused + })} + onDoubleClick={handleRowDoubleClick} + > +
    +
    + setOpenPopoverForId(open ? personalLink.id : null)} + > + + + + e.preventDefault()} + > + + + + + {personalLink.icon && ( + {personalLink.title} + )} +
    +
    +

    + {personalLink.title} +

    + {personalLink.url && ( +
    +
    + )} +
    +
    +
    + +
    + {personalLink.topic && {personalLink.topic.prettyName}} +
    +
    +
  • + ) +} diff --git a/web/components/routes/page/detail/wrapper.tsx b/web/components/routes/page/detail/PageDetailRoute.tsx similarity index 75% rename from web/components/routes/page/detail/wrapper.tsx rename to web/components/routes/page/detail/PageDetailRoute.tsx index a1ed0c01..48ffb5a9 100644 --- a/web/components/routes/page/detail/wrapper.tsx +++ b/web/components/routes/page/detail/PageDetailRoute.tsx @@ -10,21 +10,19 @@ import { Content, EditorContent, useEditor } from "@tiptap/react" import { StarterKit } from "@/components/la-editor/extensions/starter-kit" import { Paragraph } from "@/components/la-editor/extensions/paragraph" import { useAccount, useCoState } from "@/lib/providers/jazz-provider" -import { toast } from "sonner" import { EditorView } from "@tiptap/pm/view" import { Editor } from "@tiptap/core" import { generateUniqueSlug } from "@/lib/utils" import { Button } from "@/components/ui/button" import { LaIcon } from "@/components/custom/la-icon" import { pageTopicSelectorAtom } from "@/store/page" -import { TopicSelector } from "@/components/routes/link/form/partial/topic-selector" +import { TopicSelector } from "@/components/routes/link/partials/form/topic-selector" +import { FocusClasses } from "@tiptap/extension-focus" import DeletePageModal from "@/components/custom/delete-modal" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -const TITLE_PLACEHOLDER = "Page title" +const TITLE_PLACEHOLDER = "Untitled" -export function DetailPageWrapper({ pageId }: { pageId: string }) { +export function PageDetailRoute({ pageId }: { pageId: string }) { const page = useCoState(PersonalPage, pageId as ID) if (!page) return
    Loading...
    @@ -45,32 +43,52 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { const titleEditorRef = useRef(null) const contentEditorRef = useRef(null) const [, setTopicSelectorOpen] = useAtom(pageTopicSelectorAtom) - const [selectedPageTopic, setSelectedPageTopic] = useState(page.topic || null) + const [, setSelectedPageTopic] = useState(page.topic || null) const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const isTitleInitialMount = useRef(true) + const isContentInitialMount = useRef(true) + const updatePageContent = (content: Content, model: PersonalPage) => { - model.content = content - } - - const handleTitleBlur = (editor: Editor) => { - const newTitle = editor.getText().trim() - - if (!newTitle) { - toast.error("Update failed", { - description: "Title must be longer than or equal to 1 character" - }) - editor.commands.setContent(page.title) + if (isContentInitialMount.current) { + isContentInitialMount.current = false return } - if (newTitle === page.title) return + console.log("Updating page content") + model.content = content + model.updatedAt = new Date() + } + const handleUpdateTitle = (editor: Editor) => { + if (isTitleInitialMount.current) { + isTitleInitialMount.current = false + return + } + + /* + * The logic changed, but we keep this commented code for reference + */ + // const newTitle = editor.getText().trim() + + // if (!newTitle) { + // toast.error("Update failed", { + // description: "Title must be longer than or equal to 1 character" + // }) + // editor.commands.setContent(page.title || "") + // return + // } + + // if (newTitle === page.title) return + + console.log("Updating page title") const personalPages = me.root?.personalPages?.toJSON() || [] - const slug = generateUniqueSlug(personalPages, page.slug) + const slug = generateUniqueSlug(personalPages, page.slug || "") const trimmedTitle = editor.getText().trim() page.title = trimmedTitle page.slug = slug + page.updatedAt = new Date() editor.commands.setContent(trimmedTitle) } @@ -108,7 +126,9 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { const titleEditor = useEditor({ immediatelyRender: false, + autofocus: true, extensions: [ + FocusClasses, Paragraph, StarterKit.configure({ bold: false, @@ -137,10 +157,12 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { handleKeyDown: handleTitleKeyDown }, onCreate: ({ editor }) => { - const capitalizedTitle = page.title.charAt(0).toUpperCase() + page.title.slice(1) - editor.commands.setContent(`

    ${capitalizedTitle}

    `) + if (page.title) editor.commands.setContent(`

    ${page.title}

    `) }, - onBlur: ({ editor }) => handleTitleBlur(editor) + onBlur: ({ editor }) => handleUpdateTitle(editor), + onUpdate: ({ editor }) => { + handleUpdateTitle(editor) + } }) useEffect(() => { @@ -149,6 +171,11 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => { } }, [titleEditor]) + useEffect(() => { + isTitleInitialMount.current = true + isContentInitialMount.current = true + }, []) + return (
    @@ -156,7 +183,7 @@ export const DetailPageForm = ({ page }: { page: PersonalPage }) => {
    { onConfirm={() => { confirmDelete(page) }} - title={page.title.charAt(0).toUpperCase() + page.title.slice(1)} + title={page.title || ""} />
    ) diff --git a/web/components/routes/page/detail/header.tsx b/web/components/routes/page/detail/header.tsx index d54bc0df..55e1e20e 100644 --- a/web/components/routes/page/detail/header.tsx +++ b/web/components/routes/page/detail/header.tsx @@ -2,20 +2,11 @@ import * as React from "react" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@/components/ui/breadcrumb" -import { useCoState } from "@/lib/providers/jazz-provider" +import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@/components/ui/breadcrumb" import { PersonalPage } from "@/lib/schema/personal-page" import { ID } from "jazz-tools" export const DetailPageHeader = ({ pageId }: { pageId: ID }) => { - const page = useCoState(PersonalPage, pageId) - return (
    diff --git a/web/components/routes/topics/detail/Header.tsx b/web/components/routes/topics/detail/Header.tsx index 8a72ac2d..0b9a9834 100644 --- a/web/components/routes/topics/detail/Header.tsx +++ b/web/components/routes/topics/detail/Header.tsx @@ -1,32 +1,98 @@ "use client" import * as React from "react" -import { Button } from "@/components/ui/button" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" -import { Topic } from "@/lib/schema" +import { ListOfTopics, Topic } from "@/lib/schema" +import { LearningStateSelector } from "@/components/custom/learning-state-selector" +import { useAccount } from "@/lib/providers/jazz-provider" +import { LearningStateValue } from "@/lib/constants" interface TopicDetailHeaderProps { topic: Topic } export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) { + 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 handleAddToProfile = (learningState: 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 (learningState === p.learningState) { + removeFromList(p.learningState, p.index) + return + } + removeFromList(p.learningState, p.index) + } + + topicLists[learningState]?.push(topic) + } + return ( - <> - -
    - -
    - {topic.prettyName} -
    + +
    + +
    + {topic.prettyName}
    +
    -
    +
    - -
    - + + ) }) diff --git a/web/components/routes/topics/detail/TopicDetailRoute.tsx b/web/components/routes/topics/detail/TopicDetailRoute.tsx index c4d9c331..6843b7c5 100644 --- a/web/components/routes/topics/detail/TopicDetailRoute.tsx +++ b/web/components/routes/topics/detail/TopicDetailRoute.tsx @@ -1,108 +1,41 @@ "use client" import React from "react" -import Link from "next/link" -import { useCoState } from "@/lib/providers/jazz-provider" -import { PublicGlobalGroup } from "@/lib/schema/master/public-group" -import { ID } from "jazz-tools" import { TopicDetailHeader } from "./Header" -import { LaIcon } from "@/components/custom/la-icon" -import { cn, ensureUrlProtocol } from "@/lib/utils" -import { Section as SectionSchema, Link as LinkSchema } from "@/lib/schema" +import { TopicSections } from "./partials/topic-sections" +import { useLinkNavigation } from "./use-link-navigation" +import { useTopicData } from "@/hooks/use-topic-data" +import { atom } from "jotai" +import { useAccount } from "@/lib/providers/jazz-provider" interface TopicDetailRouteProps { topicName: string } +export const openPopoverForIdAtom = atom(null) + export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) { - const topics = useCoState(PublicGlobalGroup, process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID, { - root: { - topics: [] - } - }) + const { me } = useAccount({ root: { personalLinks: [] } }) + const { topic, allLinks } = useTopicData(topicName) + const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks) - const topic = topics?.root.topics.find(topic => topic?.name === topicName) - - if (!topic) { + if (!topic || !me) { return null } return (
    -
    -
    -
    - {topic.latestGlobalGuide?.sections?.map( - (section, index) => section?.id &&
    - )} -
    -
    -
    +
    ) } - -interface SectionProps { - section: SectionSchema -} - -function Section({ section }: SectionProps) { - return ( -
    -
    -

    {section.title}

    -
    -
    - -
    - {section.links?.map((link, index) => link?.url && )} -
    -
    - ) -} - -interface LinkItemProps { - link: LinkSchema -} - -function LinkItem({ link }: LinkItemProps) { - return ( -
  • -
    -
    - -
    -
    -

    - {link.title} -

    - -
    -
    -
    -
    -
    -
    -
    -
  • - ) -} diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx new file mode 100644 index 00000000..7e89d818 --- /dev/null +++ b/web/components/routes/topics/detail/partials/link-item.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useMemo, useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useAtom } from "jotai" +import { toast } from "sonner" + +import { LaIcon } from "@/components/custom/la-icon" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Button } from "@/components/ui/button" +import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector" + +import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils" +import { LaAccount, Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema" +import { openPopoverForIdAtom } from "../TopicDetailRoute" +import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" + +interface LinkItemProps { + topic: Topic + link: LinkSchema + isActive: boolean + index: number + setActiveIndex: (index: number) => void + me: { + root: { + personalLinks: PersonalLinkLists + } & UserRoot + } & LaAccount + personalLinks: PersonalLinkLists +} + +export const LinkItem = React.memo( + React.forwardRef( + ({ topic, link, isActive, index, setActiveIndex, me, personalLinks }, ref) => { + const router = useRouter() + const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + const personalLink = useMemo(() => { + return personalLinks.find(pl => pl?.link?.id === link.id) + }, [personalLinks, link.id]) + + 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) => { + const defaultToast = { + duration: 5000, + position: "bottom-right" as const, + closeButton: true, + action: { + label: "Go to list", + onClick: () => router.push("/") + } + } + + 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 { + const slug = generateUniqueSlug(personalLinks.toJSON(), 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.` + }) + } + + setOpenPopoverForId(null) + setIsPopoverOpen(false) + }, + [personalLink, personalLinks, me, link, router, setOpenPopoverForId] + ) + + const handlePopoverOpenChange = useCallback( + (open: boolean) => { + 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 new file mode 100644 index 00000000..4a711d84 --- /dev/null +++ b/web/components/routes/topics/detail/partials/section.tsx @@ -0,0 +1,59 @@ +import React from "react" +import { LinkItem } from "./link-item" +import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema" + +interface SectionProps { + topic: Topic + section: SectionSchema + activeIndex: number + startIndex: number + linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]> + setActiveIndex: (index: number) => void + me: { + root: { + personalLinks: PersonalLinkLists + } & UserRoot + } & LaAccount + personalLinks: PersonalLinkLists +} + +export function Section({ + topic, + section, + activeIndex, + setActiveIndex, + startIndex, + linkRefs, + me, + personalLinks +}: SectionProps) { + return ( +
    +
    +

    {section.title}

    +
    +
    + +
    + {section.links?.map( + (link, index) => + link?.url && ( + { + linkRefs.current[startIndex + index] = el + }} + me={me} + personalLinks={personalLinks} + /> + ) + )} +
    +
    + ) +} diff --git a/web/components/routes/topics/detail/partials/topic-sections.tsx b/web/components/routes/topics/detail/partials/topic-sections.tsx new file mode 100644 index 00000000..b4c13dc7 --- /dev/null +++ b/web/components/routes/topics/detail/partials/topic-sections.tsx @@ -0,0 +1,54 @@ +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 + me: { + root: { + personalLinks: PersonalLinkLists + } & UserRoot + } & LaAccount + personalLinks: PersonalLinkLists +} + +export function TopicSections({ + topic, + sections, + activeIndex, + setActiveIndex, + linkRefs, + containerRef, + me, + personalLinks +}: TopicSectionsProps) { + return ( +
    +
    +
    + {sections?.map( + (section, sectionIndex) => + section?.id && ( +
    acc + (s?.links?.length || 0), 0)} + linkRefs={linkRefs} + me={me} + personalLinks={personalLinks} + /> + ) + )} +
    +
    +
    + ) +} diff --git a/web/components/routes/topics/detail/use-link-navigation.ts b/web/components/routes/topics/detail/use-link-navigation.ts new file mode 100644 index 00000000..5726626a --- /dev/null +++ b/web/components/routes/topics/detail/use-link-navigation.ts @@ -0,0 +1,61 @@ +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) => { + console.log("handleKeyDown") + if (e.key === "ArrowDown") { + e.preventDefault() + setActiveIndex(prevIndex => { + const newIndex = (prevIndex + 1) % allLinks.length + scrollToLink(newIndex) + return newIndex + }) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setActiveIndex(prevIndex => { + const newIndex = (prevIndex - 1 + allLinks.length) % allLinks.length + scrollToLink(newIndex) + return newIndex + }) + } else if (e.key === "Enter" && activeIndex !== -1) { + const link = allLinks[activeIndex] + if (link) { + window.open(ensureUrlProtocol(link.url), "_blank") + } + } + }, + [activeIndex, allLinks, scrollToLink] + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) + + return { activeIndex, setActiveIndex, containerRef, linkRefs } +} diff --git a/web/components/ui/dropdown-menu.tsx b/web/components/ui/dropdown-menu.tsx index a62791af..348480ab 100644 --- a/web/components/ui/dropdown-menu.tsx +++ b/web/components/ui/dropdown-menu.tsx @@ -34,7 +34,6 @@ const DropdownMenuSubTrigger = React.forwardRef< {...props} > {children} - )) DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName diff --git a/web/hooks/use-topic-data.ts b/web/hooks/use-topic-data.ts new file mode 100644 index 00000000..80b8efd9 --- /dev/null +++ b/web/hooks/use-topic-data.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react" +import { useCoState } from "@/lib/providers/jazz-provider" +import { PublicGlobalGroup } from "@/lib/schema/master/public-group" +import { ID } from "jazz-tools" +import { Link } from "@/lib/schema" + +const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID + +export function useTopicData(topicName: string) { + const group = useCoState(PublicGlobalGroup, GLOBAL_GROUP_ID, { + root: { topics: [] } + }) + + // const topic = useCoState(Topic, "co_zS3TH4Lkj5MK9GEehinxhjjNTxB" as ID, {}) + const topic = useMemo( + () => group?.root.topics.find(topic => topic?.name === topicName), + [group?.root.topics, topicName] + ) + + const allLinks = useMemo(() => { + if (!topic?.latestGlobalGuide?.sections) return [] + + return topic.latestGlobalGuide.sections.flatMap( + section => section?.links?.filter((link): link is Link => !!link?.url) ?? [] + ) + }, [topic?.latestGlobalGuide?.sections]) + + return { topic, allLinks } +} diff --git a/web/lib/constants.ts b/web/lib/constants.ts index a08d0631..3b024258 100644 --- a/web/lib/constants.ts +++ b/web/lib/constants.ts @@ -3,7 +3,7 @@ import { icons } from "lucide-react" export type LearningStateValue = "wantToLearn" | "learning" | "learned" export type LearningState = { label: string - value: string + value: LearningStateValue icon: keyof typeof icons className: string } diff --git a/web/lib/schema/index.ts b/web/lib/schema/index.ts index fa5d333b..0528647f 100644 --- a/web/lib/schema/index.ts +++ b/web/lib/schema/index.ts @@ -24,7 +24,6 @@ export class UserRoot extends CoMap { personalLinks = co.ref(PersonalLinkLists) personalPages = co.ref(PersonalPageLists) - // not implemented yet topicsWantToLearn = co.ref(ListOfTopics) topicsLearning = co.ref(ListOfTopics) topicsLearned = co.ref(ListOfTopics) @@ -53,7 +52,6 @@ export class LaAccount extends Account { personalLinks: PersonalLinkLists.create([], { owner: this }), personalPages: PersonalPageLists.create([], { owner: this }), - // not implemented yet topicsWantToLearn: ListOfTopics.create([], { owner: this }), topicsLearning: ListOfTopics.create([], { owner: this }), topicsLearned: ListOfTopics.create([], { owner: this }) diff --git a/web/lib/schema/master/topic.ts b/web/lib/schema/master/topic.ts index 63cb4112..a39e351b 100644 --- a/web/lib/schema/master/topic.ts +++ b/web/lib/schema/master/topic.ts @@ -1,5 +1,6 @@ import { co, CoList, CoMap } from "jazz-tools" +// TODO: this should be GlobalLink but it's not because lookup of 100k elements is slow export class Link extends CoMap { title = co.string url = co.string diff --git a/web/lib/schema/personal-link.ts b/web/lib/schema/personal-link.ts index dea1e220..a71def64 100644 --- a/web/lib/schema/personal-link.ts +++ b/web/lib/schema/personal-link.ts @@ -1,5 +1,5 @@ import { co, CoList, CoMap, Encoders, ID } from "jazz-tools" -import { Topic } from "./master/topic" +import { Link, Topic } from "./master/topic" class BaseModel extends CoMap { createdAt = co.encoded(Encoders.Date) @@ -9,6 +9,7 @@ class BaseModel extends CoMap { export class PersonalLink extends BaseModel { url = co.string icon = co.optional.string // is an icon URL + link = co.optional.ref(Link) title = co.string slug = co.string description = co.optional.string diff --git a/web/lib/schema/personal-page.ts b/web/lib/schema/personal-page.ts index fae2108a..6db76862 100644 --- a/web/lib/schema/personal-page.ts +++ b/web/lib/schema/personal-page.ts @@ -1,4 +1,4 @@ -import { co, CoList, CoMap } from "jazz-tools" +import { co, CoList, CoMap, Encoders } from "jazz-tools" import { Topic } from "./master/topic" /* @@ -8,10 +8,13 @@ import { Topic } from "./master/topic" * - if public, certain members (can do read/write access accordingly), personal (end to end encrypted, only accessed by user) */ export class PersonalPage extends CoMap { - title = co.string - slug = co.string + title = co.optional.string + slug = co.optional.string // is used only when `public: true` for sharing, `@user/page-slug` + public = co.boolean content = co.optional.json() topic = co.optional.ref(Topic) + createdAt = co.encoded(Encoders.Date) + updatedAt = co.encoded(Encoders.Date) // backlinks = co.optional.ref() // other PersonalPages linking to this page TODO: add, think through how to do it well, efficiently } diff --git a/web/lib/utils/index.ts b/web/lib/utils/index.ts index f77bb88d..742f4e90 100644 --- a/web/lib/utils/index.ts +++ b/web/lib/utils/index.ts @@ -11,3 +11,4 @@ export const randomId = () => { export * from "./urls" export * from "./slug" +export * from "./keyboard" diff --git a/web/lib/utils/keyboard.ts b/web/lib/utils/keyboard.ts new file mode 100644 index 00000000..faaaf7ce --- /dev/null +++ b/web/lib/utils/keyboard.ts @@ -0,0 +1,88 @@ +let isMac: boolean | undefined + +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 { symbol: "⇧", readable: "Shift" } + } else if (lowercaseKey === "control") { + return { symbol: "⌃", readable: "Control" } + } else if (lowercaseKey === "windows" && !isMacOS()) { + return { symbol: "Win", readable: "Windows" } + } else { + return { symbol: key.toUpperCase(), readable: key } + } +} + +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 28a9456b..4b9982ef 100644 --- a/web/package.json +++ b/web/package.json @@ -95,7 +95,7 @@ "@types/node": "^22.5.0", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", - "eslint": "^9.9.1", + "eslint": "^8.57.0", "eslint-config-next": "14.2.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/web/store/link.ts b/web/store/link.ts index 3368710b..566cfd28 100644 --- a/web/store/link.ts +++ b/web/store/link.ts @@ -3,6 +3,7 @@ import { atomWithStorage } from "jotai/utils" export const linkSortAtom = atomWithStorage("sort", "manual") export const linkShowCreateAtom = atom(false) -export const linkEditIdAtom = atom(null) +export const linkEditIdAtom = atom(null) export const linkLearningStateSelectorAtom = atom(false) export const linkTopicSelectorAtom = atom(false) +export const linkOpenPopoverForIdAtom = atom(null)