mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
feat: keyboard navigate between title and content
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { EditorContent, useEditor } from "@tiptap/react"
|
import { EditorContent, useEditor } from "@tiptap/react"
|
||||||
import { Editor, Content } from "@tiptap/core"
|
import { Editor, Content } from "@tiptap/core"
|
||||||
@@ -9,21 +7,22 @@ import { createExtensions } from "./extensions"
|
|||||||
import "./styles/index.css"
|
import "./styles/index.css"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getOutput } from "./lib/utils"
|
import { getOutput } from "./lib/utils"
|
||||||
|
import { EditorView } from "@tiptap/pm/view"
|
||||||
|
|
||||||
export interface LAEditorProps extends Omit<React.HTMLProps<HTMLDivElement>, "value"> {
|
export interface LAEditorProps extends Omit<React.HTMLProps<HTMLDivElement>, "value"> {
|
||||||
initialContent?: any
|
|
||||||
output?: "html" | "json" | "text"
|
output?: "html" | "json" | "text"
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
editorClassName?: string
|
editorClassName?: string
|
||||||
onUpdate?: (content: Content) => void
|
onUpdate?: (content: Content) => void
|
||||||
onBlur?: (content: Content) => void
|
onBlur?: (content: Content) => void
|
||||||
onNewBlock?: (content: Content) => void
|
onNewBlock?: (content: Content) => void
|
||||||
value?: Content
|
handleKeyDown?: (view: EditorView, event: KeyboardEvent) => boolean
|
||||||
|
value?: any
|
||||||
throttleDelay?: number
|
throttleDelay?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LAEditorRef {
|
export interface LAEditorRef {
|
||||||
focus: () => void
|
editor: Editor | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomEditor extends Editor {
|
interface CustomEditor extends Editor {
|
||||||
@@ -33,7 +32,6 @@ interface CustomEditor extends Editor {
|
|||||||
export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
initialContent,
|
|
||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
output = "html",
|
output = "html",
|
||||||
@@ -42,6 +40,7 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onBlur,
|
onBlur,
|
||||||
onNewBlock,
|
onNewBlock,
|
||||||
|
handleKeyDown,
|
||||||
throttleDelay = 1000,
|
throttleDelay = 1000,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -66,9 +65,7 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
|||||||
typeof customEditor.previousBlockCount === "number" &&
|
typeof customEditor.previousBlockCount === "number" &&
|
||||||
currentBlockCount > customEditor.previousBlockCount
|
currentBlockCount > customEditor.previousBlockCount
|
||||||
) {
|
) {
|
||||||
requestAnimationFrame(() => {
|
onNewBlock?.(newContent)
|
||||||
onNewBlock?.(newContent)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
customEditor.previousBlockCount = currentBlockCount
|
customEditor.previousBlockCount = currentBlockCount
|
||||||
@@ -79,6 +76,7 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
|||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
|
immediatelyRender: false,
|
||||||
extensions: createExtensions({ placeholder }),
|
extensions: createExtensions({ placeholder }),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -86,44 +84,29 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
|||||||
autocorrect: "off",
|
autocorrect: "off",
|
||||||
autocapitalize: "off",
|
autocapitalize: "off",
|
||||||
class: editorClassName || ""
|
class: editorClassName || ""
|
||||||
}
|
},
|
||||||
|
handleKeyDown
|
||||||
},
|
},
|
||||||
onCreate: ({ editor }) => {
|
onCreate: ({ editor }) => {
|
||||||
if (editor.isEmpty && value) {
|
editor.commands.setContent(value)
|
||||||
editor.commands.setContent(value)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor }) => handleUpdate(editor),
|
onUpdate: ({ editor }) => handleUpdate(editor),
|
||||||
onBlur: ({ editor }) => {
|
onBlur: ({ editor }) => {
|
||||||
requestAnimationFrame(() => {
|
onBlur?.(getOutput(editor, output))
|
||||||
onBlur?.(getOutput(editor, output))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (editor && initialContent) {
|
|
||||||
// https://github.com/ueberdosis/tiptap/issues/3764
|
|
||||||
setTimeout(() => {
|
|
||||||
editor.commands.setContent(initialContent)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [editor, initialContent])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (lastThrottledContent !== throttledContent) {
|
if (lastThrottledContent !== throttledContent) {
|
||||||
setLastThrottledContent(throttledContent)
|
setLastThrottledContent(throttledContent)
|
||||||
|
onUpdate?.(throttledContent!)
|
||||||
requestAnimationFrame(() => {
|
|
||||||
onUpdate?.(throttledContent!)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [throttledContent, lastThrottledContent, onUpdate])
|
}, [throttledContent, lastThrottledContent, onUpdate])
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
focus: () => editor?.commands.focus()
|
editor: editor
|
||||||
}),
|
}),
|
||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { useEffect, useRef } from "react"
|
import React, { useCallback, useRef, useEffect } from "react"
|
||||||
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
||||||
import { DetailPageHeader } from "./header"
|
import { DetailPageHeader } from "./header"
|
||||||
import { ID } from "jazz-tools"
|
import { ID } from "jazz-tools"
|
||||||
@@ -8,132 +8,177 @@ import { PersonalPage } from "@/lib/schema/personal-page"
|
|||||||
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
import { Content, EditorContent, useEditor } from "@tiptap/react"
|
||||||
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
import { StarterKit } from "@/components/la-editor/extensions/starter-kit"
|
||||||
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
|
import { Paragraph } from "@/components/la-editor/extensions/paragraph"
|
||||||
import { useCoState } from "@/lib/providers/jazz-provider"
|
import { useAccount, useCoState } from "@/lib/providers/jazz-provider"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { EditorView } from "prosemirror-view"
|
import { EditorView } from "prosemirror-view"
|
||||||
|
import { Editor } from "@tiptap/core"
|
||||||
|
import { generateUniqueSlug } from "@/lib/utils"
|
||||||
|
|
||||||
const configureStarterKit = () =>
|
const TITLE_PLACEHOLDER = "Page title"
|
||||||
StarterKit.configure({
|
|
||||||
bold: false,
|
|
||||||
italic: false,
|
|
||||||
typography: false,
|
|
||||||
hardBreak: false,
|
|
||||||
listItem: false,
|
|
||||||
strike: false,
|
|
||||||
focus: false,
|
|
||||||
gapcursor: false,
|
|
||||||
history: false,
|
|
||||||
placeholder: {
|
|
||||||
placeholder: "Page title"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const editorProps = {
|
|
||||||
attributes: {
|
|
||||||
spellcheck: "true",
|
|
||||||
role: "textbox",
|
|
||||||
"aria-readonly": "false",
|
|
||||||
"aria-multiline": "false",
|
|
||||||
"aria-label": "Page title",
|
|
||||||
translate: "no"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DetailPageWrapper({ pageId }: { pageId: string }) {
|
export function DetailPageWrapper({ pageId }: { pageId: string }) {
|
||||||
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
||||||
const contentEditorRef = useRef<LAEditorRef>(null)
|
|
||||||
|
|
||||||
const handleKeyDown = (view: EditorView, event: KeyboardEvent) => {
|
if (!page) return <div>Loading...</div>
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
|
||||||
event.preventDefault()
|
|
||||||
contentEditorRef.current?.focus()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTitleBlur = (title: string) => {
|
|
||||||
if (page && editor) {
|
|
||||||
if (!title) {
|
|
||||||
toast.error("Update failed", {
|
|
||||||
description: "Title must be longer than or equal to 1 character"
|
|
||||||
})
|
|
||||||
|
|
||||||
// https://github.com/ueberdosis/tiptap/issues/3764
|
|
||||||
setTimeout(() => {
|
|
||||||
editor.commands.setContent(`<p>${page.title}</p>`)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
page.title = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
extensions: [configureStarterKit(), Paragraph],
|
|
||||||
editorProps: {
|
|
||||||
...editorProps,
|
|
||||||
handleKeyDown: handleKeyDown as unknown as (view: EditorView, event: KeyboardEvent) => boolean | void
|
|
||||||
},
|
|
||||||
onBlur: ({ editor }) => handleTitleBlur(editor.getText())
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleContentUpdate = (content: Content) => {
|
|
||||||
console.log("content", content)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePageContent = (content: Content) => {
|
|
||||||
if (page) {
|
|
||||||
page.content = content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (page && editor) {
|
|
||||||
setTimeout(() => {
|
|
||||||
editor.commands.setContent(`<p>${page.title}</p>`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [page, editor])
|
|
||||||
|
|
||||||
if (!editor) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
||||||
<DetailPageHeader pageId={pageId as ID<PersonalPage>} />
|
<DetailPageHeader pageId={pageId as ID<PersonalPage>} />
|
||||||
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
|
<DetailPageForm page={page} />
|
||||||
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
|
|
||||||
<form className="flex shrink-0 flex-col">
|
|
||||||
<div className="mb-2 mt-8 py-1.5">
|
|
||||||
<EditorContent
|
|
||||||
editor={editor}
|
|
||||||
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-auto flex-col">
|
|
||||||
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
|
||||||
<LAEditor
|
|
||||||
ref={contentEditorRef}
|
|
||||||
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto"
|
|
||||||
initialContent={page?.content}
|
|
||||||
placeholder="Add content..."
|
|
||||||
output="json"
|
|
||||||
throttleDelay={3000}
|
|
||||||
onUpdate={handleContentUpdate}
|
|
||||||
onBlur={updatePageContent}
|
|
||||||
onNewBlock={updatePageContent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
||||||
|
const { me } = useAccount()
|
||||||
|
|
||||||
|
const titleEditorRef = useRef<Editor | null>(null)
|
||||||
|
const contentEditorRef = useRef<LAEditorRef>(null)
|
||||||
|
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTitle === page.title) return
|
||||||
|
|
||||||
|
const personalPages = me.root?.personalPages?.toJSON() || []
|
||||||
|
const slug = generateUniqueSlug(personalPages, page.slug)
|
||||||
|
|
||||||
|
page.title = newTitle
|
||||||
|
page.slug = slug
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||||
|
const editor = titleEditorRef.current
|
||||||
|
if (!editor) return false
|
||||||
|
|
||||||
|
const { state } = editor
|
||||||
|
const { selection } = state
|
||||||
|
const { $anchor } = selection
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowRight":
|
||||||
|
case "ArrowDown":
|
||||||
|
if ($anchor.pos === state.doc.content.size - 1) {
|
||||||
|
event.preventDefault()
|
||||||
|
contentEditorRef.current?.editor?.commands.focus("start")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "Enter":
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
contentEditorRef.current?.editor?.commands.focus("start")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleContentKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
|
||||||
|
const editor = contentEditorRef.current?.editor
|
||||||
|
if (!editor) return false
|
||||||
|
|
||||||
|
const { state } = editor
|
||||||
|
const { selection } = state
|
||||||
|
const { $anchor } = selection
|
||||||
|
|
||||||
|
if ((event.key === "ArrowLeft" || event.key === "ArrowUp") && $anchor.pos - 1 === 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
titleEditorRef.current?.commands.focus("end")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const titleEditor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
|
extensions: [
|
||||||
|
Paragraph,
|
||||||
|
StarterKit.configure({
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
typography: false,
|
||||||
|
hardBreak: false,
|
||||||
|
listItem: false,
|
||||||
|
strike: false,
|
||||||
|
focus: false,
|
||||||
|
gapcursor: false,
|
||||||
|
history: false,
|
||||||
|
placeholder: {
|
||||||
|
placeholder: TITLE_PLACEHOLDER
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
spellcheck: "true",
|
||||||
|
role: "textbox",
|
||||||
|
"aria-readonly": "false",
|
||||||
|
"aria-multiline": "false",
|
||||||
|
"aria-label": TITLE_PLACEHOLDER,
|
||||||
|
translate: "no"
|
||||||
|
},
|
||||||
|
handleKeyDown: handleTitleKeyDown
|
||||||
|
},
|
||||||
|
onCreate: ({ editor }) => {
|
||||||
|
editor.commands.setContent(`<p>${page.title}</p>`)
|
||||||
|
},
|
||||||
|
onBlur: ({ editor }) => handleTitleBlur(editor)
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (titleEditor) {
|
||||||
|
titleEditorRef.current = titleEditor
|
||||||
|
}
|
||||||
|
}, [titleEditor])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div tabIndex={0} className="relative flex grow flex-col overflow-y-auto">
|
||||||
|
<div className="relative mx-auto flex h-full w-[calc(100%-40px)] shrink-0 grow flex-col sm:w-[calc(100%-80px)]">
|
||||||
|
<form className="flex shrink-0 flex-col">
|
||||||
|
<div className="mb-2 mt-8 py-1.5">
|
||||||
|
<EditorContent
|
||||||
|
editor={titleEditor}
|
||||||
|
className="la-editor cursor-text select-text text-2xl font-semibold leading-[calc(1.33333)] tracking-[-0.00625rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-auto flex-col">
|
||||||
|
<div className="relative flex h-full max-w-full grow flex-col items-stretch p-0">
|
||||||
|
<LAEditor
|
||||||
|
ref={contentEditorRef}
|
||||||
|
editorClassName="-mx-3.5 px-3.5 py-2.5 flex-auto"
|
||||||
|
value={page.content}
|
||||||
|
placeholder="Add content..."
|
||||||
|
output="json"
|
||||||
|
throttleDelay={3000}
|
||||||
|
onUpdate={c => updatePageContent(c, page)}
|
||||||
|
handleKeyDown={handleContentKeyDown}
|
||||||
|
onBlur={c => updatePageContent(c, page)}
|
||||||
|
onNewBlock={c => updatePageContent(c, page)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,20 +57,20 @@
|
|||||||
"@tiptap/react": "^2.5.9",
|
"@tiptap/react": "^2.5.9",
|
||||||
"@tiptap/suggestion": "^2.5.9",
|
"@tiptap/suggestion": "^2.5.9",
|
||||||
"axios": "^1.7.3",
|
"axios": "^1.7.3",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "1.0.0-rc.12",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"jazz-react": "^0.7.23",
|
"jazz-react": "^0.7.25",
|
||||||
"jazz-tools": "^0.7.23",
|
"jazz-tools": "^0.7.25",
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.2",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"lucide-react": "^0.424.0",
|
"lucide-react": "^0.424.0",
|
||||||
"next": "14.2.5",
|
"next": "14.2.5",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^9.0.7",
|
"react-day-picker": "^9.0.8",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.52.2",
|
"react-hook-form": "^7.52.2",
|
||||||
"react-use": "^17.5.1",
|
"react-use": "^17.5.1",
|
||||||
@@ -84,20 +84,20 @@
|
|||||||
"zsa": "^0.5.1"
|
"zsa": "^0.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.1.0",
|
|
||||||
"jest": "^29.7.0",
|
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
|
||||||
"ts-jest": "^29.2.4",
|
|
||||||
"@types/react": "^18.3.3",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@testing-library/jest-dom": "^6.4.8",
|
"@testing-library/jest-dom": "^6.4.8",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/node": "^22.1.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.5",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8.4.41",
|
"postcss": "^8.4.41",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.9",
|
||||||
|
"ts-jest": "^29.2.4",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user