Add vendored neurapress editor shell (#779)
This commit is contained in:
parent
7c6876247c
commit
058336c0ea
@ -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.
|
||||
|
||||
|
||||
7
dashboard/src/app/dashboard/cms/page.tsx
Normal file
7
dashboard/src/app/dashboard/cms/page.tsx
Normal 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" />
|
||||
}
|
||||
6
dashboard/src/app/editor/page.tsx
Normal file
6
dashboard/src/app/editor/page.tsx
Normal 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" />
|
||||
}
|
||||
@ -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
|
||||
|
||||
299
dashboard/src/dashboard/apps/cms-editor/EditorShell.tsx
Normal file
299
dashboard/src/dashboard/apps/cms-editor/EditorShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
dashboard/src/dashboard/apps/cms-editor/README.md
Normal file
18
dashboard/src/dashboard/apps/cms-editor/README.md
Normal 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.
|
||||
25
dashboard/src/dashboard/apps/cms-editor/storage/index.ts
Normal file
25
dashboard/src/dashboard/apps/cms-editor/storage/index.ts
Normal 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>
|
||||
}
|
||||
75
dashboard/src/dashboard/apps/cms-editor/storage/local.ts
Normal file
75
dashboard/src/dashboard/apps/cms-editor/storage/local.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
16
dashboard/src/dashboard/apps/cms-editor/storage/remote.ts
Normal file
16
dashboard/src/dashboard/apps/cms-editor/storage/remote.ts
Normal 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
21
dashboard/vendor/neurapress/LICENSE
vendored
Normal 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
10
dashboard/vendor/neurapress/NOTICE
vendored
Normal 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).
|
||||
14
dashboard/vendor/neurapress/src/index.ts
vendored
Normal file
14
dashboard/vendor/neurapress/src/index.ts
vendored
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user