update web to dashboard

This commit is contained in:
mlogclub
2026-04-15 17:20:57 +08:00
parent 3d98cf1e27
commit c07e588030
206 changed files with 0 additions and 0 deletions
-26
View File
@@ -1,26 +0,0 @@
import MarkdownIt from "markdown-it"
import TurndownService from "turndown"
const markdownIt = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "*",
strongDelimiter: "**",
})
turndownService.keep(["table", "thead", "tbody", "tr", "th", "td"])
export function markdownToHtml(markdown: string) {
return markdownIt.render(markdown ?? "")
}
export function htmlToMarkdown(html: string) {
return turndownService.turndown(html ?? "")
}
@@ -1,49 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
import type { ContentMode } from "./types"
type EditorModeSwitchProps = {
value: ContentMode
disabled?: boolean
onChange: (nextMode: ContentMode) => void
}
const MODE_OPTIONS: Array<{ value: ContentMode; label: string }> = [
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]
export function EditorModeSwitch({
value,
disabled = false,
onChange,
}: EditorModeSwitchProps) {
return (
<div className="mx-0.5 rounded-[3px] border border-border/80 bg-transparent p-0">
<div className="flex items-center">
{MODE_OPTIONS.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onChange(option.value)}
className={cn(
"px-1.5 py-0 text-[12px] leading-6 whitespace-nowrap transition-colors",
"hover:bg-[#f2f2f2] disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-[#333]",
active
? "bg-[#f2f2f2] text-[#3f4a54] dark:bg-[#333] dark:text-[#999]"
: "bg-transparent text-[#3f4a54] dark:text-[#999]"
)}
>
{option.label}
</button>
)
})}
</div>
</div>
)
}
@@ -1,362 +0,0 @@
"use client"
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
type ChangeEvent,
} from "react"
import { EditorContent, useEditor } from "@tiptap/react"
import Image from "@tiptap/extension-image"
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import StarterKit from "@tiptap/starter-kit"
import Underline from "@tiptap/extension-underline"
import {
BoldIcon,
Code2Icon,
Heading1Icon,
Heading2Icon,
ImageIcon,
ItalicIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
RedoIcon,
RotateCcwIcon,
StrikethroughIcon,
EyeIcon,
Maximize2Icon,
Minimize2Icon,
UnderlineIcon,
} from "lucide-react"
import { EditorModeSwitch } from "./editor-mode-switch"
import { EditorToolbar } from "./toolbar"
import type { ContentMode, EditorToolbarAction, UploadImageHandler } from "./types"
export type HtmlEditorRef = {
focus: () => void
}
type HtmlEditorProps = {
value: string
onChange: (nextValue: string) => void
mode: ContentMode
onModeChange: (nextMode: ContentMode) => void
fullscreen: boolean
onToggleFullscreen: () => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height: string
}
export const HtmlEditor = forwardRef<HtmlEditorRef, HtmlEditorProps>(
function HtmlEditor(
{
value,
onChange,
mode,
onModeChange,
fullscreen,
onToggleFullscreen,
placeholder = "",
disabled = false,
onUploadImage,
height,
},
ref
) {
const imageInputRef = useRef<HTMLInputElement>(null)
const [previewOnly, setPreviewOnly] = useState(false)
const proseClassName =
"h-full overflow-y-auto px-4 py-3 text-sm leading-7 text-foreground outline-none [&_.ProseMirror-focused]:outline-none [&_p]:m-0 [&_p]:mb-2 [&_h1]:mb-3 [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:mb-1 [&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_img]:my-2 [&_img]:max-h-80 [&_img]:rounded-md [&_img]:object-contain [&_p.is-editor-empty:first-child]:before:text-muted-foreground"
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2],
},
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Image,
Link.configure({
openOnClick: false,
autolink: true,
}),
Underline,
Placeholder.configure({
placeholder,
}),
],
content: value,
editable: !disabled,
onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getHTML())
},
editorProps: {
attributes: {
class: proseClassName,
},
},
})
useImperativeHandle(ref, () => ({
focus() {
editor?.commands.focus()
},
}), [editor])
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value, { emitUpdate: false })
}
}, [editor, value])
useEffect(() => {
if (editor) {
editor.setEditable(!disabled)
}
}, [disabled, editor])
const handleTogglePreviewOnly = () => {
setPreviewOnly((current: boolean) => !current)
}
const handleInsertLink = () => {
if (!editor || disabled) {
return
}
const previousUrl = editor.getAttributes("link").href as string | undefined
const url = window.prompt("输入链接地址", previousUrl || "https://")
if (url === null) {
return
}
if (!url.trim()) {
editor.chain().focus().unsetLink().run()
return
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url.trim() }).run()
}
const handleSelectImage = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file || !editor || !onUploadImage || disabled) {
return
}
const uploaded = await onUploadImage(file)
if (!uploaded?.url) {
return
}
editor
.chain()
.focus()
.setImage({
src: uploaded.url,
alt: uploaded.alt || file.name || "image",
title: uploaded.title || "",
})
.run()
}
const actions: EditorToolbarAction[] = [
{
key: "mode-switch",
type: "custom",
content: (
<EditorModeSwitch
value={mode}
disabled={disabled}
onChange={onModeChange}
/>
),
},
{ key: "separator-mode", type: "separator" },
{
key: "bold",
label: "粗体",
icon: BoldIcon,
disabled,
pressed: !!editor?.isActive("bold"),
onClick: () => editor?.chain().focus().toggleBold().run(),
},
{
key: "underline",
label: "下划线",
icon: UnderlineIcon,
disabled,
pressed: !!editor?.isActive("underline"),
onClick: () => editor?.chain().focus().toggleUnderline().run(),
},
{
key: "italic",
label: "斜体",
icon: ItalicIcon,
disabled,
pressed: !!editor?.isActive("italic"),
onClick: () => editor?.chain().focus().toggleItalic().run(),
},
{
key: "strike",
label: "删除线",
icon: StrikethroughIcon,
disabled,
pressed: !!editor?.isActive("strike"),
onClick: () => editor?.chain().focus().toggleStrike().run(),
},
{ key: "separator-1", type: "separator" },
{
key: "h1",
label: "一级标题",
icon: Heading1Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 1 }),
onClick: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
key: "h2",
label: "二级标题",
icon: Heading2Icon,
disabled,
pressed: !!editor?.isActive("heading", { level: 2 }),
onClick: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
key: "quote",
label: "引用",
icon: QuoteIcon,
disabled,
pressed: !!editor?.isActive("blockquote"),
onClick: () => editor?.chain().focus().toggleBlockquote().run(),
},
{
key: "bullet-list",
label: "无序列表",
icon: ListIcon,
disabled,
pressed: !!editor?.isActive("bulletList"),
onClick: () => editor?.chain().focus().toggleBulletList().run(),
},
{
key: "ordered-list",
label: "有序列表",
icon: ListOrderedIcon,
disabled,
pressed: !!editor?.isActive("orderedList"),
onClick: () => editor?.chain().focus().toggleOrderedList().run(),
},
{ key: "separator-2", type: "separator" },
{
key: "code",
label: "行内代码",
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("code"),
onClick: () => editor?.chain().focus().toggleCode().run(),
},
{
key: "code-block",
label: "代码块",
icon: Code2Icon,
disabled,
pressed: !!editor?.isActive("codeBlock"),
onClick: () => editor?.chain().focus().toggleCodeBlock().run(),
},
{ key: "separator-3", type: "separator" },
{
key: "link",
label: "链接",
icon: LinkIcon,
disabled,
pressed: !!editor?.isActive("link"),
onClick: handleInsertLink,
},
{
key: "image",
label: "图片",
icon: ImageIcon,
disabled: disabled || !onUploadImage,
onClick: () => imageInputRef.current?.click(),
},
{ key: "separator-4", type: "separator" },
{
key: "undo-tail",
label: "撤销",
icon: RotateCcwIcon,
disabled: disabled || !editor?.can().undo(),
onClick: () => editor?.chain().focus().undo().run(),
},
{
key: "redo-tail",
label: "重做",
icon: RedoIcon,
disabled: disabled || !editor?.can().redo(),
onClick: () => editor?.chain().focus().redo().run(),
},
{ key: "separator-fullscreen", type: "separator" },
{
key: "fullscreen",
label: fullscreen ? "退出全屏" : "全屏",
icon: fullscreen ? Minimize2Icon : Maximize2Icon,
disabled,
pressed: fullscreen,
onClick: onToggleFullscreen,
},
{ key: "separator-preview", type: "separator" },
{
key: "preview-only",
label: "仅预览",
icon: EyeIcon,
disabled,
pressed: previewOnly,
onClick: handleTogglePreviewOnly,
},
]
if (!editor) {
return null
}
return (
<div
className="flex w-full flex-col rounded-lg border bg-background"
style={{ height }}
>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleSelectImage(event)
}}
/>
<EditorToolbar actions={actions} />
<div className="min-h-0 flex-1 p-2">
{previewOnly ? (
<div
className={proseClassName}
dangerouslySetInnerHTML={{ __html: value }}
/>
) : (
<EditorContent editor={editor} className="h-full" />
)}
</div>
</div>
)
}
)
-150
View File
@@ -1,150 +0,0 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import { htmlToMarkdown, markdownToHtml } from "./convert"
import { HtmlEditor } from "./html-editor"
import { MarkdownEditor } from "./markdown-editor"
import type { ContentMode, ContentValue, UploadImageHandler } from "./types"
type ContentEditorProps = {
value: ContentValue
onChange: (next: ContentValue) => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height?: number | string
}
function normalizeHeight(height?: number | string) {
if (typeof height === "number") {
return `${height}px`
}
if (typeof height === "string" && height.trim()) {
return height
}
return "400px"
}
function getModeLabel(mode: ContentMode) {
return mode === "markdown" ? "Markdown" : "HTML"
}
function convertContent(mode: ContentMode, raw: string) {
if (mode === "markdown") {
return markdownToHtml(raw)
}
return htmlToMarkdown(raw)
}
export function ContentEditor({
value,
onChange,
placeholder,
disabled = false,
onUploadImage,
height,
}: ContentEditorProps) {
const editorHeight = normalizeHeight(height)
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!fullscreen) {
return
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setFullscreen(false)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener("keydown", handleKeyDown)
}
}, [fullscreen])
const handleModeChange = useCallback(
(nextMode: ContentMode) => {
if (disabled || nextMode === value.mode) {
return
}
const currentText = value.raw.trim()
if (!currentText) {
onChange({ mode: nextMode, raw: "" })
return
}
const confirmed = window.confirm(
`切换到 ${getModeLabel(nextMode)} 模式会尝试自动转换内容,复杂格式可能有损。是否继续?`
)
if (!confirmed) {
return
}
onChange({
mode: nextMode,
raw: convertContent(value.mode, value.raw),
})
},
[disabled, onChange, value.mode, value.raw]
)
const content = (
<div
className={cn(
"w-full",
fullscreen && "fixed inset-0 z-[10000] overflow-hidden bg-background p-4"
)}
>
{value.mode === "markdown" ? (
<MarkdownEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "markdown", raw: nextRaw })}
mode={value.mode}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
/>
) : (
<HtmlEditor
value={value.raw}
onChange={(nextRaw) => onChange({ mode: "html", raw: nextRaw })}
mode={value.mode}
onModeChange={handleModeChange}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((current) => !current)}
placeholder={placeholder}
disabled={disabled}
onUploadImage={onUploadImage}
height={fullscreen ? "calc(100vh - 2rem)" : editorHeight}
/>
)}
</div>
)
if (fullscreen && mounted) {
return createPortal(content, document.body)
}
return content
}
export type { ContentMode, ContentValue, UploadImageHandler, UploadImageResult } from "./types"
@@ -1,48 +0,0 @@
.content-editor-markdown {
height: 100%;
}
.content-editor-markdown .md-editor {
height: 100%;
border: 0;
box-shadow: none;
background: transparent;
}
.content-editor-markdown .md-editor-footer {
display: none;
}
.content-editor-markdown .md-editor-toolbar {
border-bottom: 0;
}
.content-editor-markdown .md-editor-content {
height: calc(100% - 37px);
}
.content-editor-markdown .md-editor-content-wrapper,
.content-editor-markdown .md-editor-input-wrapper {
height: 100%;
}
.content-editor-markdown .md-editor-input-wrapper {
border-right: 0;
}
.content-editor-markdown .cm-editor {
height: 100%;
background: transparent;
}
.content-editor-markdown .cm-scroller {
height: 100%;
overflow: auto;
}
.content-editor-markdown .cm-content,
.content-editor-markdown .cm-line {
font-family: var(--font-geist-mono), monospace;
font-size: 0.875rem;
line-height: 1.75;
}
@@ -1,149 +0,0 @@
"use client"
import {
forwardRef,
useId,
useImperativeHandle,
useMemo,
useRef,
} from "react"
import { Maximize2Icon, Minimize2Icon } from "lucide-react"
import { MdEditor, NormalToolbar, type ExposeParam } from "md-editor-rt"
import { useTheme } from "next-themes"
import "./markdown-editor.css"
import { EditorModeSwitch } from "./editor-mode-switch"
import type { ContentMode, UploadImageHandler } from "./types"
export type MarkdownEditorRef = {
focus: () => void
}
type MarkdownEditorProps = {
value: string
onChange: (nextValue: string) => void
mode: ContentMode
onModeChange: (nextMode: ContentMode) => void
fullscreen: boolean
onToggleFullscreen: () => void
placeholder?: string
disabled?: boolean
onUploadImage?: UploadImageHandler
height: string
}
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
function MarkdownEditor(
{
value,
onChange,
mode,
onModeChange,
fullscreen,
onToggleFullscreen,
placeholder = "",
disabled = false,
onUploadImage,
height,
},
ref
) {
const editorId = useId()
const editorRef = useRef<ExposeParam>(null)
const { resolvedTheme } = useTheme()
const defToolbars = useMemo(
() => [
<EditorModeSwitch
key="mode-switch"
value={mode}
disabled={disabled}
onChange={onModeChange}
/>,
<NormalToolbar
key="toggle-fullscreen"
title={fullscreen ? "退出全屏" : "全屏"}
disabled={disabled}
onClick={onToggleFullscreen}
>
{fullscreen ? (
<Minimize2Icon className="h-[16px] w-[16px]" />
) : (
<Maximize2Icon className="h-[16px] w-[16px]" />
)}
</NormalToolbar>,
],
[disabled, fullscreen, mode, onModeChange, onToggleFullscreen]
)
useImperativeHandle(ref, () => ({
focus() {
editorRef.current?.focus()
},
}))
return (
<div
className="w-full rounded-lg border bg-background"
style={{ height }}
>
<div className="content-editor-markdown h-full">
<MdEditor
ref={editorRef}
id={editorId}
value={value}
onChange={onChange}
theme={resolvedTheme === "dark" ? "dark" : "light"}
preview={false}
toolbars={[
0,
"-",
"bold",
"underline",
"italic",
"strikeThrough",
"-",
"title",
"quote",
"unorderedList",
"orderedList",
"-",
"codeRow",
"code",
"link",
"image",
"-",
"revoke",
"next",
1,
"=",
"preview",
"previewOnly",
]}
defToolbars={defToolbars}
footers={[]}
noMermaid
noKatex
noHighlight
placeholder={placeholder}
disabled={disabled}
style={{ height: "100%" }}
onUploadImg={
onUploadImage
? async (files, callback) => {
const uploadedUrls: string[] = []
for (const file of files) {
const uploaded = await onUploadImage(file)
if (uploaded?.url) {
uploadedUrls.push(uploaded.url)
}
}
callback(uploadedUrls)
}
: undefined
}
/>
</div>
</div>
)
}
)
-12
View File
@@ -1,12 +0,0 @@
.content-editor-toolbar {
scrollbar-width: none;
cursor: grab;
}
.content-editor-toolbar::-webkit-scrollbar {
height: 0 !important;
}
.content-editor-toolbar:active {
cursor: grabbing;
}
-142
View File
@@ -1,142 +0,0 @@
"use client"
import "./toolbar.css"
import { useRef, type PointerEvent as ReactPointerEvent } from "react"
import { cn } from "@/lib/utils"
import type { EditorToolbarAction } from "./types"
type EditorToolbarProps = {
actions: ReadonlyArray<EditorToolbarAction>
}
function isSeparatorAction(
action: EditorToolbarAction
): action is Extract<EditorToolbarAction, { type: "separator" }> {
return "type" in action && action.type === "separator"
}
function isCustomAction(
action: EditorToolbarAction
): action is Extract<EditorToolbarAction, { type: "custom" }> {
return "type" in action && action.type === "custom"
}
export function EditorToolbar({ actions }: EditorToolbarProps) {
const containerRef = useRef<HTMLDivElement>(null)
const dragStateRef = useRef<{
pointerId: number
startX: number
startScrollLeft: number
moved: boolean
} | null>(null)
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return
}
const container = containerRef.current
if (!container || container.scrollWidth <= container.clientWidth) {
return
}
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startScrollLeft: container.scrollLeft,
moved: false,
}
container.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const container = containerRef.current
const dragState = dragStateRef.current
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const deltaX = event.clientX - dragState.startX
if (Math.abs(deltaX) > 3) {
dragState.moved = true
}
container.scrollLeft = dragState.startScrollLeft - deltaX
}
const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
const container = containerRef.current
const dragState = dragStateRef.current
if (!container || !dragState || dragState.pointerId !== event.pointerId) {
return
}
if (container.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId)
}
window.setTimeout(() => {
dragStateRef.current = null
}, 0)
}
return (
<div
ref={containerRef}
className="content-editor-toolbar overflow-x-auto overflow-y-hidden border-b border-border px-1 py-1 h-9.25"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
>
<div className="flex min-w-max items-center justify-between">
<div className="flex items-center">
{actions.map((action) => {
if (isSeparatorAction(action)) {
return (
<span
key={action.key}
aria-hidden="true"
className="relative mx-2 inline-block h-[0.9em] w-px self-center bg-border"
/>
)
}
if (isCustomAction(action)) {
return <div key={action.key}>{action.content}</div>
}
const Icon = action.icon
return (
<button
key={action.key}
type="button"
aria-label={action.label}
title={action.label}
disabled={action.disabled}
onClick={action.onClick}
onClickCapture={(event) => {
if (dragStateRef.current?.moved) {
event.preventDefault()
event.stopPropagation()
}
}}
data-pressed={action.pressed ? "true" : "false"}
className={cn(
"mx-[2px] cursor-pointer list-none rounded-[3px] border-none bg-transparent text-[#3f4a54] transition-all duration-300 select-none hover:bg-[#f2f2f2] disabled:cursor-not-allowed disabled:opacity-60 data-[pressed=true]:bg-[#f2f2f2] dark:text-[#999] dark:hover:bg-[#333] dark:data-[pressed=true]:bg-[#333]",
action.icon && !action.content
? "flex flex-col items-center px-[2px] py-0"
: "flex flex-col items-center px-[6px] py-0"
)}
>
{Icon ? <Icon className="box-content size-4 p-1" /> : null}
{action.content ? (
<span className="text-[12px] leading-none whitespace-nowrap">
{action.content}
</span>
) : null}
</button>
)
})}
</div>
</div>
</div>
)
}
-42
View File
@@ -1,42 +0,0 @@
import type { ComponentType, ReactNode } from "react"
export type ContentMode = "markdown" | "html"
export type ContentValue = {
mode: ContentMode
raw: string
}
export type UploadImageResult = {
url: string
alt?: string
title?: string
}
export type UploadImageHandler = (file: File) => Promise<UploadImageResult | null>
export type EditorToolbarButtonAction = {
key: string
label: string
icon?: ComponentType<{ className?: string }>
content?: ReactNode
onClick: () => void
disabled?: boolean
pressed?: boolean
}
export type EditorToolbarSeparatorAction = {
key: string
type: "separator"
}
export type EditorToolbarCustomAction = {
key: string
type: "custom"
content: ReactNode
}
export type EditorToolbarAction =
| EditorToolbarButtonAction
| EditorToolbarSeparatorAction
| EditorToolbarCustomAction