diff --git a/scripts/past-seed.ts b/scripts/past-seed.ts index 07ec79e5..d821546e 100644 --- a/scripts/past-seed.ts +++ b/scripts/past-seed.ts @@ -27,11 +27,11 @@ async function devSeed() { const pageOneTitle = "Physics" const pageTwoTitle = "Karabiner" const page1 = PersonalPage.create( - { title: pageOneTitle, slug: generateUniqueSlug([], pageOneTitle), content: "Physics is great" }, + { title: pageOneTitle, slug: generateUniqueSlug(pageOneTitle), content: "Physics is great" }, { owner: user } ) const page2 = PersonalPage.create( - { title: pageTwoTitle, slug: generateUniqueSlug([], pageTwoTitle), content: "Karabiner is great" }, + { title: pageTwoTitle, slug: generateUniqueSlug(pageTwoTitle), content: "Karabiner is great" }, { owner: user } ) user.root.personalPages?.push(page1) diff --git a/web/app/(pages)/pages/page.tsx b/web/app/(pages)/pages/page.tsx new file mode 100644 index 00000000..edc1ae2c --- /dev/null +++ b/web/app/(pages)/pages/page.tsx @@ -0,0 +1,5 @@ +import { PageRoute } from "@/components/routes/page/PageRoute" + +export default function Page() { + return +} diff --git a/web/components/routes/link/partials/form/link-form.tsx b/web/components/routes/link/partials/form/link-form.tsx index 0381527b..cf5b9fa9 100644 --- a/web/components/routes/link/partials/form/link-form.tsx +++ b/web/components/routes/link/partials/form/link-form.tsx @@ -147,8 +147,7 @@ export const LinkForm: React.FC = ({ if (isFetching || !me) return try { - const personalLinks = me.root?.personalLinks?.toJSON() || [] - const slug = generateUniqueSlug(personalLinks, values.title) + const slug = generateUniqueSlug(values.title) if (selectedLink) { const { topic, ...diffValues } = values diff --git a/web/components/routes/page/PageRoute.tsx b/web/components/routes/page/PageRoute.tsx new file mode 100644 index 00000000..f01892a5 --- /dev/null +++ b/web/components/routes/page/PageRoute.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { PageHeader } from "./header" +import { PageList } from "./list" +import { useAtom } from "jotai" +import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" + +export function PageRoute() { + 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/page/detail/PageDetailRoute.tsx b/web/components/routes/page/detail/PageDetailRoute.tsx index a1bd5776..ca075c36 100644 --- a/web/components/routes/page/detail/PageDetailRoute.tsx +++ b/web/components/routes/page/detail/PageDetailRoute.tsx @@ -126,8 +126,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const newTitle = editor.getText() if (newTitle !== page.title) { - const personalPages = me?.root?.personalPages?.toJSON() || [] - const slug = generateUniqueSlug(personalPages, page.slug || "") + const slug = generateUniqueSlug(page.title?.toString() || "") page.title = newTitle page.slug = slug page.updatedAt = new Date() diff --git a/web/components/routes/page/header.tsx b/web/components/routes/page/header.tsx new file mode 100644 index 00000000..af1c1b22 --- /dev/null +++ b/web/components/routes/page/header.tsx @@ -0,0 +1,54 @@ +"use client" + +import * as React from "react" +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" + +export const PageHeader = React.memo(() => { + const { me } = useAccount() + const router = useRouter() + + 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") + } + } + + return ( + +
+ +
+ Pages +
+
+ +
+ +
+
+ +
+
+
+ ) +}) + +PageHeader.displayName = "PageHeader" diff --git a/web/components/routes/page/hooks/use-column-styles.ts b/web/components/routes/page/hooks/use-column-styles.ts new file mode 100644 index 00000000..30f1171e --- /dev/null +++ b/web/components/routes/page/hooks/use-column-styles.ts @@ -0,0 +1,16 @@ +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" + }, + content: { "--width": "auto", "--min-width": "200px", "--max-width": "200px" }, + topic: { "--width": "65px", "--min-width": "120px", "--max-width": "120px" }, + updated: { "--width": "82px", "--min-width": "82px", "--max-width": "82px" } + } +} diff --git a/web/components/routes/page/hooks/use-keyboard-navigation.ts b/web/components/routes/page/hooks/use-keyboard-navigation.ts new file mode 100644 index 00000000..3878b653 --- /dev/null +++ b/web/components/routes/page/hooks/use-keyboard-navigation.ts @@ -0,0 +1,69 @@ +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/list.tsx b/web/components/routes/page/list.tsx new file mode 100644 index 00000000..d5796a73 --- /dev/null +++ b/web/components/routes/page/list.tsx @@ -0,0 +1,100 @@ +import React, { useMemo, useCallback } 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" + +interface PageListProps { + activeItemIndex: number | null + setActiveItemIndex: React.Dispatch> + disableEnterKey: boolean +} + +export const PageList: React.FC = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => { + const isTablet = useMedia("(max-width: 640px)") + const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom) + const { me } = useAccount({ root: { personalPages: [] } }) + const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages]) + const router = useRouter() + + const handleEnter = useCallback( + (selectedPage: PersonalPage) => { + router.push(`/pages/${selectedPage.id}`) + }, + [router] + ) + + const { listRef, setItemRef } = useKeyboardNavigation({ + personalPages, + activeItemIndex, + setActiveItemIndex, + isCommandPaletteOpen, + disableEnterKey, + onEnter: handleEnter + }) + + return ( +
+ {!isTablet && } + +
+ ) +} + +export const ColumnHeader: React.FC = () => { + const columnStyles = useColumnStyles() + + return ( +
+ + Title + + + Topic + + + Updated + +
+ ) +} + +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} + /> + ) + )} + +) diff --git a/web/components/routes/page/partials/column.tsx b/web/components/routes/page/partials/column.tsx new file mode 100644 index 00000000..79c8b03d --- /dev/null +++ b/web/components/routes/page/partials/column.tsx @@ -0,0 +1,38 @@ +import React from "react" +import { cn } from "@/lib/utils" + +interface ColumnWrapperProps extends React.HTMLAttributes { + style?: { [key: string]: string } +} + +interface ColumnTextProps extends React.HTMLAttributes {} + +const ColumnWrapper = React.forwardRef( + ({ children, className, style, ...props }, ref) => ( +
+ {children} +
+ ) +) + +const ColumnText = React.forwardRef(({ children, className, ...props }, ref) => ( + + {children} + +)) + +export const Column = { + Wrapper: ColumnWrapper, + Text: ColumnText +} diff --git a/web/components/routes/page/partials/page-item.tsx b/web/components/routes/page/partials/page-item.tsx new file mode 100644 index 00000000..c7f42379 --- /dev/null +++ b/web/components/routes/page/partials/page-item.tsx @@ -0,0 +1,59 @@ +import React from "react" +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" + +interface PageItemProps { + page: PersonalPage + isActive: boolean +} + +export const PageItem = React.forwardRef(({ page, isActive }, ref) => { + const isTablet = useMedia("(max-width: 640px)") + const columnStyles = useColumnStyles() + + return ( + +
+ + {page.title || "Untitled"} + + + {!isTablet && ( + <> + {/* + {page.slug} + */} + + {page.topic && {page.topic.prettyName}} + + + )} + + + {page.updatedAt.toLocaleDateString()} + +
+ + ) +}) + +PageItem.displayName = "PageItem" diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx index 8273b86c..0c99fd01 100644 --- a/web/components/routes/topics/detail/partials/link-item.tsx +++ b/web/components/routes/topics/detail/partials/link-item.tsx @@ -82,7 +82,7 @@ export const LinkItem = React.memo( toast.success("Link learning state updated", defaultToast) } } else { - const slug = generateUniqueSlug(personalLinks.toJSON(), link.title) + const slug = generateUniqueSlug(link.title) const newPersonalLink = PersonalLink.create( { url: link.url, diff --git a/web/lib/utils/slug.test.ts b/web/lib/utils/slug.test.ts new file mode 100644 index 00000000..573e72b3 --- /dev/null +++ b/web/lib/utils/slug.test.ts @@ -0,0 +1,29 @@ +import { generateUniqueSlug } from "./slug" + +describe("generateUniqueSlug", () => { + it("should generate a slug with the correct format", () => { + const title = "This is a test title" + const slug = generateUniqueSlug(title) + expect(slug).toMatch(/^this-is-a-test-title-[a-f0-9]{8}$/) + }) + + it("should respect the maxLength parameter", () => { + const title = "This is a very long title that should be truncated" + const maxLength = 30 + const slug = generateUniqueSlug(title, maxLength) + expect(slug.length).toBe(maxLength) + }) + + it("should generate different slugs for the same title", () => { + const title = "Same Title" + const slug1 = generateUniqueSlug(title) + const slug2 = generateUniqueSlug(title) + expect(slug1).not.toBe(slug2) + }) + + it("should handle empty strings", () => { + const title = "" + const slug = generateUniqueSlug(title) + expect(slug).toMatch(/^-[a-f0-9]{8}$/) + }) +}) diff --git a/web/lib/utils/slug.ts b/web/lib/utils/slug.ts index 9c07d145..20e37ece 100644 --- a/web/lib/utils/slug.ts +++ b/web/lib/utils/slug.ts @@ -1,36 +1,14 @@ import slugify from "slugify" +import crypto from "crypto" -type SlugLikeProperty = string | undefined +export function generateUniqueSlug(title: string, maxLength: number = 60): string { + const baseSlug = slugify(title, { + lower: true, + strict: true + }) + const randomSuffix = crypto.randomBytes(4).toString("hex") -interface Data { - [key: string]: any -} - -export function generateUniqueSlug( - existingItems: Data[], - title: string, - slugProperty: string = "slug", - maxLength: number = 50 -): string { - const baseSlug = slugify(title, { lower: true, strict: true }) - let uniqueSlug = baseSlug.slice(0, maxLength) - let num = 1 - - if (!existingItems || existingItems.length === 0) { - return uniqueSlug - } - - const isSlugTaken = (slug: string) => - existingItems.some(item => { - const itemSlug = item[slugProperty] as SlugLikeProperty - return itemSlug === slug - }) - - while (isSlugTaken(uniqueSlug)) { - const suffix = `-${num}` - uniqueSlug = `${baseSlug.slice(0, maxLength - suffix.length)}${suffix}` - num++ - } - - return uniqueSlug + const truncatedSlug = baseSlug.slice(0, Math.min(maxLength, 75) - 9) + + return `${truncatedSlug}-${randomSuffix}` }