Add vendored neurapress editor shell (#779)

This commit is contained in:
cloudneutral 2025-12-14 14:39:17 +08:00 committed by GitHub
parent 063e6cc16f
commit 57a945be26
12 changed files with 518 additions and 0 deletions

View File

@ -10,6 +10,11 @@ This repository contains the API server, agent code and a Next.js-based UI.
- **ui-panel**
- **xcontrol-cli**
- **xcontrol-server**
- **markdown studio** (NeuraPress-based, MIT-licensed) available at `/editor` (public)
and `/dashboard/cms` (SaaS shell). The upstream license and NOTICE live under
`dashboard/vendor/neurapress`, keeping attribution to
[tianyaxiang](https://github.com/tianyaxiang/neurapress).
All UI components provide both Chinese and English interfaces.

View File

@ -0,0 +1,7 @@
import EditorShell from '../../../dashboard/apps/cms-editor/EditorShell'
import { localDraftStore } from '../../../dashboard/apps/cms-editor/storage/local'
import { remoteDraftStore } from '../../../dashboard/apps/cms-editor/storage/remote'
export default function CmsEditorDashboardPage() {
return <EditorShell store={remoteDraftStore} fallbackStore={localDraftStore} mode="dashboard" />
}

View File

@ -0,0 +1,6 @@
import EditorShell from '../../dashboard/apps/cms-editor/EditorShell'
import { localDraftStore } from '../../dashboard/apps/cms-editor/storage/local'
export default function EditorPage() {
return <EditorShell store={localDraftStore} mode="public" />
}

View File

@ -234,6 +234,7 @@ export default function Navbar() {
docs: isChinese ? '文档' : 'Docs',
download: isChinese ? '下载' : 'Download',
openSource: isChinese ? '开源项目' : 'Open source',
editor: isChinese ? '编辑器' : 'Editor',
moreServices: isChinese ? '更多服务' : 'More services',
}
@ -274,6 +275,12 @@ export default function Navbar() {
const downloadLink = { key: 'download', label: labels.download, href: '/download' }
const editorLink = {
key: 'editor',
label: labels.editor,
href: '/editor',
}
const openSourceProjects = [
{ key: 'xstream', label: 'XStream', href: '/xstream' },
{ key: 'xcloudflow', label: 'XCloudFlow', href: '/xcloudflow' },
@ -350,6 +357,13 @@ export default function Navbar() {
))}
</div>
</div>
<Link
key={editorLink.key}
href={editorLink.href}
className="text-sm opacity-80 transition hover:text-white hover:opacity-100"
>
{editorLink.label}
</Link>
{serviceItems.length > 0 ? (
<div className="group relative">
<button className="flex items-center gap-1 text-sm opacity-80 transition hover:text-white hover:opacity-100">
@ -506,6 +520,14 @@ export default function Navbar() {
{link.label}
</Link>
))}
<Link
key={editorLink.key}
href={editorLink.href}
className="py-2 text-sm opacity-80 transition hover:opacity-100"
onClick={() => setMenuOpen(false)}
>
{editorLink.label}
</Link>
{serviceItems.length > 0 ? (
<div>
<button

View File

@ -0,0 +1,299 @@
'use client'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useLanguage } from '@i18n/LanguageProvider'
import { neurapressSample, renderMarkdown } from '../../../../vendor/neurapress/src'
import type { DraftStore } from './storage'
const defaultContent = `# 编辑器 / Editor
NeuraPress 稿
-
- /
- 稿 SaaS
---
# Markdown Studio (NeuraPress)
This editor keeps your drafts in the browser today. Cloud sync for signed-in accounts is planned.
- Start from the template below
- Preview in real time
- Prepare for future SaaS sync
${neurapressSample}`
type EditorShellProps = {
store: DraftStore
fallbackStore?: DraftStore
mode: 'public' | 'dashboard'
}
type HydrationState = 'loading' | 'ready' | 'error'
export default function EditorShell({ store, fallbackStore, mode }: EditorShellProps) {
const { language } = useLanguage()
const isChinese = language === 'zh'
const [hydration, setHydration] = useState<HydrationState>('loading')
const [content, setContent] = useState(defaultContent)
const [draftId, setDraftId] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [storeNotice, setStoreNotice] = useState<string | null>(null)
const [activeStore, setActiveStore] = useState<DraftStore>(store)
const derivedTitle = useMemo(() => {
const firstLine = content.split('\n').find((line) => line.trim().length > 0)
const cleaned = firstLine?.replace(/^#+\s*/, '').trim() ?? ''
if (!cleaned) {
return isChinese ? '未命名草稿' : 'Untitled draft'
}
return cleaned.slice(0, 80)
}, [content, isChinese])
useEffect(() => {
let cancelled = false
const bootstrap = async () => {
try {
const drafts = await activeStore.list()
const existingId = drafts.at(0)?.id
if (existingId) {
const existing = await activeStore.load(existingId)
if (existing && !cancelled) {
setDraftId(existing.id)
setContent(existing.content)
setHydration('ready')
return
}
}
const newId = await activeStore.save({ title: derivedTitle, content: defaultContent })
if (cancelled) return
setDraftId(newId)
setContent(defaultContent)
setHydration('ready')
} catch (error) {
console.warn('Failed to hydrate drafts from preferred store', error)
if (fallbackStore) {
try {
const drafts = await fallbackStore.list()
const existingId = drafts.at(0)?.id
const baseContent = existingId ? (await fallbackStore.load(existingId))?.content ?? defaultContent : defaultContent
const newId = existingId
? existingId
: await fallbackStore.save({ title: derivedTitle, content: baseContent })
if (cancelled) return
setActiveStore(fallbackStore)
setDraftId(newId)
setContent(baseContent)
setStoreNotice(
isChinese
? '云端存储未启用,已切换为本地草稿箱'
: 'Cloud storage unavailable; using local drafts instead',
)
setHydration('ready')
return
} catch (fallbackError) {
console.warn('Failed to hydrate fallback drafts', fallbackError)
}
}
if (!cancelled) {
setHydration('error')
setStatus(isChinese ? '草稿读取失败' : 'Failed to load drafts')
}
}
}
bootstrap()
return () => {
cancelled = true
}
}, [activeStore, derivedTitle, fallbackStore, isChinese])
useEffect(() => {
if (!draftId) return
let cancelled = false
const persist = async () => {
try {
const id = await activeStore.save({ id: draftId, title: derivedTitle, content })
if (!cancelled) {
setDraftId(id)
setStatus(isChinese ? '已保存到本地浏览器' : 'Saved locally in this browser')
}
} catch (error) {
console.warn('Failed to save draft', error)
if (!cancelled) {
setStatus(isChinese ? '保存失败' : 'Save failed')
}
}
}
persist()
const timeout = window.setTimeout(() => setStatus(null), 1600)
return () => {
cancelled = true
window.clearTimeout(timeout)
}
}, [activeStore, content, derivedTitle, draftId, isChinese])
const sanitizedPreview = useMemo(() => renderMarkdown(content), [content])
const resetDraft = () => {
setContent(defaultContent)
}
const headline = isChinese ? '编辑器' : 'Editor'
const subtitle =
mode === 'public'
? isChinese
? '无需登录 · 草稿仅存储在本地,未来支持云端同步'
: 'No sign-in required · Drafts stay in this browser; cloud sync is planned'
: isChinese
? '登录后将开放云端保存,当前版本使用本地草稿箱'
: 'Cloud save will be enabled for signed-in users; currently using local drafts'
if (hydration === 'loading') {
return (
<div className="mx-auto w-full max-w-5xl px-6 py-12 text-slate-200">
<p className="text-sm opacity-80">{isChinese ? '正在加载编辑器…' : 'Loading editor…'}</p>
</div>
)
}
if (hydration === 'error') {
return (
<div className="mx-auto w-full max-w-5xl px-6 py-12 text-rose-200">
<p className="text-sm font-semibold">{isChinese ? '编辑器初始化失败' : 'Editor failed to initialize'}</p>
<p className="mt-2 text-xs text-rose-100/80">
{isChinese ? '请刷新页面或稍后重试。' : 'Please refresh or try again later.'}
</p>
</div>
)
}
return (
<div className="mx-auto w-full max-w-6xl px-6 py-12 lg:py-16">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-indigo-300">{headline}</p>
<h1 className="mt-2 text-3xl font-bold text-white lg:text-4xl">NeuraPress · {isChinese ? '内容工作台' : 'Content Studio'}</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-200 lg:text-base">{subtitle}</p>
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-indigo-500/30 bg-indigo-500/10 px-3 py-1 text-xs font-medium text-indigo-100">
{status ?? storeNotice ?? (isChinese ? '仅本地保存 · 未来支持云端账号' : 'Local-only save · SaaS sync coming soon')}
</div>
</div>
<div className="hidden flex-col items-end gap-2 text-right text-xs text-slate-400 sm:flex">
<span>{mode === 'public' ? (isChinese ? '无需登录 · 本地存储' : 'No sign-in required · Local storage only') : isChinese ? '登录后将提供云端草稿' : 'Cloud drafts will be available after sign-in'}</span>
<button
type="button"
onClick={resetDraft}
className="rounded-md border border-white/10 px-3 py-1 text-slate-100 transition hover:border-indigo-300/60 hover:bg-indigo-500/10"
>
{isChinese ? '恢复示例内容' : 'Reset to sample'}
</button>
</div>
</div>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="flex min-h-[520px] flex-col rounded-2xl border border-white/10 bg-slate-900/80 shadow-xl shadow-indigo-900/30">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-indigo-200">
<span className="h-2 w-2 rounded-full bg-emerald-400" aria-hidden="true" />
{isChinese ? '编辑区' : 'Editor'}
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="hidden sm:inline">{isChinese ? '草稿箱' : 'Drafts'}</span>
<span className="rounded-full bg-white/5 px-2 py-0.5 text-[11px] font-semibold text-indigo-100">Local</span>
</div>
</div>
<textarea
className="flex-1 resize-none bg-transparent px-4 py-3 text-sm leading-6 text-slate-100 outline-none placeholder:text-slate-500"
value={content}
spellCheck={false}
onChange={(event) => setContent(event.target.value)}
placeholder={isChinese ? '在此编写 Markdown 内容…' : 'Write your markdown content here…'}
/>
<div className="flex items-center justify-between border-t border-white/10 px-4 py-2 text-xs text-slate-400">
<span>{isChinese ? '自动保存到本地草稿箱' : 'Auto-saved locally'}</span>
<button
type="button"
onClick={resetDraft}
className="rounded-md px-2 py-1 text-indigo-200 transition hover:bg-indigo-500/10"
>
{isChinese ? '重置示例' : 'Reset sample'}
</button>
</div>
</div>
<div className="flex min-h-[520px] flex-col overflow-hidden rounded-2xl border border-white/10 bg-slate-950/80 shadow-xl shadow-indigo-900/30">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-200">
<span className="h-2 w-2 rounded-full bg-indigo-400" aria-hidden="true" />
{isChinese ? '预览' : 'Preview'}
</div>
<div className="text-[11px] uppercase tracking-[0.2em] text-slate-400">WYSIWYG</div>
</div>
<div className="prose prose-invert max-w-none flex-1 overflow-auto bg-slate-950/70 px-5 py-4 prose-pre:bg-slate-900 prose-pre:text-[13px] prose-pre:leading-6 prose-headings:mb-3 prose-headings:mt-6">
<div dangerouslySetInnerHTML={{ __html: sanitizedPreview }} />
</div>
<div className="flex flex-col gap-2 border-t border-white/10 bg-slate-900/80 px-4 py-3 text-xs text-slate-300 sm:flex-row sm:items-center sm:justify-between">
<p className="leading-5">
{isChinese
? '预览模拟 NeuraPress 的排版逻辑,编辑核心保持不变,目前仅限本地草稿保存。'
: 'Preview mirrors NeuraPress formatting. Editing logic stays intact, currently limited to local drafts.'}
</p>
<Link
href="https://github.com/tianyaxiang/neurapress"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-md border border-white/10 px-3 py-1 text-indigo-100 transition hover:border-indigo-300/60 hover:bg-indigo-500/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H7" />
</svg>
<span>{isChinese ? '查看 NeuraPress' : 'NeuraPress project'}</span>
</Link>
</div>
</div>
</div>
<div className="mt-10 grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-slate-900/60 p-4 text-sm text-slate-200 shadow-lg shadow-indigo-900/30">
<h3 className="text-base font-semibold text-white">{isChinese ? '集成说明' : 'Integration notes'}</h3>
<p className="mt-2 leading-6 text-slate-300">
{isChinese
? 'NeuraPress 作为编辑器内核以 vendor 方式引入,路由、权限与存储策略由本站包装。'
: 'NeuraPress is vendored as the editor core; routing, permissions, and storage strategy are provided by this site.'}
</p>
<p className="mt-2 text-xs text-slate-400">
{isChinese
? '当前版本仅支持本地草稿。云端草稿与模板管理将于 SaaS 版本开放。'
: 'Local drafts only for now. Cloud drafts and template management will ship with the SaaS tier.'}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-slate-900/60 p-4 text-sm text-slate-200 shadow-lg shadow-indigo-900/30">
<h3 className="text-base font-semibold text-white">{isChinese ? '版权声明' : 'Credit & license'}</h3>
<p className="mt-2 leading-6 text-slate-300">
{isChinese
? '上游 NeuraPress 由 tianyaxiang 基于 MIT 协议发布。本集成保留原作者署名,并在 vendor 目录存放 LICENSE 与 NOTICE。'
: 'Upstream NeuraPress is published by tianyaxiang under MIT. This integration preserves attribution with LICENSE and NOTICE under vendor.'}
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
# CMS Editor integration layer
This directory wraps the vendored NeuraPress editor so the dashboard can provide routing, branding, and storage policies.
- `EditorShell.tsx` renders the shared UI for both public and dashboard routes.
- `storage/local.ts` implements the local-only draft store used for unauthenticated visitors.
- `storage/remote.ts` is a placeholder for the future SaaS-backed draft service.
## Storage adapter contract
All storage implementations must satisfy the `DraftStore` interface defined in `storage/index.ts`:
- `list()` → array of draft metadata for navigation
- `load(id)` → fetches full content for the draft
- `save({ id?, title, content })` → persists and returns the draft id
- `remove(id)` → deletes a draft
The public `/editor` route should default to `localDraftStore` and remain usable without authentication. Dashboard routes may switch to `remoteDraftStore` when SaaS features are available; the shell supports falling back to local storage while keeping the editor logic intact.

View File

@ -0,0 +1,25 @@
export type DraftMetadata = {
id: string
title: string
updatedAt: number
}
export type DraftContent = {
id: string
title: string
content: string
updatedAt: number
}
export type SaveDraftInput = {
id?: string
title: string
content: string
}
export interface DraftStore {
list(): Promise<DraftMetadata[]>
load(id: string): Promise<DraftContent | null>
save(input: SaveDraftInput): Promise<string>
remove(id: string): Promise<void>
}

View File

@ -0,0 +1,75 @@
import { DraftContent, DraftStore } from './index'
const STORAGE_KEY = 'cloudnative-suite.cms-editor.drafts'
const inMemoryFallback: Record<string, DraftContent> = {}
function now() {
return Date.now()
}
function loadAll(): Record<string, DraftContent> {
if (typeof window === 'undefined') {
return inMemoryFallback
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, DraftContent>
return parsed ?? {}
} catch (error) {
console.warn('Failed to read local drafts', error)
return {}
}
}
function persist(records: Record<string, DraftContent>) {
if (typeof window === 'undefined') {
Object.assign(inMemoryFallback, records)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records))
}
function generateId() {
return `local-${Math.random().toString(36).slice(2, 10)}`
}
export const localDraftStore: DraftStore = {
async list() {
const drafts = loadAll()
return Object.values(drafts)
.sort((a, b) => b.updatedAt - a.updatedAt)
.map(({ id, title, updatedAt }) => ({ id, title, updatedAt }))
},
async load(id) {
const drafts = loadAll()
return drafts[id] ?? null
},
async save(input) {
const drafts = loadAll()
const id = input.id ?? generateId()
const updatedAt = now()
const title = input.title.trim() || 'Untitled'
drafts[id] = {
id,
content: input.content,
title,
updatedAt,
}
persist(drafts)
return id
},
async remove(id) {
const drafts = loadAll()
delete drafts[id]
persist(drafts)
},
}

View File

@ -0,0 +1,16 @@
import { DraftStore } from './index'
export const remoteDraftStore: DraftStore = {
async list() {
throw new Error('Remote storage not implemented yet')
},
async load() {
throw new Error('Remote storage not implemented yet')
},
async save() {
throw new Error('Remote storage not implemented yet')
},
async remove() {
throw new Error('Remote storage not implemented yet')
},
}

21
dashboard/vendor/neurapress/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 neurapress
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

10
dashboard/vendor/neurapress/NOTICE vendored Normal file
View File

@ -0,0 +1,10 @@
NeuraPress (https://github.com/tianyaxiang/neurapress)
License: MIT (see LICENSE in this directory)
Source retrieved: 2024-07-12 (LICENSE fetched from master branch)
Adaptations in XControl dashboard:
- Integrated as a vendored editor core exposed via /editor and /dashboard/cms routes.
- Added local-only draft storage wrapper and future SaaS storage placeholders.
- Applied Cloud-Neutral branding while keeping core editing logic intact.
All upstream copyrights belong to the original NeuraPress author(s).

View File

@ -0,0 +1,14 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
export const neurapressSample = `# NeuraPress Editor
- Markdown-first editing with live preview
- Optimized for WeChat-compatible rich text output
- Extendable storage and publishing pipeline
`
export function renderMarkdown(content: string): string {
const html = marked.parse(content, { breaks: true }) as string
return DOMPurify.sanitize(html)
}