diff --git a/frontend/src/components/MobilePreview.jsx b/frontend/src/components/MobilePreview.jsx
new file mode 100644
index 0000000..9422162
--- /dev/null
+++ b/frontend/src/components/MobilePreview.jsx
@@ -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 (
+
+ {/* Phone Frame */}
+
+ {/* Simulated Top App Bar */}
+
+
+ {/* Scrollable Content Area */}
+
+ {/* Header Section with Title and Date */}
+
+
+ {title || 'Untitled Post'}
+
+
+ {formattedDate}
+
+
+
+ {/* Divider */}
+
+
+
+
+ {/* Content Section */}
+
+ {content ? (
+
+ ) : (
+
+ Start typing to see preview...
+
+ )}
+
+
+ {/* Bottom padding for better scrolling */}
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/TipTapContentRenderer.jsx b/frontend/src/components/TipTapContentRenderer.jsx
new file mode 100644
index 0000000..05d4b3c
--- /dev/null
+++ b/frontend/src/components/TipTapContentRenderer.jsx
@@ -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 (
+
+ {contentArray.map((node, index) => (
+
+ ))}
+
+ )
+}
+
+function RenderNode({ node }) {
+ if (!node || !node.type) return null
+
+ const { type, attrs = {}, content = [], marks = [] } = node
+
+ switch (type) {
+ case 'paragraph':
+ return (
+
+ )
+
+ case 'heading':
+ return (
+
+ )
+
+ case 'bulletList':
+ return (
+
+ )
+
+ case 'orderedList':
+ return (
+
+ )
+
+ case 'listItem':
+ return (
+
+ )
+
+ case 'image':
+ return (
+
+ )
+
+ case 'blockquote':
+ return (
+
+ )
+
+ case 'codeBlock':
+ return (
+
+ )
+
+ case 'horizontalRule':
+ return (
+
+ )
+
+ case 'hardBreak':
+ return
+
+ case 'text':
+ return (
+
+ )
+
+ default:
+ // For unknown types, try to render content if available
+ if (content && content.length > 0) {
+ return (
+
+ {content.map((childNode, index) => (
+
+ ))}
+
+ )
+ }
+ 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 (
+
+ {renderContent(content, marks)}
+
+ )
+}
+
+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 (
+
+ {renderContent(content, marks)}
+
+ )
+}
+
+function BulletListNode({ content }) {
+ return (
+
+ {content.map((item, index) => {
+ if (item.type === 'listItem') {
+ return (
+ -
+ {renderContent(item.content || [], item.marks || [])}
+
+ )
+ }
+ return null
+ })}
+
+ )
+}
+
+function OrderedListNode({ content }) {
+ return (
+
+ {content.map((item, index) => {
+ if (item.type === 'listItem') {
+ return (
+ -
+ {renderContent(item.content || [], item.marks || [])}
+
+ )
+ }
+ return null
+ })}
+
+ )
+}
+
+function ListItemNode({ content, marks }) {
+ return (
+
+ {renderContent(content, marks)}
+
+ )
+}
+
+function ImageNode({ attrs }) {
+ const { src, alt = '', title = null } = attrs
+
+ if (!src) return null
+
+ return (
+
+

+ {title && (
+
+ {title}
+
+ )}
+
+ )
+}
+
+function BlockquoteNode({ content, marks }) {
+ return (
+
+ {renderContent(content, marks)}
+
+ )
+}
+
+function CodeBlockNode({ content }) {
+ const code = extractTextFromNodes(content, [])
+
+ return (
+
+
+ {code}
+
+
+ )
+}
+
+function HorizontalRuleNode() {
+ return (
+
+ )
+}
+
+function TextNode({ text, marks = [] }) {
+ if (!text) return null
+
+ let element = {text}
+
+ // 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 = {element}
+ break
+ case 'italic':
+ element = {element}
+ break
+ case 'underline':
+ element = {element}
+ 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 = {element}
+ break
+ case 'code':
+ element = {element}
+ 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
+ } else {
+ return
+ }
+ })}
+ >
+ )
+}
+
+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('')
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 9af612b..b22cd08 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Editor.jsx b/frontend/src/pages/Editor.jsx
index c71481f..f2990b2 100644
--- a/frontend/src/pages/Editor.jsx
+++ b/frontend/src/pages/Editor.jsx
@@ -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 (
-
-