mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-20 15:31:30 +02:00
260 lines
7.8 KiB
TypeScript
260 lines
7.8 KiB
TypeScript
"use client"
|
|
|
|
import React, { useCallback, useRef, useEffect } from "react"
|
|
import { ID } from "jazz-tools"
|
|
import { PersonalPage } from "@/lib/schema"
|
|
import { LAEditor, LAEditorRef } from "@/components/la-editor"
|
|
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 { EditorView } from "@tiptap/pm/view"
|
|
import { Editor } from "@tiptap/core"
|
|
import { generateUniqueSlug } from "@/lib/utils"
|
|
import { FocusClasses } from "@tiptap/extension-focus"
|
|
import { DetailPageHeader } from "./header"
|
|
import { useMedia } from "react-use"
|
|
import { TopicSelector } from "@/components/custom/topic-selector"
|
|
import { Button } from "@/components/ui/button"
|
|
import { LaIcon } from "@/components/custom/la-icon"
|
|
import { useConfirm } from "@omit/react-confirm-dialog"
|
|
import { toast } from "sonner"
|
|
import { useRouter } from "next/navigation"
|
|
|
|
const TITLE_PLACEHOLDER = "Untitled"
|
|
|
|
export function PageDetailRoute({ pageId }: { pageId: string }) {
|
|
const { me } = useAccount({ root: { personalLinks: [] } })
|
|
const isMobile = useMedia("(max-width: 770px)")
|
|
const page = useCoState(PersonalPage, pageId as ID<PersonalPage>)
|
|
const router = useRouter()
|
|
const confirm = useConfirm()
|
|
|
|
const handleDelete = async () => {
|
|
const result = await confirm({
|
|
title: "Delete page",
|
|
description: "Are you sure you want to delete this page?",
|
|
confirmText: "Delete",
|
|
cancelText: "Cancel",
|
|
cancelButton: { variant: "outline" },
|
|
confirmButton: { variant: "destructive" }
|
|
})
|
|
|
|
if (result && me?.root.personalPages) {
|
|
try {
|
|
const index = me.root.personalPages.findIndex(item => item?.id === pageId)
|
|
if (index === -1) {
|
|
toast.error("Page not found.")
|
|
return
|
|
}
|
|
|
|
me.root.personalPages.splice(index, 1)
|
|
toast.success("Page deleted.", { position: "bottom-right" })
|
|
router.replace("/")
|
|
} catch (error) {
|
|
console.error("Delete operation fail", { error })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!page) return null
|
|
|
|
return (
|
|
<div className="absolute inset-0 flex flex-row overflow-hidden">
|
|
<div className="flex h-full w-full">
|
|
<div className="relative flex min-w-0 grow basis-[760px] flex-col">
|
|
<DetailPageHeader page={page} handleDelete={handleDelete} isMobile={isMobile} />
|
|
<DetailPageForm page={page} />
|
|
</div>
|
|
{!isMobile && <SidebarActions page={page} handleDelete={handleDelete} />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const SidebarActions = ({ page, handleDelete }: { page: PersonalPage; handleDelete: () => void }) => (
|
|
<div className="relative min-w-56 max-w-72 border-l">
|
|
<div className="flex">
|
|
<div className="flex h-10 flex-auto flex-row items-center justify-between px-5">
|
|
<span className="text-left text-[13px] font-medium">Page actions</span>
|
|
</div>
|
|
<div className="absolute bottom-0 left-0 right-0 top-10 space-y-3 overflow-y-auto px-4 py-1.5">
|
|
<div className="flex flex-row">
|
|
<TopicSelector
|
|
value={page.topic?.name}
|
|
onTopicChange={topic => {
|
|
page.topic = topic
|
|
page.updatedAt = new Date()
|
|
}}
|
|
variant="ghost"
|
|
className="-ml-1.5"
|
|
renderSelectedText={() => <span className="truncate">{page.topic?.prettyName || "Select a topic"}</span>}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-row">
|
|
<Button size="sm" variant="ghost" onClick={handleDelete} className="-ml-1.5">
|
|
<LaIcon name="Trash" className="mr-2 size-3.5" />
|
|
<span className="text-sm">Delete</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const DetailPageForm = ({ page }: { page: PersonalPage }) => {
|
|
const { me } = useAccount()
|
|
const titleEditorRef = useRef<Editor | null>(null)
|
|
const contentEditorRef = useRef<LAEditorRef>(null)
|
|
const isTitleInitialMount = useRef(true)
|
|
const isContentInitialMount = useRef(true)
|
|
|
|
const updatePageContent = (content: Content, model: PersonalPage) => {
|
|
if (isContentInitialMount.current) {
|
|
isContentInitialMount.current = false
|
|
return
|
|
}
|
|
model.content = content
|
|
model.updatedAt = new Date()
|
|
}
|
|
|
|
const handleUpdateTitle = (editor: Editor) => {
|
|
if (isTitleInitialMount.current) {
|
|
isTitleInitialMount.current = false
|
|
return
|
|
}
|
|
|
|
const newTitle = editor.getText()
|
|
if (newTitle !== page.title) {
|
|
const personalPages = me?.root?.personalPages?.toJSON() || []
|
|
const slug = generateUniqueSlug(personalPages, page.slug || "")
|
|
page.title = newTitle
|
|
page.slug = slug
|
|
page.updatedAt = new Date()
|
|
}
|
|
}
|
|
|
|
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,
|
|
autofocus: true,
|
|
extensions: [
|
|
FocusClasses,
|
|
Paragraph,
|
|
StarterKit.configure({
|
|
bold: false,
|
|
italic: false,
|
|
typography: false,
|
|
hardBreak: false,
|
|
listItem: false,
|
|
strike: false,
|
|
focus: false,
|
|
gapcursor: false,
|
|
placeholder: { placeholder: TITLE_PLACEHOLDER }
|
|
})
|
|
],
|
|
editorProps: {
|
|
attributes: {
|
|
spellcheck: "true",
|
|
role: "textbox",
|
|
"aria-readonly": "false",
|
|
"aria-multiline": "false",
|
|
"aria-label": TITLE_PLACEHOLDER,
|
|
translate: "no",
|
|
class: "focus:outline-none"
|
|
},
|
|
handleKeyDown: handleTitleKeyDown
|
|
},
|
|
onCreate: ({ editor }) => {
|
|
if (page.title) editor.commands.setContent(`<p>${page.title}</p>`)
|
|
},
|
|
onBlur: ({ editor }) => handleUpdateTitle(editor),
|
|
onUpdate: ({ editor }) => handleUpdateTitle(editor)
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (titleEditor) {
|
|
titleEditorRef.current = titleEditor
|
|
}
|
|
}, [titleEditor])
|
|
|
|
useEffect(() => {
|
|
isTitleInitialMount.current = true
|
|
isContentInitialMount.current = true
|
|
}, [])
|
|
|
|
return (
|
|
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
|
<div className="relative mx-auto flex h-full w-[calc(100%-80px)] shrink-0 grow flex-col max-lg:w-[calc(100%-40px)] max-lg:max-w-[unset]">
|
|
<form className="flex shrink-0 flex-col">
|
|
<div className="mb-2 mt-8 py-1.5">
|
|
<EditorContent
|
|
editor={titleEditor}
|
|
className="la-editor no-command grow 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 focus:outline-none"
|
|
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>
|
|
)
|
|
}
|