mirror of
https://github.com/linsa-io/linsa.git
synced 2026-07-11 23:42:37 +02:00
feat: pages (#151)
* wip * wip * wip * wwip * wip * wip * fix(util): rmeove checking to existing in slug * wip * chore: handle create page * chore: handle page title untitled
This commit is contained in:
@@ -27,11 +27,11 @@ async function devSeed() {
|
|||||||
const pageOneTitle = "Physics"
|
const pageOneTitle = "Physics"
|
||||||
const pageTwoTitle = "Karabiner"
|
const pageTwoTitle = "Karabiner"
|
||||||
const page1 = PersonalPage.create(
|
const page1 = PersonalPage.create(
|
||||||
{ title: pageOneTitle, slug: generateUniqueSlug([], pageOneTitle), content: "Physics is great" },
|
{ title: pageOneTitle, slug: generateUniqueSlug(pageOneTitle), content: "Physics is great" },
|
||||||
{ owner: user }
|
{ owner: user }
|
||||||
)
|
)
|
||||||
const page2 = PersonalPage.create(
|
const page2 = PersonalPage.create(
|
||||||
{ title: pageTwoTitle, slug: generateUniqueSlug([], pageTwoTitle), content: "Karabiner is great" },
|
{ title: pageTwoTitle, slug: generateUniqueSlug(pageTwoTitle), content: "Karabiner is great" },
|
||||||
{ owner: user }
|
{ owner: user }
|
||||||
)
|
)
|
||||||
user.root.personalPages?.push(page1)
|
user.root.personalPages?.push(page1)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PageRoute } from "@/components/routes/page/PageRoute"
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRoute />
|
||||||
|
}
|
||||||
@@ -147,8 +147,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
|||||||
if (isFetching || !me) return
|
if (isFetching || !me) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const personalLinks = me.root?.personalLinks?.toJSON() || []
|
const slug = generateUniqueSlug(values.title)
|
||||||
const slug = generateUniqueSlug(personalLinks, values.title)
|
|
||||||
|
|
||||||
if (selectedLink) {
|
if (selectedLink) {
|
||||||
const { topic, ...diffValues } = values
|
const { topic, ...diffValues } = values
|
||||||
|
|||||||
@@ -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<number | null>(null)
|
||||||
|
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||||
|
const [disableEnterKey, setDisableEnterKey] = useState(false)
|
||||||
|
|
||||||
|
const handleCommandPaletteClose = useCallback(() => {
|
||||||
|
setDisableEnterKey(true)
|
||||||
|
setTimeout(() => setDisableEnterKey(false), 100)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCommandPaletteOpen) {
|
||||||
|
handleCommandPaletteClose()
|
||||||
|
}
|
||||||
|
}, [isCommandPaletteOpen, handleCommandPaletteClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-auto flex-col overflow-hidden">
|
||||||
|
<PageHeader />
|
||||||
|
<PageList
|
||||||
|
activeItemIndex={activeItemIndex}
|
||||||
|
setActiveItemIndex={setActiveItemIndex}
|
||||||
|
disableEnterKey={disableEnterKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -126,8 +126,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|||||||
|
|
||||||
const newTitle = editor.getText()
|
const newTitle = editor.getText()
|
||||||
if (newTitle !== page.title) {
|
if (newTitle !== page.title) {
|
||||||
const personalPages = me?.root?.personalPages?.toJSON() || []
|
const slug = generateUniqueSlug(page.title?.toString() || "")
|
||||||
const slug = generateUniqueSlug(personalPages, page.slug || "")
|
|
||||||
page.title = newTitle
|
page.title = newTitle
|
||||||
page.slug = slug
|
page.slug = slug
|
||||||
page.updatedAt = new Date()
|
page.updatedAt = new Date()
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||||
|
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||||
|
<SidebarToggleButton />
|
||||||
|
<div className="flex min-h-0 items-center">
|
||||||
|
<span className="truncate text-left font-bold lg:text-xl">Pages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-auto"></div>
|
||||||
|
|
||||||
|
<div className="flex w-auto items-center justify-end">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={handleClick}>
|
||||||
|
<LaIcon name="Plus" />
|
||||||
|
<span className="hidden md:block">New page</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContentHeader>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
PageHeader.displayName = "PageHeader"
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<React.SetStateAction<number | null>>
|
||||||
|
isCommandPaletteOpen: boolean
|
||||||
|
disableEnterKey: boolean
|
||||||
|
onEnter?: (selectedPage: PersonalPage) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useKeyboardNavigation = ({
|
||||||
|
personalPages,
|
||||||
|
activeItemIndex,
|
||||||
|
setActiveItemIndex,
|
||||||
|
isCommandPaletteOpen,
|
||||||
|
disableEnterKey,
|
||||||
|
onEnter
|
||||||
|
}: UseKeyboardNavigationProps) => {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const itemRefs = useRef<(HTMLAnchorElement | null)[]>([])
|
||||||
|
const itemCount = personalPages?.length || 0
|
||||||
|
|
||||||
|
const scrollIntoView = useCallback((index: number) => {
|
||||||
|
if (itemRefs.current[index]) {
|
||||||
|
itemRefs.current[index]?.scrollIntoView({
|
||||||
|
block: "nearest"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeItemIndex !== null) {
|
||||||
|
scrollIntoView(activeItemIndex)
|
||||||
|
}
|
||||||
|
}, [activeItemIndex, scrollIntoView])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (isCommandPaletteOpen) return
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveItemIndex(prevIndex => {
|
||||||
|
if (prevIndex === null) return 0
|
||||||
|
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
|
||||||
|
return newIndex
|
||||||
|
})
|
||||||
|
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) {
|
||||||
|
e.preventDefault()
|
||||||
|
const selectedPage = personalPages[activeItemIndex]
|
||||||
|
if (selectedPage) onEnter?.(selectedPage)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, onEnter]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [handleKeyDown])
|
||||||
|
|
||||||
|
const setItemRef = useCallback((el: HTMLAnchorElement | null, index: number) => {
|
||||||
|
itemRefs.current[index] = el
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { listRef, setItemRef }
|
||||||
|
}
|
||||||
@@ -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<React.SetStateAction<number | null>>
|
||||||
|
disableEnterKey: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageList: React.FC<PageListProps> = ({ 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 (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
{!isTablet && <ColumnHeader />}
|
||||||
|
<PageListItems
|
||||||
|
listRef={listRef}
|
||||||
|
setItemRef={setItemRef}
|
||||||
|
personalPages={personalPages}
|
||||||
|
activeItemIndex={activeItemIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnHeader: React.FC = () => {
|
||||||
|
const columnStyles = useColumnStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
|
||||||
|
<Column.Wrapper style={columnStyles.title}>
|
||||||
|
<Column.Text>Title</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
<Column.Wrapper style={columnStyles.topic}>
|
||||||
|
<Column.Text>Topic</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
<Column.Wrapper style={columnStyles.updated} className="justify-end">
|
||||||
|
<Column.Text>Updated</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageListItemsProps {
|
||||||
|
listRef: React.RefObject<HTMLDivElement>
|
||||||
|
setItemRef: (el: HTMLAnchorElement | null, index: number) => void
|
||||||
|
personalPages?: PersonalPageLists | null
|
||||||
|
activeItemIndex: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageListItems: React.FC<PageListItemsProps> = ({ listRef, setItemRef, personalPages, activeItemIndex }) => (
|
||||||
|
<Primitive.div
|
||||||
|
ref={listRef}
|
||||||
|
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||||
|
tabIndex={-1}
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
{personalPages?.map(
|
||||||
|
(page, index) =>
|
||||||
|
page?.id && (
|
||||||
|
<PageItem
|
||||||
|
key={page.id}
|
||||||
|
ref={(el: HTMLAnchorElement | null) => setItemRef(el, index)}
|
||||||
|
page={page}
|
||||||
|
isActive={index === activeItemIndex}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Primitive.div>
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ColumnWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
style?: { [key: string]: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnTextProps extends React.HTMLAttributes<HTMLSpanElement> {}
|
||||||
|
|
||||||
|
const ColumnWrapper = React.forwardRef<HTMLDivElement, ColumnWrapperProps>(
|
||||||
|
({ children, className, style, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex grow flex-row items-center justify-start", className)}
|
||||||
|
style={{
|
||||||
|
width: "var(--width)",
|
||||||
|
minWidth: "var(--min-width, min-content)",
|
||||||
|
maxWidth: "min(var(--width), var(--max-width))",
|
||||||
|
flexBasis: "var(--width)",
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const ColumnText = React.forwardRef<HTMLSpanElement, ColumnTextProps>(({ children, className, ...props }, ref) => (
|
||||||
|
<span className={cn("text-left text-xs", className)} ref={ref} {...props}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
|
||||||
|
export const Column = {
|
||||||
|
Wrapper: ColumnWrapper,
|
||||||
|
Text: ColumnText
|
||||||
|
}
|
||||||
@@ -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<HTMLAnchorElement, PageItemProps>(({ page, isActive }, ref) => {
|
||||||
|
const isTablet = useMedia("(max-width: 640px)")
|
||||||
|
const columnStyles = useColumnStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
ref={ref}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
className={cn(
|
||||||
|
"relative block cursor-default outline-none",
|
||||||
|
"h-12 items-center gap-x-2 py-2 max-lg:px-4 sm:px-6",
|
||||||
|
{
|
||||||
|
"bg-muted-foreground/10": isActive,
|
||||||
|
"hover:bg-muted/50": !isActive
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
href={`/pages/${page.id}`}
|
||||||
|
role="listitem"
|
||||||
|
aria-selected={isActive}
|
||||||
|
>
|
||||||
|
<div className="flex h-full items-center gap-4">
|
||||||
|
<Column.Wrapper style={columnStyles.title}>
|
||||||
|
<Column.Text className="truncate text-[13px] font-medium">{page.title || "Untitled"}</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
|
||||||
|
{!isTablet && (
|
||||||
|
<>
|
||||||
|
{/* <Column.Wrapper style={columnStyles.content}>
|
||||||
|
<Column.Text className="text-[13px]">{page.slug}</Column.Text>
|
||||||
|
</Column.Wrapper> */}
|
||||||
|
<Column.Wrapper style={columnStyles.topic}>
|
||||||
|
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
|
||||||
|
</Column.Wrapper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Column.Wrapper style={columnStyles.updated} className="justify-end">
|
||||||
|
<Column.Text className="text-[13px]">{page.updatedAt.toLocaleDateString()}</Column.Text>
|
||||||
|
</Column.Wrapper>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
PageItem.displayName = "PageItem"
|
||||||
@@ -82,7 +82,7 @@ export const LinkItem = React.memo(
|
|||||||
toast.success("Link learning state updated", defaultToast)
|
toast.success("Link learning state updated", defaultToast)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const slug = generateUniqueSlug(personalLinks.toJSON(), link.title)
|
const slug = generateUniqueSlug(link.title)
|
||||||
const newPersonalLink = PersonalLink.create(
|
const newPersonalLink = PersonalLink.create(
|
||||||
{
|
{
|
||||||
url: link.url,
|
url: link.url,
|
||||||
|
|||||||
@@ -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}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
+10
-32
@@ -1,36 +1,14 @@
|
|||||||
import slugify from "slugify"
|
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 {
|
const truncatedSlug = baseSlug.slice(0, Math.min(maxLength, 75) - 9)
|
||||||
[key: string]: any
|
|
||||||
}
|
return `${truncatedSlug}-${randomSuffix}`
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user