Initial commit
This commit is contained in:
parent
296ca32039
commit
a9828c93d0
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Cache directories
|
||||||
|
.cache/
|
||||||
|
.npm/
|
||||||
|
.pnpm-store/
|
51
package.json
Normal file
51
package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "neurapress",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bytemd/plugin-gfm": "^1.21.0",
|
||||||
|
"@bytemd/plugin-highlight": "^1.21.0",
|
||||||
|
"@bytemd/react": "^1.21.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-select": "^2.1.5",
|
||||||
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
|
"@tiptap/extension-image": "^2.2.4",
|
||||||
|
"@tiptap/extension-link": "^2.2.4",
|
||||||
|
"@tiptap/pm": "^2.2.4",
|
||||||
|
"@tiptap/react": "^2.2.4",
|
||||||
|
"@tiptap/starter-kit": "^2.2.4",
|
||||||
|
"@types/marked": "^6.0.0",
|
||||||
|
"bytemd": "^1.21.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.474.0",
|
||||||
|
"marked": "^15.0.6",
|
||||||
|
"next": "14.1.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shadcn/ui": "^0.0.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@types/node": "^20.17.16",
|
||||||
|
"@types/react": "^18.2.57",
|
||||||
|
"@types/react-dom": "^18.2.19",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
4174
pnpm-lock.yaml
Normal file
4174
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
203
src/app/globals.css
Normal file
203
src/app/globals.css
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模板样式 */
|
||||||
|
.prose-elegant {
|
||||||
|
--tw-prose-body: #2c3e50;
|
||||||
|
--tw-prose-headings: #16a34a;
|
||||||
|
--tw-prose-links: #3b82f6;
|
||||||
|
--tw-prose-code: #475569;
|
||||||
|
--tw-prose-quotes: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-modern {
|
||||||
|
--tw-prose-body: #374151;
|
||||||
|
--tw-prose-headings: #111827;
|
||||||
|
--tw-prose-links: #2563eb;
|
||||||
|
--tw-prose-code: #374151;
|
||||||
|
--tw-prose-quotes: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-creative {
|
||||||
|
--tw-prose-body: #4a5568;
|
||||||
|
--tw-prose-headings: #2d3748;
|
||||||
|
--tw-prose-links: #4299e1;
|
||||||
|
--tw-prose-code: #4a5568;
|
||||||
|
--tw-prose-quotes: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-minimal {
|
||||||
|
--tw-prose-body: #1a1a1a;
|
||||||
|
--tw-prose-headings: #000000;
|
||||||
|
--tw-prose-links: #0066cc;
|
||||||
|
--tw-prose-code: #1a1a1a;
|
||||||
|
--tw-prose-quotes: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览内容样式 */
|
||||||
|
.preview-content {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1em auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化预览区域滚动效果 */
|
||||||
|
.preview-container {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模板预览卡片样式 */
|
||||||
|
.template-preview-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(to right, var(--primary), var(--primary-foreground));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-preview-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ByteMD 编辑器样式 */
|
||||||
|
.bytemd-preview {
|
||||||
|
@apply prose max-w-none p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-modern .bytemd-preview {
|
||||||
|
--tw-prose-body: #374151;
|
||||||
|
--tw-prose-headings: #111827;
|
||||||
|
--tw-prose-links: #2563eb;
|
||||||
|
--tw-prose-code: #374151;
|
||||||
|
--tw-prose-quotes: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-elegant .bytemd-preview {
|
||||||
|
--tw-prose-body: #4a5568;
|
||||||
|
--tw-prose-headings: #2d3748;
|
||||||
|
--tw-prose-links: #4299e1;
|
||||||
|
--tw-prose-code: #4a5568;
|
||||||
|
--tw-prose-quotes: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-minimal .bytemd-preview {
|
||||||
|
--tw-prose-body: #1a1a1a;
|
||||||
|
--tw-prose-headings: #000000;
|
||||||
|
--tw-prose-links: #0066cc;
|
||||||
|
--tw-prose-code: #1a1a1a;
|
||||||
|
--tw-prose-quotes: #666666;
|
||||||
|
}
|
28
src/app/layout.tsx
Normal file
28
src/app/layout.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "NeuraPress - 专业的内容转换工具",
|
||||||
|
description: "将 Markdown 转换为微信公众号和小红书样式的专业工具",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" className="h-full">
|
||||||
|
<body className={cn(
|
||||||
|
inter.className,
|
||||||
|
"h-full bg-gray-50 antialiased"
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
54
src/app/page.tsx
Normal file
54
src/app/page.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { MainNav } from '@/components/nav/MainNav'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
<MainNav />
|
||||||
|
<main className="py-10">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-gray-900">
|
||||||
|
内容转换工具
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
选择需要使用的功能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Link
|
||||||
|
href="/wechat"
|
||||||
|
className="group relative rounded-lg border border-gray-200 bg-white p-6 hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
微信公众号
|
||||||
|
<span className="absolute inset-0"></span>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
将内容转换为微信公众号格式,支持多种样式模板
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/xiaohongshu"
|
||||||
|
className="group relative rounded-lg border border-gray-200 bg-white p-6 hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
小红书
|
||||||
|
<span className="absolute inset-0"></span>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
将内容转换为小红书笔记格式,轻松发布内容
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
26
src/app/wechat/page.tsx
Normal file
26
src/app/wechat/page.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { MainNav } from '@/components/nav/MainNav'
|
||||||
|
import WechatEditor from '@/components/editor/WechatEditor'
|
||||||
|
|
||||||
|
export default function WechatPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
<MainNav />
|
||||||
|
<main className="py-6">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900">
|
||||||
|
微信公众号编辑器
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
|
编辑内容,预览效果,一键复制
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border">
|
||||||
|
<WechatEditor />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
28
src/app/xiaohongshu/page.tsx
Normal file
28
src/app/xiaohongshu/page.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { MainNav } from '@/components/nav/MainNav'
|
||||||
|
import XiaohongshuEditor from '@/components/editor/XiaohongshuEditor'
|
||||||
|
|
||||||
|
export default function XiaohongshuPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-full">
|
||||||
|
<MainNav />
|
||||||
|
<main className="py-10">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-gray-900">
|
||||||
|
小红书笔记编辑器
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
编辑内容,预览效果,一键复制
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||||
|
<XiaohongshuEditor />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
63
src/components/editor/Editor.tsx
Normal file
63
src/components/editor/Editor.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { TemplateSelector } from '../template/TemplateSelector'
|
||||||
|
import { templates } from '@/config/templates'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Editor = () => {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
|
||||||
|
const [preview, setPreview] = useState(false)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [StarterKit],
|
||||||
|
content: '<p>开始编辑...</p>',
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[500px]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTemplateSelect = (templateId: string) => {
|
||||||
|
setSelectedTemplate(templateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPreviewContent = () => {
|
||||||
|
if (!editor || !selectedTemplate) return ''
|
||||||
|
const template = templates.find(t => t.id === selectedTemplate)
|
||||||
|
if (!template) return editor.getHTML()
|
||||||
|
return template.transform(editor.getHTML())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<TemplateSelector onSelect={handleTemplateSelect} />
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview(!preview)}
|
||||||
|
className="px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{preview ? '编辑' : '预览'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
{preview ? (
|
||||||
|
<div className={cn(
|
||||||
|
"prose max-w-none",
|
||||||
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
||||||
|
)}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: getPreviewContent() }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Editor
|
132
src/components/editor/EditorToolbar.tsx
Normal file
132
src/components/editor/EditorToolbar.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Editor } from '@tiptap/react'
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Minus,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Toggle } from '@/components/ui/toggle'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface EditorToolbarProps {
|
||||||
|
editor: Editor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorToolbar({ editor }: EditorToolbarProps) {
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b">
|
||||||
|
<div className="flex flex-wrap gap-1 p-1">
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('heading', { level: 1 })}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
>
|
||||||
|
<Heading1 className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('heading', { level: 2 })}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
>
|
||||||
|
<Heading2 className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('heading', { level: 3 })}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
>
|
||||||
|
<Heading3 className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('bold')}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('italic')}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('bulletList')}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('orderedList')}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('blockquote')}
|
||||||
|
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('horizontalRule')}
|
||||||
|
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
pressed={editor.isActive('link')}
|
||||||
|
onPressedChange={() => {
|
||||||
|
const url = window.prompt('URL')
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
<Toggle
|
||||||
|
size="sm"
|
||||||
|
onPressedChange={() => {
|
||||||
|
const url = window.prompt('Image URL')
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setImage({ src: url }).run()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image className="h-4 w-4" />
|
||||||
|
</Toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
190
src/components/editor/StyleConfigDialog.tsx
Normal file
190
src/components/editor/StyleConfigDialog.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Settings } from 'lucide-react'
|
||||||
|
import { type RendererOptions } from '@/lib/markdown'
|
||||||
|
|
||||||
|
const stylePresets = {
|
||||||
|
default: {
|
||||||
|
name: '默认样式',
|
||||||
|
options: {
|
||||||
|
fontSize: {
|
||||||
|
h1: '24px',
|
||||||
|
h2: '20px',
|
||||||
|
h3: '18px',
|
||||||
|
paragraph: '15px',
|
||||||
|
code: '14px'
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
text: '#333333',
|
||||||
|
heading: '#1a1a1a',
|
||||||
|
link: '#576b95',
|
||||||
|
code: '#333333',
|
||||||
|
quote: '#666666'
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
paragraph: '20px',
|
||||||
|
heading: '30px',
|
||||||
|
list: '20px',
|
||||||
|
quote: '20px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modern: {
|
||||||
|
name: '现代简约',
|
||||||
|
options: {
|
||||||
|
fontSize: {
|
||||||
|
h1: '28px',
|
||||||
|
h2: '24px',
|
||||||
|
h3: '20px',
|
||||||
|
paragraph: '16px',
|
||||||
|
code: '15px'
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
text: '#2d3748',
|
||||||
|
heading: '#1a202c',
|
||||||
|
link: '#4299e1',
|
||||||
|
code: '#2d3748',
|
||||||
|
quote: '#718096'
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
paragraph: '24px',
|
||||||
|
heading: '36px',
|
||||||
|
list: '24px',
|
||||||
|
quote: '24px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StyleConfigDialogProps {
|
||||||
|
value: RendererOptions
|
||||||
|
onChange: (options: RendererOptions) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StyleConfigDialog({ value, onChange }: StyleConfigDialogProps) {
|
||||||
|
const [currentOptions, setCurrentOptions] = useState<RendererOptions>(value)
|
||||||
|
|
||||||
|
const handlePresetChange = (preset: keyof typeof stylePresets) => {
|
||||||
|
const newOptions = stylePresets[preset].options
|
||||||
|
setCurrentOptions(newOptions)
|
||||||
|
onChange(newOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOptionChange = (
|
||||||
|
category: keyof RendererOptions,
|
||||||
|
subcategory: string,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
const newOptions = {
|
||||||
|
...currentOptions,
|
||||||
|
[category]: {
|
||||||
|
...currentOptions[category],
|
||||||
|
[subcategory]: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCurrentOptions(newOptions)
|
||||||
|
onChange(newOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
样式设置
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>样式配置</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="presets" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="presets">预设样式</TabsTrigger>
|
||||||
|
<TabsTrigger value="font">字体</TabsTrigger>
|
||||||
|
<TabsTrigger value="colors">颜色</TabsTrigger>
|
||||||
|
<TabsTrigger value="spacing">间距</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="presets" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{Object.entries(stylePresets).map(([key, preset]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="p-4 border rounded-lg cursor-pointer hover:border-primary"
|
||||||
|
onClick={() => handlePresetChange(key as keyof typeof stylePresets)}
|
||||||
|
>
|
||||||
|
<h3 className="font-medium mb-2">{preset.name}</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p style={{ fontSize: preset.options.fontSize.paragraph, color: preset.options.colors.text }}>
|
||||||
|
正文示例
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: preset.options.fontSize.h2, color: preset.options.colors.heading }}>
|
||||||
|
标题示例
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="font" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{Object.entries(currentOptions.fontSize || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label>{key}</Label>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleOptionChange('fontSize', key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="colors" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{Object.entries(currentOptions.colors || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label>{key}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleOptionChange('colors', key, e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={value}
|
||||||
|
className="w-12"
|
||||||
|
onChange={(e) => handleOptionChange('colors', key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="spacing" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{Object.entries(currentOptions.spacing || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label>{key}</Label>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleOptionChange('spacing', key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
217
src/components/editor/WechatEditor.tsx
Normal file
217
src/components/editor/WechatEditor.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Editor } from '@bytemd/react'
|
||||||
|
import gfm from '@bytemd/plugin-gfm'
|
||||||
|
import highlight from '@bytemd/plugin-highlight'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { templates } from '@/config/wechat-templates'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Copy, Smartphone } from 'lucide-react'
|
||||||
|
import { WechatStylePicker } from '../template/WechatStylePicker'
|
||||||
|
import { StyleConfigDialog } from './StyleConfigDialog'
|
||||||
|
import { convertToWechat } from '@/lib/markdown'
|
||||||
|
import { type RendererOptions } from '@/lib/markdown'
|
||||||
|
import { TemplateManager } from '../template/TemplateManager'
|
||||||
|
import 'bytemd/dist/index.css'
|
||||||
|
|
||||||
|
const plugins = [
|
||||||
|
gfm(),
|
||||||
|
highlight(),
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function WechatEditor() {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
|
||||||
|
const [showPreview, setShowPreview] = useState(true)
|
||||||
|
const [styleOptions, setStyleOptions] = useState<RendererOptions>({})
|
||||||
|
|
||||||
|
const handleEditorChange = (v: any) => {
|
||||||
|
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
setValue(v)
|
||||||
|
} else if (v?.value && typeof v.value === 'string') {
|
||||||
|
setValue(v.value)
|
||||||
|
} else {
|
||||||
|
console.warn('Unexpected editor value:', v)
|
||||||
|
setValue('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPreviewContent = () => {
|
||||||
|
if (!value) return ''
|
||||||
|
|
||||||
|
const template = templates.find(t => t.id === selectedTemplate)
|
||||||
|
const mergedOptions = {
|
||||||
|
...styleOptions,
|
||||||
|
...(template?.options || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = convertToWechat(value, mergedOptions)
|
||||||
|
if (!template?.transform) return html
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transformed = template.transform(html)
|
||||||
|
if (transformed && typeof transformed === 'object') {
|
||||||
|
const result = transformed as { html?: string; content?: string }
|
||||||
|
if (result.html) return result.html
|
||||||
|
if (result.content) return result.content
|
||||||
|
return JSON.stringify(transformed)
|
||||||
|
}
|
||||||
|
return transformed || html
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Template transformation error:', error)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyContent = () => {
|
||||||
|
const content = getPreviewContent()
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
.then(() => alert('内容已复制到剪贴板'))
|
||||||
|
.catch(err => console.error('复制失败:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTemplateChange = () => {
|
||||||
|
setValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
const previewContent = document.querySelector('.preview-content')
|
||||||
|
if (!previewContent) return
|
||||||
|
|
||||||
|
// 创建一个临时容器来保持样式
|
||||||
|
const tempDiv = document.createElement('div')
|
||||||
|
tempDiv.innerHTML = previewContent.innerHTML
|
||||||
|
|
||||||
|
// 复制计算后的样式
|
||||||
|
const styles = window.getComputedStyle(previewContent)
|
||||||
|
const importantStyles = {
|
||||||
|
'font-family': styles.fontFamily,
|
||||||
|
'font-size': styles.fontSize,
|
||||||
|
'color': styles.color,
|
||||||
|
'line-height': styles.lineHeight,
|
||||||
|
'text-align': styles.textAlign,
|
||||||
|
'white-space': styles.whiteSpace,
|
||||||
|
'margin': styles.margin,
|
||||||
|
'padding': styles.padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用样式到临时容器
|
||||||
|
Object.assign(tempDiv.style, importantStyles)
|
||||||
|
|
||||||
|
// 将临时容器添加到文档中(隐藏)
|
||||||
|
tempDiv.style.position = 'fixed'
|
||||||
|
tempDiv.style.left = '-9999px'
|
||||||
|
document.body.appendChild(tempDiv)
|
||||||
|
|
||||||
|
// 创建选区并复制
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNode(tempDiv)
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection) {
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
alert('预览内容(带格式)已复制到剪贴板')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('复制失败:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.removeAllRanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理临时元素
|
||||||
|
document.body.removeChild(tempDiv)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<WechatStylePicker
|
||||||
|
value={selectedTemplate}
|
||||||
|
onSelect={setSelectedTemplate}
|
||||||
|
/>
|
||||||
|
<TemplateManager onTemplateChange={handleTemplateChange} />
|
||||||
|
<StyleConfigDialog
|
||||||
|
value={styleOptions}
|
||||||
|
onChange={setStyleOptions}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-3 py-2 rounded-md text-sm",
|
||||||
|
showPreview
|
||||||
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copyContent}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制源码
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制预览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
<div className={cn(
|
||||||
|
"border-r bg-white transition-all duration-200",
|
||||||
|
showPreview ? "w-1/2" : "w-full",
|
||||||
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
||||||
|
)}>
|
||||||
|
<div className="h-[calc(100vh-140px)] overflow-auto">
|
||||||
|
<Editor
|
||||||
|
value={value}
|
||||||
|
plugins={plugins}
|
||||||
|
onChange={handleEditorChange}
|
||||||
|
editorConfig={{
|
||||||
|
mode: 'split'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<div className="w-1/2 bg-gray-50">
|
||||||
|
<div className="sticky top-0 border-b bg-white p-3 z-10">
|
||||||
|
<div className="mx-auto w-8 h-1 bg-gray-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="p-6 preview-container" style={{ height: 'calc(100vh - 140px)', overflowY: 'auto' }}>
|
||||||
|
<div className="max-w-[375px] mx-auto bg-white shadow-sm rounded-lg">
|
||||||
|
<div className={cn(
|
||||||
|
"prose max-w-none p-4",
|
||||||
|
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
|
||||||
|
)}>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
|
||||||
|
className="preview-content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
83
src/components/editor/XiaohongshuEditor.tsx
Normal file
83
src/components/editor/XiaohongshuEditor.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Copy, Eye, Pencil } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function XiaohongshuEditor() {
|
||||||
|
const [preview, setPreview] = useState(false)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [StarterKit],
|
||||||
|
content: '<p>开始编辑小红书笔记...</p>',
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[500px] max-w-none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPreviewContent = () => {
|
||||||
|
if (!editor) return ''
|
||||||
|
return editor.getHTML()
|
||||||
|
.replace(/<h1>/g, '<h1 style="font-size: 20px; font-weight: bold; margin-bottom: 0.5em;">')
|
||||||
|
.replace(/<p>/g, '<p style="margin-bottom: 0.8em; color: #222; line-height: 1.6;">')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyContent = () => {
|
||||||
|
const content = getPreviewContent()
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
.then(() => alert('内容已复制到剪贴板'))
|
||||||
|
.catch(err => console.error('复制失败:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview(!preview)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-gray-100 text-gray-700 text-sm hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
{preview ? (
|
||||||
|
<>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
编辑
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
预览
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<button
|
||||||
|
onClick={copyContent}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 text-sm"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
复制内容
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"border rounded-lg",
|
||||||
|
preview ? "bg-gray-50" : "bg-white"
|
||||||
|
)}>
|
||||||
|
{preview ? (
|
||||||
|
<div className="prose max-w-none xiaohongshu-template p-6">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: getPreviewContent() }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
53
src/components/nav/MainNav.tsx
Normal file
53
src/components/nav/MainNav.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: '微信公众号', href: '/wechat' },
|
||||||
|
{ name: '小红书', href: '/xiaohongshu' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function MainNav() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="border-b border-gray-200 bg-white">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="flex h-16 items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Link href="/" className="text-xl font-bold text-primary">
|
||||||
|
NeuraPress
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="ml-10 flex items-baseline space-x-4">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm font-medium rounded-md",
|
||||||
|
pathname === item.href
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
专业的内容转换工具
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
339
src/components/template/TemplateManager.tsx
Normal file
339
src/components/template/TemplateManager.tsx
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Settings2, Download, Upload, Star, Plus } from 'lucide-react'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { templates as defaultTemplates, type Template } from '@/config/wechat-templates'
|
||||||
|
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface TemplateManagerProps {
|
||||||
|
onTemplateChange: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StyleConfig {
|
||||||
|
base: {
|
||||||
|
'--md-primary-color': string
|
||||||
|
'text-align': string
|
||||||
|
'line-height': string
|
||||||
|
}
|
||||||
|
block: {
|
||||||
|
container?: React.CSSProperties
|
||||||
|
h1?: React.CSSProperties
|
||||||
|
h2?: React.CSSProperties
|
||||||
|
h3?: React.CSSProperties
|
||||||
|
h4?: React.CSSProperties
|
||||||
|
h5?: React.CSSProperties
|
||||||
|
h6?: React.CSSProperties
|
||||||
|
p?: React.CSSProperties
|
||||||
|
blockquote?: React.CSSProperties
|
||||||
|
blockquote_p?: React.CSSProperties
|
||||||
|
code_pre?: React.CSSProperties
|
||||||
|
code?: React.CSSProperties
|
||||||
|
image?: React.CSSProperties
|
||||||
|
ol?: React.CSSProperties
|
||||||
|
ul?: React.CSSProperties
|
||||||
|
footnotes?: React.CSSProperties
|
||||||
|
figure?: React.CSSProperties
|
||||||
|
hr?: React.CSSProperties
|
||||||
|
}
|
||||||
|
inline: {
|
||||||
|
listitem?: React.CSSProperties
|
||||||
|
codespan?: React.CSSProperties
|
||||||
|
em?: React.CSSProperties
|
||||||
|
link?: React.CSSProperties
|
||||||
|
wx_link?: React.CSSProperties
|
||||||
|
strong?: React.CSSProperties
|
||||||
|
table?: React.CSSProperties
|
||||||
|
thead?: React.CSSProperties
|
||||||
|
td?: React.CSSProperties
|
||||||
|
footnote?: React.CSSProperties
|
||||||
|
figcaption?: React.CSSProperties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateManager({ onTemplateChange }: TemplateManagerProps) {
|
||||||
|
const [customTemplates, setCustomTemplates] = useLocalStorage<Template[]>('custom-templates', [])
|
||||||
|
const [favoriteIds, setFavoriteIds] = useLocalStorage<string[]>('favorite-templates', [])
|
||||||
|
const [newTemplate, setNewTemplate] = useState<Partial<Template>>({})
|
||||||
|
|
||||||
|
const allTemplates = [...defaultTemplates, ...customTemplates]
|
||||||
|
|
||||||
|
const handleAddTemplate = () => {
|
||||||
|
if (!newTemplate.id || !newTemplate.name) return
|
||||||
|
|
||||||
|
const template: Template = {
|
||||||
|
id: newTemplate.id,
|
||||||
|
name: newTemplate.name,
|
||||||
|
description: newTemplate.description || '',
|
||||||
|
styles: newTemplate.styles || '',
|
||||||
|
options: {
|
||||||
|
fontSize: {},
|
||||||
|
colors: {},
|
||||||
|
spacing: {}
|
||||||
|
},
|
||||||
|
transform: (html) => html
|
||||||
|
}
|
||||||
|
|
||||||
|
setCustomTemplates([...customTemplates, template])
|
||||||
|
setNewTemplate({})
|
||||||
|
onTemplateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportTemplates = () => {
|
||||||
|
const exportData = customTemplates.map(template => ({
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
styles: template.styles,
|
||||||
|
styleConfig: {
|
||||||
|
base: {
|
||||||
|
'--md-primary-color': template.options.base?.primaryColor || '#000000',
|
||||||
|
'text-align': template.options.base?.textAlign || 'left',
|
||||||
|
'line-height': template.options.base?.lineHeight || '1.75'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
h1: template.options.block?.h1,
|
||||||
|
h2: template.options.block?.h2,
|
||||||
|
h3: template.options.block?.h3,
|
||||||
|
// ... 其他块级元素
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
strong: template.options.inline?.strong,
|
||||||
|
em: template.options.inline?.em,
|
||||||
|
codespan: template.options.inline?.codespan,
|
||||||
|
// ... 其他内联元素
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const data = JSON.stringify(exportData, null, 2)
|
||||||
|
const blob = new Blob([data], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'wechat-templates.json'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportTemplates = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const importData = JSON.parse(e.target?.result as string)
|
||||||
|
const templates = importData.map((data: any) => ({
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
styles: data.styles,
|
||||||
|
options: {
|
||||||
|
base: {
|
||||||
|
primaryColor: data.styleConfig.base['--md-primary-color'],
|
||||||
|
textAlign: data.styleConfig.base['text-align'],
|
||||||
|
lineHeight: data.styleConfig.base['line-height']
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
h1: data.styleConfig.block.h1,
|
||||||
|
h2: data.styleConfig.block.h2,
|
||||||
|
h3: data.styleConfig.block.h3,
|
||||||
|
// ... 其他块级元素
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
strong: data.styleConfig.inline.strong,
|
||||||
|
em: data.styleConfig.inline.em,
|
||||||
|
codespan: data.styleConfig.inline.codespan,
|
||||||
|
// ... 其他内联元素
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transform: (html: string) => {
|
||||||
|
return `
|
||||||
|
<section>
|
||||||
|
<style>
|
||||||
|
:root { --md-primary-color: ${data.styleConfig.base['--md-primary-color']}; }
|
||||||
|
</style>
|
||||||
|
${html}
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
setCustomTemplates(prev => [...prev, ...templates])
|
||||||
|
onTemplateChange()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFavorite = (templateId: string) => {
|
||||||
|
setFavoriteIds(prev =>
|
||||||
|
prev.includes(templateId)
|
||||||
|
? prev.filter(id => id !== templateId)
|
||||||
|
: [...prev, templateId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Settings2 className="h-4 w-4 mr-2" />
|
||||||
|
模板管理
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>模板管理</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="all">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">所有模板</TabsTrigger>
|
||||||
|
<TabsTrigger value="favorites">收藏模板</TabsTrigger>
|
||||||
|
<TabsTrigger value="custom">自定义模板</TabsTrigger>
|
||||||
|
<TabsTrigger value="add">添加模板</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="all" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{allTemplates.map((template) => (
|
||||||
|
<div key={template.id} className="relative border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-medium">{template.name}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleFavorite(template.id)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
favoriteIds.includes(template.id)
|
||||||
|
? "fill-yellow-400 text-yellow-400"
|
||||||
|
: "text-gray-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="favorites" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{allTemplates
|
||||||
|
.filter(template => favoriteIds.includes(template.id))
|
||||||
|
.map((template) => (
|
||||||
|
<div key={template.id} className="relative border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-medium">{template.name}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleFavorite(template.id)}
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="custom" className="space-y-4">
|
||||||
|
<div className="flex justify-between mb-4">
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button onClick={handleExportTemplates}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
导出模板
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => document.getElementById('import-file')?.click()}>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
导入模板
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
id="import-file"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportTemplates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{customTemplates.map((template) => (
|
||||||
|
<div key={template.id} className="relative border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-medium">{template.name}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomTemplates(prev => prev.filter(t => t.id !== template.id))
|
||||||
|
onTemplateChange()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="add" className="space-y-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>模板ID</Label>
|
||||||
|
<Input
|
||||||
|
value={newTemplate.id || ''}
|
||||||
|
onChange={(e) => setNewTemplate({ ...newTemplate, id: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>模板名称</Label>
|
||||||
|
<Input
|
||||||
|
value={newTemplate.name || ''}
|
||||||
|
onChange={(e) => setNewTemplate({ ...newTemplate, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>描述</Label>
|
||||||
|
<Textarea
|
||||||
|
value={newTemplate.description || ''}
|
||||||
|
onChange={(e) => setNewTemplate({ ...newTemplate, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>样式类名</Label>
|
||||||
|
<Input
|
||||||
|
value={newTemplate.styles || ''}
|
||||||
|
onChange={(e) => setNewTemplate({ ...newTemplate, styles: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddTemplate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
添加模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
86
src/components/template/TemplateSelector.tsx
Normal file
86
src/components/template/TemplateSelector.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Check, ChevronDown } from "lucide-react"
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { templates } from '@/config/templates'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function TemplateSelector({
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
onSelect: (template: string, subTemplate?: string) => void
|
||||||
|
}) {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<SelectPrimitive.Root
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedTemplate(value)
|
||||||
|
onSelect(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Trigger className="inline-flex items-center justify-between rounded-md px-3 py-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground min-w-[120px]">
|
||||||
|
<SelectPrimitive.Value placeholder="选择模板..." />
|
||||||
|
<SelectPrimitive.Icon>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content className="overflow-hidden bg-popover rounded-md border shadow-md">
|
||||||
|
<SelectPrimitive.Viewport className="p-1">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
key={template.id}
|
||||||
|
value={template.id}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText>{template.name}</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator className="absolute left-2 inline-flex items-center">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
</SelectPrimitive.Root>
|
||||||
|
|
||||||
|
{selectedTemplate && templates.find(t => t.id === selectedTemplate)?.subTemplates && (
|
||||||
|
<SelectPrimitive.Root onValueChange={(value) => onSelect(selectedTemplate, value)}>
|
||||||
|
<SelectPrimitive.Trigger className="inline-flex items-center justify-between rounded-md px-3 py-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground min-w-[120px]">
|
||||||
|
<SelectPrimitive.Value placeholder="选择样式..." />
|
||||||
|
<SelectPrimitive.Icon>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content className="overflow-hidden bg-popover rounded-md border shadow-md">
|
||||||
|
<SelectPrimitive.Viewport className="p-1">
|
||||||
|
{templates
|
||||||
|
.find(t => t.id === selectedTemplate)
|
||||||
|
?.subTemplates?.map((subTemplate) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
key={subTemplate.id}
|
||||||
|
value={subTemplate.id}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText>{subTemplate.name}</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator className="absolute left-2 inline-flex items-center">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
</SelectPrimitive.Root>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
85
src/components/template/WechatStylePicker.tsx
Normal file
85
src/components/template/WechatStylePicker.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { templates } from '@/config/wechat-templates'
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
interface WechatStylePickerProps {
|
||||||
|
value?: string
|
||||||
|
onSelect: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WechatStylePicker({ value, onSelect }: WechatStylePickerProps) {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{value ? templates.find(t => t.id === value)?.name : '选择样式...'}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>选择样式模板</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className={cn(
|
||||||
|
"template-preview-card relative flex flex-col gap-4 rounded-lg border bg-card p-4 cursor-pointer",
|
||||||
|
value === template.id && "border-primary"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(template.id)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="aspect-[4/3] overflow-hidden rounded-md border bg-white">
|
||||||
|
<div className={cn(
|
||||||
|
"p-4 transform scale-[0.6] origin-top-left",
|
||||||
|
template.styles
|
||||||
|
)}>
|
||||||
|
<h1 style={{
|
||||||
|
...template.styleConfig?.block?.h1,
|
||||||
|
fontSize: template.styleConfig?.block?.h1?.fontSize || '1.2em'
|
||||||
|
}}>
|
||||||
|
标题示例
|
||||||
|
</h1>
|
||||||
|
<p style={{
|
||||||
|
...template.styleConfig?.block?.p,
|
||||||
|
fontSize: template.styleConfig?.block?.p?.fontSize || '1em'
|
||||||
|
}}>
|
||||||
|
这是一段示例文本,展示不同样式模板的效果。
|
||||||
|
</p>
|
||||||
|
<blockquote style={template.styleConfig?.block?.blockquote}>
|
||||||
|
引用文本示例
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium">{template.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||||
|
</div>
|
||||||
|
{value === template.id && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
43
src/components/template/WechatTemplateSelector.tsx
Normal file
43
src/components/template/WechatTemplateSelector.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Check, ChevronDown } from "lucide-react"
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { templates } from '@/config/wechat-templates'
|
||||||
|
|
||||||
|
export function WechatTemplateSelector({
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
onSelect: (template: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Root onValueChange={onSelect}>
|
||||||
|
<SelectPrimitive.Trigger className="inline-flex items-center justify-between rounded-md px-3 py-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground min-w-[120px]">
|
||||||
|
<SelectPrimitive.Value placeholder="选择样式..." />
|
||||||
|
<SelectPrimitive.Icon>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content className="overflow-hidden bg-popover rounded-md border shadow-md">
|
||||||
|
<SelectPrimitive.Viewport className="p-1">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
key={template.id}
|
||||||
|
value={template.id}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText>{template.name}</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator className="absolute left-2 inline-flex items-center">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
</SelectPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
9
src/components/templates/WechatTemplate.tsx
Normal file
9
src/components/templates/WechatTemplate.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { TemplateProps } from '@/types/template'
|
||||||
|
|
||||||
|
export function WechatTemplate({ content, className }: TemplateProps) {
|
||||||
|
return (
|
||||||
|
<div className={`wechat-template ${className || ''}`}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
9
src/components/templates/XiaohongshuTemplate.tsx
Normal file
9
src/components/templates/XiaohongshuTemplate.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { TemplateProps } from '@/types/template'
|
||||||
|
|
||||||
|
export function XiaohongshuTemplate({ content, className }: TemplateProps) {
|
||||||
|
return (
|
||||||
|
<div className={`xiaohongshu-template ${className || ''}`}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">关闭</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
200
src/components/ui/dropdown-menu.tsx
Normal file
200
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
54
src/components/ui/tabs.tsx
Normal file
54
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
45
src/components/ui/toggle.tsx
Normal file
45
src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-3",
|
||||||
|
sm: "h-8 px-2",
|
||||||
|
lg: "h-11 px-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||||
|
VariantProps<typeof toggleVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<TogglePrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
63
src/config/templates.ts
Normal file
63
src/config/templates.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Template } from '@/types/template'
|
||||||
|
|
||||||
|
export const templates: Template[] = [
|
||||||
|
{
|
||||||
|
id: 'wechat',
|
||||||
|
name: '微信公众号',
|
||||||
|
styles: 'wechat-template',
|
||||||
|
subTemplates: [
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
name: '默认样式',
|
||||||
|
styles: 'wechat-default',
|
||||||
|
transform: (content: string) => {
|
||||||
|
return content
|
||||||
|
.replace(/<h1>/g, '<h1 style="font-size: 24px; font-weight: bold; margin-bottom: 1em;">')
|
||||||
|
.replace(/<p>/g, '<p style="margin-bottom: 1em; color: #333; line-height: 1.75;">')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'elegant',
|
||||||
|
name: '优雅简约',
|
||||||
|
styles: 'wechat-elegant',
|
||||||
|
transform: (content: string) => {
|
||||||
|
return content
|
||||||
|
.replace(/<h1>/g, '<h1 style="font-size: 26px; font-weight: 600; margin-bottom: 1.2em; color: #2c3e50;">')
|
||||||
|
.replace(/<p>/g, '<p style="margin-bottom: 1.2em; color: #34495e; line-height: 1.8; letter-spacing: 0.05em;">')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'modern',
|
||||||
|
name: '现代商务',
|
||||||
|
styles: 'wechat-modern',
|
||||||
|
transform: (content: string) => {
|
||||||
|
return content
|
||||||
|
.replace(/<h1>/g, '<h1 style="font-size: 28px; font-weight: bold; margin-bottom: 1em; color: #1a202c; border-bottom: 2px solid #e2e8f0; padding-bottom: 0.5em;">')
|
||||||
|
.replace(/<p>/g, '<p style="margin-bottom: 1.1em; color: #4a5568; line-height: 1.85; font-size: 16px;">')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'creative',
|
||||||
|
name: '创意活力',
|
||||||
|
styles: 'wechat-creative',
|
||||||
|
transform: (content: string) => {
|
||||||
|
return content
|
||||||
|
.replace(/<h1>/g, '<h1 style="font-size: 24px; font-weight: bold; margin-bottom: 1em; color: #2d3748; background: linear-gradient(to right, #4299e1, #667eea); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">')
|
||||||
|
.replace(/<p>/g, '<p style="margin-bottom: 1em; color: #4a5568; line-height: 1.75; border-left: 3px solid #4299e1; padding-left: 1em;">')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
transform: (content: string) => content // 默认转换
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'xiaohongshu',
|
||||||
|
name: '小红书',
|
||||||
|
styles: 'xiaohongshu-template',
|
||||||
|
transform: (content: string) => {
|
||||||
|
// 小红书特定的转换逻辑
|
||||||
|
return content
|
||||||
|
.replace(/<h1>/g, '<h1 style="font-size: 20px; font-weight: bold; margin-bottom: 0.5em;">')
|
||||||
|
.replace(/<p>/g, '<p style="margin-bottom: 0.8em; color: #222; line-height: 1.6;">')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
389
src/config/wechat-templates.ts
Normal file
389
src/config/wechat-templates.ts
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
import type { RendererOptions } from '@/lib/types'
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
styles: string
|
||||||
|
options: RendererOptions
|
||||||
|
transform: (html: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templates: Template[] = [
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
name: '默认样式',
|
||||||
|
description: '清晰简约的默认样式',
|
||||||
|
styles: '',
|
||||||
|
options: {
|
||||||
|
base: {
|
||||||
|
primaryColor: '#000000',
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: '1.75'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
h1: {
|
||||||
|
display: 'table',
|
||||||
|
padding: '0 1em',
|
||||||
|
borderBottom: '2px solid var(--md-primary-color)',
|
||||||
|
margin: '2em auto 1em',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
fontSize: '1.2em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
display: 'table',
|
||||||
|
padding: '0 0.2em',
|
||||||
|
margin: '4em auto 2em',
|
||||||
|
color: '#fff',
|
||||||
|
background: 'var(--md-primary-color)',
|
||||||
|
fontSize: '1.2em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
paddingLeft: '8px',
|
||||||
|
borderLeft: '3px solid var(--md-primary-color)',
|
||||||
|
margin: '2em 8px 0.75em 0',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
fontSize: '1.1em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 1.2
|
||||||
|
},
|
||||||
|
p: {
|
||||||
|
margin: '1.5em 8px',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
textAlign: 'justify'
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
fontStyle: 'normal',
|
||||||
|
padding: '1em',
|
||||||
|
borderLeft: '4px solid var(--md-primary-color)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: 'rgba(0,0,0,0.5)',
|
||||||
|
background: 'var(--blockquote-background)',
|
||||||
|
marginBottom: '1em'
|
||||||
|
},
|
||||||
|
code_pre: {
|
||||||
|
fontSize: '14px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1em',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
margin: '10px 8px'
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
margin: '0.1em auto 0.5em',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
strong: {
|
||||||
|
color: 'var(--md-primary-color)',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
em: {
|
||||||
|
fontStyle: 'italic'
|
||||||
|
},
|
||||||
|
codespan: {
|
||||||
|
fontSize: '90%',
|
||||||
|
color: '#d14',
|
||||||
|
background: 'rgba(27,31,35,.05)',
|
||||||
|
padding: '3px 5px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: '#576b95'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transform: (html) => html
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'elegant',
|
||||||
|
name: '优雅风格',
|
||||||
|
description: '适合文学、艺术类文章',
|
||||||
|
styles: 'prose-elegant',
|
||||||
|
options: {
|
||||||
|
base: {
|
||||||
|
primaryColor: '#16a34a',
|
||||||
|
textAlign: 'justify',
|
||||||
|
lineHeight: '1.8'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
h1: {
|
||||||
|
display: 'table',
|
||||||
|
padding: '0 1.2em',
|
||||||
|
borderBottom: '2px solid #16a34a',
|
||||||
|
margin: '2.5em auto 1.2em',
|
||||||
|
fontSize: '1.4em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
display: 'table',
|
||||||
|
padding: '0.2em 1em',
|
||||||
|
margin: '4em auto 2em',
|
||||||
|
color: '#fff',
|
||||||
|
background: '#16a34a',
|
||||||
|
fontSize: '1.2em',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: '4px'
|
||||||
|
},
|
||||||
|
p: {
|
||||||
|
margin: '1.8em 8px',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
color: '#2c3e50',
|
||||||
|
textAlign: 'justify',
|
||||||
|
lineHeight: 1.8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
strong: {
|
||||||
|
color: '#16a34a',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
em: {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: '#666'
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: '#3b82f6',
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transform: (html) => `
|
||||||
|
<section style="font-family: 'Georgia', serif;">
|
||||||
|
<style>
|
||||||
|
:root { --md-primary-color: #16a34a; }
|
||||||
|
</style>
|
||||||
|
${html}
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'modern',
|
||||||
|
name: '现代商务',
|
||||||
|
description: '适合商业、科技类文章',
|
||||||
|
styles: 'prose-modern',
|
||||||
|
options: {
|
||||||
|
base: {
|
||||||
|
primaryColor: '#111827',
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: '1.75'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
h1: {
|
||||||
|
fontSize: '26px',
|
||||||
|
color: '#111827',
|
||||||
|
margin: '32px 0 16px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '22px',
|
||||||
|
color: '#111827',
|
||||||
|
margin: '24px 0 12px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#111827',
|
||||||
|
margin: '20px 0 10px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
p: {
|
||||||
|
fontSize: '15px',
|
||||||
|
color: '#374151',
|
||||||
|
margin: '20px 0',
|
||||||
|
lineHeight: 1.6
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
fontSize: '15px',
|
||||||
|
color: '#4b5563',
|
||||||
|
borderLeft: '4px solid #e5e7eb',
|
||||||
|
paddingLeft: '1em',
|
||||||
|
margin: '24px 0'
|
||||||
|
},
|
||||||
|
code_pre: {
|
||||||
|
fontSize: '14px',
|
||||||
|
background: '#f9fafb',
|
||||||
|
padding: '1em',
|
||||||
|
borderRadius: '6px',
|
||||||
|
margin: '20px 0'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
strong: {
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
em: {
|
||||||
|
color: '#374151',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: '#2563eb',
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transform: (html) => `
|
||||||
|
<section style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||||
|
<div style="max-width: 100%; margin: 0 auto; color: #374151;">
|
||||||
|
${html}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'creative',
|
||||||
|
name: '创意活力',
|
||||||
|
description: '适合营销、活动类文章',
|
||||||
|
styles: 'prose-creative',
|
||||||
|
options: {
|
||||||
|
base: {
|
||||||
|
primaryColor: '#4299e1',
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: '1.8'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
h1: {
|
||||||
|
fontSize: '26px',
|
||||||
|
background: 'linear-gradient(45deg, #4299e1, #667eea)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
margin: '32px 0 16px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '22px',
|
||||||
|
background: 'linear-gradient(45deg, #4299e1, #667eea)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
margin: '24px 0 12px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '18px',
|
||||||
|
background: 'linear-gradient(45deg, #4299e1, #667eea)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
margin: '20px 0 10px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
p: {
|
||||||
|
fontSize: '15px',
|
||||||
|
color: '#4a5568',
|
||||||
|
margin: '20px 0',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
borderLeft: '3px solid #4299e1',
|
||||||
|
paddingLeft: '1em'
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
fontSize: '15px',
|
||||||
|
color: '#718096',
|
||||||
|
borderLeft: '4px solid #4299e1',
|
||||||
|
paddingLeft: '1em',
|
||||||
|
margin: '24px 0',
|
||||||
|
background: 'rgba(66, 153, 225, 0.1)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
strong: {
|
||||||
|
color: '#4299e1',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
em: {
|
||||||
|
color: '#4a5568',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: '#4299e1',
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transform: (html) => `
|
||||||
|
<section style="font-family: system-ui, sans-serif;">
|
||||||
|
${html}
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'minimal',
|
||||||
|
name: '极简风格',
|
||||||
|
description: '简约清新的设计风格',
|
||||||
|
styles: 'prose-minimal',
|
||||||
|
options: {
|
||||||
|
base: {
|
||||||
|
primaryColor: '#000000',
|
||||||
|
textAlign: 'left',
|
||||||
|
lineHeight: '1.8'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
h1: {
|
||||||
|
fontSize: '24px',
|
||||||
|
color: '#000000',
|
||||||
|
margin: '36px 0 18px',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '20px',
|
||||||
|
color: '#000000',
|
||||||
|
margin: '28px 0 14px',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#000000',
|
||||||
|
margin: '24px 0 12px',
|
||||||
|
fontWeight: '500'
|
||||||
|
},
|
||||||
|
p: {
|
||||||
|
fontSize: '15px',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
margin: '24px 0',
|
||||||
|
lineHeight: 1.8
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
borderLeft: '2px solid #000',
|
||||||
|
margin: '24px 0',
|
||||||
|
paddingLeft: '1.5em',
|
||||||
|
color: '#666666'
|
||||||
|
},
|
||||||
|
code_pre: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '1em',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
strong: {
|
||||||
|
color: '#000000',
|
||||||
|
fontWeight: '600'
|
||||||
|
},
|
||||||
|
em: {
|
||||||
|
color: '#666666',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: '#0066cc',
|
||||||
|
textDecoration: 'underline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transform: (html) => `
|
||||||
|
<section style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
|
||||||
|
${html}
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]
|
51
src/hooks/use-local-storage.ts
Normal file
51
src/hooks/use-local-storage.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
|
// 获取初始值
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key)
|
||||||
|
return item ? JSON.parse(item) : initialValue
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听其他标签页的更改
|
||||||
|
useEffect(() => {
|
||||||
|
function handleStorageChange(e: StorageEvent) {
|
||||||
|
if (e.key === key) {
|
||||||
|
try {
|
||||||
|
const item = e.newValue
|
||||||
|
setStoredValue(item ? JSON.parse(item) : initialValue)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange)
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange)
|
||||||
|
}, [key, initialValue])
|
||||||
|
|
||||||
|
// 更新存储的值
|
||||||
|
const setValue = (value: T | ((val: T) => T)) => {
|
||||||
|
try {
|
||||||
|
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||||
|
setStoredValue(valueToStore)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [storedValue, setValue] as const
|
||||||
|
}
|
202
src/lib/markdown.ts
Normal file
202
src/lib/markdown.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { marked, type Tokens } from 'marked'
|
||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
// 配置 marked 选项
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
headerIds: false,
|
||||||
|
mangle: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 将 React CSSProperties 转换为 CSS 字符串
|
||||||
|
function cssPropertiesToString(style: React.CSSProperties = {}): string {
|
||||||
|
return Object.entries(style)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
// 转换驼峰命名为连字符命名
|
||||||
|
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
||||||
|
// 处理对象类型的值
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
if ('toString' in value) {
|
||||||
|
return `${cssKey}: ${value.toString()}`
|
||||||
|
}
|
||||||
|
return `${cssKey}: ${JSON.stringify(value)}`
|
||||||
|
}
|
||||||
|
return `${cssKey}: ${value}`
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将基础样式选项转换为 CSS 字符串
|
||||||
|
function baseStylesToString(base: RendererOptions['base'] = {}): string {
|
||||||
|
const styles: string[] = []
|
||||||
|
|
||||||
|
if (base.primaryColor) {
|
||||||
|
styles.push(`--md-primary-color: ${base.primaryColor}`)
|
||||||
|
}
|
||||||
|
if (base.textAlign) {
|
||||||
|
styles.push(`text-align: ${base.textAlign}`)
|
||||||
|
}
|
||||||
|
if (base.lineHeight) {
|
||||||
|
styles.push(`line-height: ${base.lineHeight}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles.join(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertToWechat(markdown: string, options: RendererOptions = {}): string {
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
// 标题渲染
|
||||||
|
renderer.heading = ({ tokens, depth }: { tokens: Tokens.Generic[]; depth: number }) => {
|
||||||
|
const style = options.block?.[`h${depth}` as keyof RendererOptions['block']]
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const content = tokens.map(token => token.text).join('')
|
||||||
|
return `<h${depth}${styleStr ? ` style="${styleStr}"` : ''}>${content}</h${depth}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 段落渲染
|
||||||
|
renderer.paragraph = (text) => {
|
||||||
|
const style = options.block?.p
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const content = typeof text === 'object' ? (text.text || text.toString()) : text
|
||||||
|
|
||||||
|
return `<p${styleStr ? ` style="${styleStr}"` : ''}>${content}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用渲染
|
||||||
|
renderer.blockquote = (quote) => {
|
||||||
|
const style = options.block?.blockquote
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const content = typeof quote === 'object' ? (quote.text || quote.toString()) : quote
|
||||||
|
|
||||||
|
return `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${content}</blockquote>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码块渲染
|
||||||
|
renderer.code = ({ text, lang = '' }: { text: string; lang?: string }) => {
|
||||||
|
const style = options.block?.code_pre
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang}">${text}</code></pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行内代码渲染
|
||||||
|
renderer.codespan = (code) => {
|
||||||
|
const style = options.inline?.codespan
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<code${styleStr ? ` style="${styleStr}"` : ''}>${code}</code>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强调(斜体)渲染
|
||||||
|
renderer.em = (text) => {
|
||||||
|
const style = options.inline?.em
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<em${styleStr ? ` style="${styleStr}"` : ''}>${text}</em>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加粗渲染
|
||||||
|
renderer.strong = (text) => {
|
||||||
|
const style = options.inline?.strong
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${text}</strong>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链接渲染
|
||||||
|
renderer.link = (href, title, text) => {
|
||||||
|
const style = options.inline?.link
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<a href="${href}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''}>${text}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片渲染
|
||||||
|
renderer.image = (href, title, text) => {
|
||||||
|
const style = options.block?.image
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''} />`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表渲染
|
||||||
|
renderer.list = (token: Tokens.List) => {
|
||||||
|
const tag = token.ordered ? 'ol' : 'ul'
|
||||||
|
const style = options.block?.[token.ordered ? 'ol' : 'ul']
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${token.items.map(item => item.text).join('')}</${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表项渲染
|
||||||
|
renderer.listitem = (text) => {
|
||||||
|
const style = options.inline?.listitem
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<li${styleStr ? ` style="${styleStr}"` : ''}>${text}</li>`
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.use({ renderer })
|
||||||
|
|
||||||
|
// 转换 Markdown 为 HTML
|
||||||
|
const html = marked.parse(markdown, { async: false }) as string
|
||||||
|
|
||||||
|
// 应用基础样式
|
||||||
|
const baseStyles = baseStylesToString(options.base)
|
||||||
|
return baseStyles ? `<div style="${baseStyles}">${html}</div>` : html
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为小红书格式
|
||||||
|
export function convertToXiaohongshu(markdown: string): string {
|
||||||
|
// 配置小红书特定的样式
|
||||||
|
const xiaohongshuRenderer = new marked.Renderer()
|
||||||
|
|
||||||
|
xiaohongshuRenderer.heading = (text, level) => {
|
||||||
|
const fontSize = {
|
||||||
|
1: '20px',
|
||||||
|
2: '18px',
|
||||||
|
3: '16px',
|
||||||
|
4: '15px',
|
||||||
|
5: '14px',
|
||||||
|
6: '14px'
|
||||||
|
}[level]
|
||||||
|
|
||||||
|
return `<h${level} style="margin-top: 25px; margin-bottom: 12px; font-weight: bold; font-size: ${fontSize}; color: #222;">${text}</h${level}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
xiaohongshuRenderer.paragraph = (text) => {
|
||||||
|
return `<p style="margin-bottom: 16px; line-height: 1.6; font-size: 15px; color: #222;">${text}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.setOptions({ renderer: xiaohongshuRenderer })
|
||||||
|
|
||||||
|
let html = marked(markdown)
|
||||||
|
html = `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; color: #222; line-height: 1.6;">${html}</div>`
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
type RendererOptions = {
|
||||||
|
base?: {
|
||||||
|
primaryColor?: string
|
||||||
|
textAlign?: string
|
||||||
|
lineHeight?: string | number
|
||||||
|
}
|
||||||
|
block?: {
|
||||||
|
h1?: CSSProperties
|
||||||
|
h2?: CSSProperties
|
||||||
|
h3?: CSSProperties
|
||||||
|
h4?: CSSProperties
|
||||||
|
h5?: CSSProperties
|
||||||
|
h6?: CSSProperties
|
||||||
|
p?: CSSProperties
|
||||||
|
blockquote?: CSSProperties
|
||||||
|
code_pre?: CSSProperties
|
||||||
|
image?: CSSProperties
|
||||||
|
ul?: CSSProperties
|
||||||
|
ol?: CSSProperties
|
||||||
|
}
|
||||||
|
inline?: {
|
||||||
|
strong?: CSSProperties
|
||||||
|
em?: CSSProperties
|
||||||
|
codespan?: CSSProperties
|
||||||
|
link?: CSSProperties
|
||||||
|
listitem?: CSSProperties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { RendererOptions }
|
31
src/lib/types.ts
Normal file
31
src/lib/types.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
export interface RendererOptions {
|
||||||
|
base?: {
|
||||||
|
primaryColor?: string
|
||||||
|
textAlign?: string
|
||||||
|
lineHeight?: string
|
||||||
|
}
|
||||||
|
block?: {
|
||||||
|
h1?: CSSProperties
|
||||||
|
h2?: CSSProperties
|
||||||
|
h3?: CSSProperties
|
||||||
|
h4?: CSSProperties
|
||||||
|
h5?: CSSProperties
|
||||||
|
h6?: CSSProperties
|
||||||
|
p?: CSSProperties
|
||||||
|
blockquote?: CSSProperties
|
||||||
|
code_pre?: CSSProperties
|
||||||
|
code?: CSSProperties
|
||||||
|
image?: CSSProperties
|
||||||
|
ol?: CSSProperties
|
||||||
|
ul?: CSSProperties
|
||||||
|
}
|
||||||
|
inline?: {
|
||||||
|
strong?: CSSProperties
|
||||||
|
em?: CSSProperties
|
||||||
|
codespan?: CSSProperties
|
||||||
|
link?: CSSProperties
|
||||||
|
listitem?: CSSProperties
|
||||||
|
}
|
||||||
|
}
|
11
src/lib/utils.ts
Normal file
11
src/lib/utils.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownToHtml(markdown: string, template: string) {
|
||||||
|
// 这里添加 markdown 转换逻辑
|
||||||
|
return markdown
|
||||||
|
}
|
16
src/styles/templates/wechat.css
Normal file
16
src/styles/templates/wechat.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
.wechat-template {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell;
|
||||||
|
line-height: 1.75;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-template h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-template p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
color: #333;
|
||||||
|
}
|
15
src/styles/templates/xiaohongshu.css
Normal file
15
src/styles/templates/xiaohongshu.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.xiaohongshu-template {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xiaohongshu-template h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xiaohongshu-template p {
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
color: #222;
|
||||||
|
}
|
20
src/types/template.ts
Normal file
20
src/types/template.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export interface SubTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
styles: string
|
||||||
|
transform: (content: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
styles: string
|
||||||
|
subTemplates?: SubTemplate[]
|
||||||
|
transform: (content: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateProps {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
subTemplateId?: string
|
||||||
|
}
|
84
tailwind.config.ts
Normal file
84
tailwind.config.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { fontFamily } from "tailwindcss/defaultTheme"
|
||||||
|
import type { Config } from "tailwindcss"
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
prefix: "",
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||||
|
} satisfies Config
|
||||||
|
|
||||||
|
export default config
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user