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 */} +
+
+ + + +
+

Blog Post

+
+ + {/* 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 ( +
  1. + {renderContent(item.content || [], item.marks || [])} +
  2. + ) + } + return null + })} +
+ ) +} + +function ListItemNode({ content, marks }) { + return ( +
+ {renderContent(content, marks)} +
+ ) +} + +function ImageNode({ attrs }) { + const { src, alt = '', title = null } = attrs + + if (!src) return null + + return ( +
+ {alt} + {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 ( -
-