This commit is contained in:
true1ck 2026-02-08 22:19:31 +05:30
parent b37f444df6
commit 7613e66da8
4 changed files with 475 additions and 14 deletions

View File

@ -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>
)
}

View File

@ -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('')
}

View File

@ -120,3 +120,26 @@ body {
background-color: #2563eb;
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;
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import Editor from '../components/Editor'
import MobilePreview from '../components/MobilePreview'
import api from '../utils/api'
import toast from 'react-hot-toast'
@ -9,8 +10,10 @@ export default function EditorPage() {
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [content, setContent] = useState(null)
const [createdAt, setCreatedAt] = useState(null)
const [loading, setLoading] = useState(!!id)
const [saving, setSaving] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const autoSaveTimeoutRef = useRef(null)
const isInitialLoadRef = useRef(true)
const currentPostIdRef = useRef(id)
@ -90,6 +93,7 @@ export default function EditorPage() {
const post = res.data
setTitle(post.title || '')
setContent(post.content_json || null)
setCreatedAt(post.created_at || null)
isInitialLoadRef.current = true // Reset after loading
} catch (error) {
toast.error('Failed to load post')
@ -153,8 +157,8 @@ export default function EditorPage() {
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow">
<div className="min-h-screen bg-gray-50 flex flex-col">
<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="flex justify-between items-center h-16">
<button
@ -167,6 +171,29 @@ export default function EditorPage() {
{saving && (
<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
onClick={handlePublish}
disabled={saving}
@ -179,6 +206,13 @@ export default function EditorPage() {
</div>
</nav>
<div className="flex-1 flex overflow-hidden">
{/* Main Editor Section */}
<div
className={`flex-1 overflow-y-auto transition-all duration-300 ${
showPreview ? 'mr-0' : ''
}`}
>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<input
type="text"
@ -194,5 +228,24 @@ export default function EditorPage() {
/>
</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>
)
}