updated
This commit is contained in:
parent
b37f444df6
commit
7613e66da8
|
|
@ -0,0 +1,85 @@
|
||||||
|
import React from 'react'
|
||||||
|
import TipTapContentRenderer from './TipTapContentRenderer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile preview component that displays blog post exactly as it appears in Android app
|
||||||
|
* Matches BlogDetailScreen layout and styling
|
||||||
|
*/
|
||||||
|
export default function MobilePreview({ title, content, createdAt }) {
|
||||||
|
// Format date to match Android format: "MMM dd, yyyy" (e.g., "Feb 08, 2026")
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) {
|
||||||
|
// Use current date if no date provided
|
||||||
|
const now = new Date()
|
||||||
|
const month = now.toLocaleDateString('en-US', { month: 'short' })
|
||||||
|
const day = now.getDate().toString().padStart(2, '0')
|
||||||
|
const year = now.getFullYear()
|
||||||
|
return `${month} ${day}, ${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const month = date.toLocaleDateString('en-US', { month: 'short' })
|
||||||
|
const day = date.getDate().toString().padStart(2, '0')
|
||||||
|
const year = date.getFullYear()
|
||||||
|
return `${month} ${day}, ${year}`
|
||||||
|
} catch (e) {
|
||||||
|
const now = new Date()
|
||||||
|
const month = now.toLocaleDateString('en-US', { month: 'short' })
|
||||||
|
const day = now.getDate().toString().padStart(2, '0')
|
||||||
|
const year = now.getFullYear()
|
||||||
|
return `${month} ${day}, ${year}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDate = formatDate(createdAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-gray-50">
|
||||||
|
{/* Phone Frame */}
|
||||||
|
<div className="flex-1 flex flex-col bg-white rounded-lg shadow-lg overflow-hidden border border-gray-300">
|
||||||
|
{/* Simulated Top App Bar */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-4 py-3 flex items-center">
|
||||||
|
<div className="w-6 h-6 flex items-center justify-center mr-3">
|
||||||
|
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Blog Post</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content Area */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Header Section with Title and Date */}
|
||||||
|
<div className="px-4 py-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-3 leading-tight">
|
||||||
|
{title || 'Untitled Post'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{formattedDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="px-4">
|
||||||
|
<hr className="border-gray-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<div className="px-4 py-6">
|
||||||
|
{content ? (
|
||||||
|
<TipTapContentRenderer content={content} />
|
||||||
|
) : (
|
||||||
|
<p className="text-base text-gray-500 italic">
|
||||||
|
Start typing to see preview...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom padding for better scrolling */}
|
||||||
|
<div className="h-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,300 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders TipTap JSON content as HTML matching Android styling
|
||||||
|
*/
|
||||||
|
export default function TipTapContentRenderer({ content }) {
|
||||||
|
if (!content) return null
|
||||||
|
|
||||||
|
const contentArray = content.content || (Array.isArray(content) ? content : [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{contentArray.map((node, index) => (
|
||||||
|
<RenderNode key={index} node={node} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderNode({ node }) {
|
||||||
|
if (!node || !node.type) return null
|
||||||
|
|
||||||
|
const { type, attrs = {}, content = [], marks = [] } = node
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'paragraph':
|
||||||
|
return (
|
||||||
|
<ParagraphNode
|
||||||
|
content={content}
|
||||||
|
marks={marks}
|
||||||
|
attrs={attrs}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'heading':
|
||||||
|
return (
|
||||||
|
<HeadingNode
|
||||||
|
level={attrs.level || 1}
|
||||||
|
content={content}
|
||||||
|
marks={marks}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'bulletList':
|
||||||
|
return (
|
||||||
|
<BulletListNode content={content} />
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'orderedList':
|
||||||
|
return (
|
||||||
|
<OrderedListNode content={content} />
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'listItem':
|
||||||
|
return (
|
||||||
|
<ListItemNode content={content} marks={marks} />
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<ImageNode attrs={attrs} />
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'blockquote':
|
||||||
|
return (
|
||||||
|
<BlockquoteNode content={content} marks={marks} />
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'codeBlock':
|
||||||
|
return (
|
||||||
|
<CodeBlockNode content={content} />
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'horizontalRule':
|
||||||
|
return (
|
||||||
|
<HorizontalRuleNode />
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'hardBreak':
|
||||||
|
return <br />
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<TextNode text={node.text} marks={marks || []} />
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For unknown types, try to render content if available
|
||||||
|
if (content && content.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{content.map((childNode, index) => (
|
||||||
|
<RenderNode key={index} node={childNode} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParagraphNode({ content, marks, attrs }) {
|
||||||
|
const textAlign = attrs.textAlign || 'left'
|
||||||
|
const textContent = extractTextFromNodes(content, marks)
|
||||||
|
|
||||||
|
if (!textContent.trim()) return null
|
||||||
|
|
||||||
|
const alignClass = {
|
||||||
|
left: 'text-left',
|
||||||
|
center: 'text-center',
|
||||||
|
right: 'text-right',
|
||||||
|
justify: 'text-justify'
|
||||||
|
}[textAlign] || 'text-left'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className={`${alignClass} mb-3 text-base leading-relaxed text-gray-900`}>
|
||||||
|
{renderContent(content, marks)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeadingNode({ level, content, marks }) {
|
||||||
|
const textContent = extractTextFromNodes(content, marks)
|
||||||
|
if (!textContent.trim()) return null
|
||||||
|
|
||||||
|
const headingClasses = {
|
||||||
|
1: 'text-3xl font-bold mb-4 mt-6',
|
||||||
|
2: 'text-2xl font-bold mb-4 mt-6',
|
||||||
|
3: 'text-xl font-bold mb-3 mt-5',
|
||||||
|
4: 'text-lg font-bold mb-3 mt-4',
|
||||||
|
5: 'text-base font-bold mb-2 mt-3',
|
||||||
|
6: 'text-sm font-bold mb-2 mt-3'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tag = `h${level}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag className={`${headingClasses[level] || headingClasses[3]} text-gray-900`}>
|
||||||
|
{renderContent(content, marks)}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulletListNode({ content }) {
|
||||||
|
return (
|
||||||
|
<ul className="list-disc list-inside mb-3 space-y-1 ml-4">
|
||||||
|
{content.map((item, index) => {
|
||||||
|
if (item.type === 'listItem') {
|
||||||
|
return (
|
||||||
|
<li key={index} className="text-base text-gray-900">
|
||||||
|
{renderContent(item.content || [], item.marks || [])}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderedListNode({ content }) {
|
||||||
|
return (
|
||||||
|
<ol className="list-decimal list-inside mb-3 space-y-1 ml-4">
|
||||||
|
{content.map((item, index) => {
|
||||||
|
if (item.type === 'listItem') {
|
||||||
|
return (
|
||||||
|
<li key={index} className="text-base text-gray-900">
|
||||||
|
{renderContent(item.content || [], item.marks || [])}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListItemNode({ content, marks }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-1">
|
||||||
|
{renderContent(content, marks)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageNode({ attrs }) {
|
||||||
|
const { src, alt = '', title = null } = attrs
|
||||||
|
|
||||||
|
if (!src) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="w-full rounded-lg"
|
||||||
|
/>
|
||||||
|
{title && (
|
||||||
|
<p className="text-sm text-gray-500 text-center mt-2">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockquoteNode({ content, marks }) {
|
||||||
|
return (
|
||||||
|
<blockquote className="border-l-4 border-indigo-500 pl-4 py-2 my-3 italic text-gray-700 bg-gray-50 rounded-r">
|
||||||
|
{renderContent(content, marks)}
|
||||||
|
</blockquote>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlockNode({ content }) {
|
||||||
|
const code = extractTextFromNodes(content, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre className="bg-gray-100 p-4 rounded-lg overflow-x-auto mb-3">
|
||||||
|
<code className="text-sm font-mono text-gray-800">
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HorizontalRuleNode() {
|
||||||
|
return (
|
||||||
|
<hr className="my-4 border-gray-200 opacity-30" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextNode({ text, marks = [] }) {
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
let element = <span>{text}</span>
|
||||||
|
|
||||||
|
// Apply marks in reverse order (inner to outer)
|
||||||
|
const sortedMarks = [...marks].reverse()
|
||||||
|
|
||||||
|
sortedMarks.forEach(mark => {
|
||||||
|
if (!mark || !mark.type) return
|
||||||
|
|
||||||
|
switch (mark.type) {
|
||||||
|
case 'bold':
|
||||||
|
element = <strong>{element}</strong>
|
||||||
|
break
|
||||||
|
case 'italic':
|
||||||
|
element = <em>{element}</em>
|
||||||
|
break
|
||||||
|
case 'underline':
|
||||||
|
element = <u>{element}</u>
|
||||||
|
break
|
||||||
|
case 'textStyle':
|
||||||
|
const color = mark.attrs?.color
|
||||||
|
const fontSize = mark.attrs?.fontSize
|
||||||
|
const style = {}
|
||||||
|
if (color) style.color = color
|
||||||
|
if (fontSize) style.fontSize = fontSize
|
||||||
|
element = <span style={style}>{element}</span>
|
||||||
|
break
|
||||||
|
case 'code':
|
||||||
|
element = <code className="bg-gray-100 px-1 rounded text-sm font-mono">{element}</code>
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(content, parentMarks = []) {
|
||||||
|
if (!content || !Array.isArray(content)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content.map((node, index) => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return <TextNode key={index} text={node.text} marks={node.marks || parentMarks} />
|
||||||
|
} else {
|
||||||
|
return <RenderNode key={index} node={node} />
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromNodes(content, marks = []) {
|
||||||
|
if (!content || !Array.isArray(content)) return ''
|
||||||
|
|
||||||
|
return content.map(node => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return node.text || ''
|
||||||
|
} else if (node.content) {
|
||||||
|
return extractTextFromNodes(node.content, node.marks || marks)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
@ -120,3 +120,26 @@ body {
|
||||||
background-color: #2563eb;
|
background-color: #2563eb;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Preview Styles */
|
||||||
|
.mobile-preview-container {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-preview-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-preview-container::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-preview-container::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-preview-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import Editor from '../components/Editor'
|
import Editor from '../components/Editor'
|
||||||
|
import MobilePreview from '../components/MobilePreview'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
|
@ -9,8 +10,10 @@ export default function EditorPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [content, setContent] = useState(null)
|
const [content, setContent] = useState(null)
|
||||||
|
const [createdAt, setCreatedAt] = useState(null)
|
||||||
const [loading, setLoading] = useState(!!id)
|
const [loading, setLoading] = useState(!!id)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
const autoSaveTimeoutRef = useRef(null)
|
const autoSaveTimeoutRef = useRef(null)
|
||||||
const isInitialLoadRef = useRef(true)
|
const isInitialLoadRef = useRef(true)
|
||||||
const currentPostIdRef = useRef(id)
|
const currentPostIdRef = useRef(id)
|
||||||
|
|
@ -90,6 +93,7 @@ export default function EditorPage() {
|
||||||
const post = res.data
|
const post = res.data
|
||||||
setTitle(post.title || '')
|
setTitle(post.title || '')
|
||||||
setContent(post.content_json || null)
|
setContent(post.content_json || null)
|
||||||
|
setCreatedAt(post.created_at || null)
|
||||||
isInitialLoadRef.current = true // Reset after loading
|
isInitialLoadRef.current = true // Reset after loading
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load post')
|
toast.error('Failed to load post')
|
||||||
|
|
@ -153,8 +157,8 @@ export default function EditorPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
<nav className="bg-white shadow">
|
<nav className="bg-white shadow flex-shrink-0">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center h-16">
|
<div className="flex justify-between items-center h-16">
|
||||||
<button
|
<button
|
||||||
|
|
@ -167,6 +171,29 @@ export default function EditorPage() {
|
||||||
{saving && (
|
{saving && (
|
||||||
<span className="text-sm text-gray-500">Saving...</span>
|
<span className="text-sm text-gray-500">Saving...</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
className={`p-2 rounded-md transition-colors ${
|
||||||
|
showPreview
|
||||||
|
? 'bg-indigo-100 text-indigo-600'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
title="Toggle Mobile Preview"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|
@ -179,19 +206,45 @@ export default function EditorPage() {
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
<input
|
{/* Main Editor Section */}
|
||||||
type="text"
|
<div
|
||||||
placeholder="Enter post title..."
|
className={`flex-1 overflow-y-auto transition-all duration-300 ${
|
||||||
value={title}
|
showPreview ? 'mr-0' : ''
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
}`}
|
||||||
className="w-full text-3xl font-bold mb-6 p-2 border-0 border-b-2 border-gray-300 focus:outline-none focus:border-indigo-600 bg-transparent"
|
>
|
||||||
/>
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter post title..."
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full text-3xl font-bold mb-6 p-2 border-0 border-b-2 border-gray-300 focus:outline-none focus:border-indigo-600 bg-transparent"
|
||||||
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
content={content}
|
content={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Preview Sidebar */}
|
||||||
|
<div
|
||||||
|
className={`bg-gray-100 border-l border-gray-300 transition-all duration-300 overflow-hidden ${
|
||||||
|
showPreview ? 'w-[375px]' : 'w-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showPreview && (
|
||||||
|
<div className="h-full p-4">
|
||||||
|
<MobilePreview
|
||||||
|
title={title}
|
||||||
|
content={content}
|
||||||
|
createdAt={createdAt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue