Decent functionality

This commit is contained in:
chandresh 2026-02-10 01:46:31 +05:30
parent d512196fb7
commit 71967f891e
13 changed files with 683 additions and 133 deletions

30
docs/blog-layout-spec.md Normal file
View File

@ -0,0 +1,30 @@
# Blog post layout spec
This document defines the shared layout and spacing for the blog post detail screen so that the **blog-editor mobile preview** and the **Android app** (BlogDetailScreen) match exactly.
## Values (single source of truth)
| Element | Web (Tailwind) | Android (Compose) | Notes |
|--------|----------------|-------------------|--------|
| Screen horizontal padding | `px-4` (16px) | `16.dp` | Gutter for title, date, divider, body |
| Header block vertical padding | `py-6` (24px) | `24.dp` | Top and bottom of header block |
| Gap between title and date | `mb-3` (12px) | `12.dp` | |
| Divider | Full width, `px-4` inset | `padding(horizontal = 16.dp)` | |
| Gap between divider and content | `pt-6` (24px) | `24.dp` | |
| Content block vertical padding | 0 top, 0 bottom | None | Only horizontal padding on content |
| Paragraph / block spacing | `mb-3` (12px) | `12.dp` | Between paragraphs and after blocks |
| Image block vertical margin | `my-2` (8px) | `8.dp` | Top and bottom of image |
| Image caption gap | `mt-1` (4px) | `4.dp` | Above caption text |
| Bottom padding (scroll) | `h-8` (32px) | `32.dp` | |
| TopAppBar | `px-4 py-3` | Material TopAppBar | Back + "Blog Post" |
## Image caption
- Only show caption when `title` is present and **not** the literal string `"null"` (case-insensitive). Both Web and Android must hide caption when title is null, blank, or `"null"` to avoid showing "null" on screen.
## Where this is used
- **blog-editor frontend**: [MobilePreview.jsx](../frontend/src/components/MobilePreview.jsx), [TipTapContentRenderer.jsx](../frontend/src/components/TipTapContentRenderer.jsx)
- **Android app**: [BlogDetailScreen.kt](../../android-app/app/src/main/java/com/livingai/android/ui/screens/BlogDetailScreen.kt), [TipTapContentRenderer.kt](../../android-app/app/src/main/java/com/livingai/android/ui/components/TipTapContentRenderer.kt)
When changing layout or spacing, update both codebases and this spec.

View File

@ -12,6 +12,7 @@
"@tiptap/extension-bubble-menu": "^3.19.0", "@tiptap/extension-bubble-menu": "^3.19.0",
"@tiptap/extension-color": "^2.1.13", "@tiptap/extension-color": "^2.1.13",
"@tiptap/extension-image": "^2.1.13", "@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-link": "^2.1.13",
"@tiptap/extension-text-style": "^2.1.13", "@tiptap/extension-text-style": "^2.1.13",
"@tiptap/extension-underline": "^2.1.13", "@tiptap/extension-underline": "^2.1.13",
"@tiptap/react": "^2.1.13", "@tiptap/react": "^2.1.13",

View File

@ -4,8 +4,10 @@ import StarterKit from '@tiptap/starter-kit'
import TextStyle from '@tiptap/extension-text-style' import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color' import Color from '@tiptap/extension-color'
import Underline from '@tiptap/extension-underline' import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
import { FontSize } from '../extensions/FontSize' import { FontSize } from '../extensions/FontSize'
import { ImageResize } from '../extensions/ImageResize' import { ImageResize } from '../extensions/ImageResize'
import { YouTube } from '../extensions/YouTube'
import Toolbar from './Toolbar' import Toolbar from './Toolbar'
import ImageBubbleMenu from './ImageBubbleMenu' import ImageBubbleMenu from './ImageBubbleMenu'
import MediaLibraryModal from './MediaLibraryModal' import MediaLibraryModal from './MediaLibraryModal'
@ -131,9 +133,11 @@ export default function Editor({ content, onChange, onImageUpload, postId, sessi
extensions: [ extensions: [
StarterKit, StarterKit,
ImageResize, ImageResize,
YouTube,
TextStyle, TextStyle,
Color, Color,
Underline, Underline,
Link.configure({ openOnClick: false }),
FontSize, FontSize,
], ],
content: content || '', content: content || '',
@ -195,7 +199,7 @@ export default function Editor({ content, onChange, onImageUpload, postId, sessi
} }
return ( return (
<div className="border border-gray-300 rounded-lg overflow-hidden"> <div className="border border-gray-300 rounded-lg overflow-visible">
<Toolbar <Toolbar
editor={editor} editor={editor}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}
@ -207,7 +211,9 @@ export default function Editor({ content, onChange, onImageUpload, postId, sessi
postId={postId} postId={postId}
sessionId={sessionId} sessionId={sessionId}
/> />
<EditorContent editor={editor} className="min-h-[400px] bg-white" /> <div className="relative z-0 pt-4 bg-white">
<EditorContent editor={editor} className="min-h-[240px] sm:min-h-[400px]" />
</div>
<ImageBubbleMenu <ImageBubbleMenu
editor={editor} editor={editor}
onReplaceClick={() => { onReplaceClick={() => {

View File

@ -85,7 +85,7 @@ export default function MediaLibraryModal({
onClick={onClose} onClick={onClose}
> >
<div <div
className="bg-white rounded-lg shadow-xl w-full max-w-[600px] max-h-[90vh] flex flex-col" className="bg-white rounded-lg shadow-xl w-full max-w-[600px] max-h-[85vh] sm:max-h-[90vh] flex flex-col mx-2 sm:mx-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="document" role="document"
> >

View File

@ -48,14 +48,14 @@ export default function MobilePreview({ title, content, createdAt }) {
<h2 className="text-lg font-semibold text-gray-900">Blog Post</h2> <h2 className="text-lg font-semibold text-gray-900">Blog Post</h2>
</div> </div>
{/* Scrollable Content Area */} {/* Scrollable Content Area - layout matches Android BlogDetailScreen (see blog layout spec) */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* Header Section with Title and Date */} {/* Header: 16px horizontal, 24px vertical; 12px gap between title and date */}
<div className="px-4 py-6"> <div className="px-4 pt-6 pb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-3 leading-tight"> <h1 className="text-3xl font-bold text-gray-900 mb-3 leading-tight">
{title || 'Untitled Post'} {title || 'Untitled Post'}
</h1> </h1>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-600">
{formattedDate} {formattedDate}
</p> </p>
</div> </div>
@ -65,8 +65,8 @@ export default function MobilePreview({ title, content, createdAt }) {
<hr className="border-gray-200" /> <hr className="border-gray-200" />
</div> </div>
{/* Content Section */} {/* Content: 16px horizontal only, 24px gap after divider */}
<div className="px-4 py-6"> <div className="px-4 pt-6">
{content ? ( {content ? (
<TipTapContentRenderer content={content} /> <TipTapContentRenderer content={content} />
) : ( ) : (
@ -76,7 +76,7 @@ export default function MobilePreview({ title, content, createdAt }) {
)} )}
</div> </div>
{/* Bottom padding for better scrolling */} {/* Bottom padding for scroll (32px) */}
<div className="h-8" /> <div className="h-8" />
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { getYouTubeEmbedUrl } from '../extensions/YouTube'
/** /**
* Renders TipTap JSON content as HTML matching Android styling * Renders TipTap JSON content as HTML matching Android styling
@ -61,6 +62,11 @@ function RenderNode({ node }) {
<ImageNode attrs={attrs} /> <ImageNode attrs={attrs} />
) )
case 'youtube':
return (
<YoutubeNode attrs={attrs} />
)
case 'blockquote': case 'blockquote':
return ( return (
<BlockquoteNode content={content} marks={marks} /> <BlockquoteNode content={content} marks={marks} />
@ -124,12 +130,12 @@ function HeadingNode({ level, content, marks }) {
if (!textContent.trim()) return null if (!textContent.trim()) return null
const headingClasses = { const headingClasses = {
1: 'text-3xl font-bold mb-4 mt-6', 1: 'text-3xl font-bold mb-3 mt-3',
2: 'text-2xl font-bold mb-4 mt-6', 2: 'text-2xl font-bold mb-3 mt-3',
3: 'text-xl font-bold mb-3 mt-5', 3: 'text-xl font-bold mb-3 mt-3',
4: 'text-lg font-bold mb-3 mt-4', 4: 'text-lg font-bold mb-3 mt-3',
5: 'text-base font-bold mb-2 mt-3', 5: 'text-base font-bold mb-3 mt-3',
6: 'text-sm font-bold mb-2 mt-3' 6: 'text-sm font-bold mb-3 mt-3'
} }
const Tag = `h${level}` const Tag = `h${level}`
@ -183,26 +189,44 @@ function ListItemNode({ content, marks }) {
) )
} }
function YoutubeNode({ attrs }) {
const videoId = attrs?.videoId
if (!videoId) return null
const embedUrl = getYouTubeEmbedUrl(videoId)
return (
<div className="my-3 mx-auto rounded-lg overflow-hidden aspect-video w-full max-w-[640px]">
<iframe
src={embedUrl}
title="YouTube video"
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)
}
function ImageNode({ attrs }) { function ImageNode({ attrs }) {
const { src, alt = '', title = null, width, height, align } = attrs const { src, alt = '', title = null, width, height, align } = attrs
if (!src) return null if (!src) return null
const showCaption = title && String(title).toLowerCase() !== 'null'
const alignClass = align === 'left' ? 'mr-auto' : align === 'right' ? 'ml-auto' : 'mx-auto' const alignClass = align === 'left' ? 'mr-auto' : align === 'right' ? 'ml-auto' : 'mx-auto'
const style = {} // In read-only view (preview/app), images fill container width so they don't appear small and centered.
if (width) style.width = `${width}px` // Stored width/height are not applied so layout is responsive; alignment still respected.
if (height) style.height = `${height}px` const style = { width: '100%', maxWidth: '100%', height: 'auto' }
return ( return (
<div className={`mb-4 block ${alignClass}`}> <div className={`my-2 block w-full ${alignClass}`}>
<img <img
src={src} src={src}
alt={alt} alt={alt}
className="max-w-full h-auto rounded-lg" className="max-w-full h-auto rounded-lg block"
style={Object.keys(style).length ? style : undefined} style={style}
/> />
{title && ( {showCaption && (
<p className="text-sm text-gray-500 text-center mt-2"> <p className="text-sm text-gray-500 text-center mt-1">
{title} {title}
</p> </p>
)} )}
@ -232,7 +256,7 @@ function CodeBlockNode({ content }) {
function HorizontalRuleNode() { function HorizontalRuleNode() {
return ( return (
<hr className="my-4 border-gray-200 opacity-30" /> <hr className="my-3 border-gray-200 opacity-30" />
) )
} }
@ -265,6 +289,17 @@ function TextNode({ text, marks = [] }) {
if (fontSize) style.fontSize = fontSize if (fontSize) style.fontSize = fontSize
element = <span style={style}>{element}</span> element = <span style={style}>{element}</span>
break break
case 'link': {
const href = mark.attrs?.href
if (href) {
element = (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-indigo-600 underline hover:text-indigo-800">
{element}
</a>
)
}
break
}
case 'code': case 'code':
element = <code className="bg-gray-100 px-1 rounded text-sm font-mono">{element}</code> element = <code className="bg-gray-100 px-1 rounded text-sm font-mono">{element}</code>
break break

View File

@ -1,27 +1,109 @@
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import toast from 'react-hot-toast'
const POPOVER_STYLE = 'absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded-lg shadow-lg p-3 z-30'
const HEX_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, postId, sessionId }) => { const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, postId, sessionId }) => {
const [showColorPicker, setShowColorPicker] = useState(false) const [showColorPicker, setShowColorPicker] = useState(false)
const [showFontSize, setShowFontSize] = useState(false) const [showFontSize, setShowFontSize] = useState(false)
const [showYoutubeInput, setShowYoutubeInput] = useState(false)
const [youtubeUrl, setYoutubeUrl] = useState('')
const [showLinkPopover, setShowLinkPopover] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const [customHex, setCustomHex] = useState('')
const customColorInputRef = useRef(null)
const toolbarRef = useRef(null)
if (!editor) { if (!editor) {
return null return null
} }
const colors = [ const openColorPicker = () => {
'#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', setShowFontSize(false)
'#EF4444', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6', setShowYoutubeInput(false)
setShowColorPicker((v) => !v)
}
const openFontSize = () => {
setShowColorPicker(false)
setShowYoutubeInput(false)
setShowFontSize((v) => !v)
}
const openYoutubeInput = () => {
setShowColorPicker(false)
setShowFontSize(false)
setShowLinkPopover(false)
setShowYoutubeInput((v) => !v)
}
const openLinkPopover = () => {
setShowColorPicker(false)
setShowFontSize(false)
setShowYoutubeInput(false)
const prev = editor.getAttributes('link')?.href || ''
setLinkUrl(prev)
setShowLinkPopover((v) => !v)
}
const closeAllMenus = () => {
setShowColorPicker(false)
setShowFontSize(false)
setShowYoutubeInput(false)
setShowLinkPopover(false)
}
useEffect(() => {
const handleClickOutside = (e) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target)) {
closeAllMenus()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape') closeAllMenus()
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [])
const presetColors = [
'#000000', '#1f2937', '#374151', '#4b5563', '#6b7280', '#9ca3af', '#d1d5db', '#f3f4f6', '#ffffff',
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4',
'#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e',
] ]
const applyColor = (color) => {
let hex = color.startsWith('#') ? color : `#${color}`
if (HEX_REGEX.test(hex)) {
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
}
editor.chain().focus().setColor(hex).run()
setShowColorPicker(false)
setCustomHex('')
}
}
const openNativePicker = () => {
customColorInputRef.current?.click()
}
const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '32px'] const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '32px']
return ( return (
<div className="border-b border-gray-300 bg-gray-50 p-2 flex flex-wrap items-center gap-2"> <div
ref={toolbarRef}
className="sticky top-0 z-20 border-b border-gray-300 bg-gray-50 p-3 flex items-center gap-2 flex-wrap min-h-[72px]"
>
{/* Text Formatting */} {/* Text Formatting */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2"> <div className="flex items-center gap-1 border-r border-gray-300 pr-2 flex-shrink-0">
<button <button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('bold') ? 'bg-gray-300' : '' editor.isActive('bold') ? 'bg-gray-300' : ''
}`} }`}
title="Bold" title="Bold"
@ -29,8 +111,9 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
<strong>B</strong> <strong>B</strong>
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('italic') ? 'bg-gray-300' : '' editor.isActive('italic') ? 'bg-gray-300' : ''
}`} }`}
title="Italic" title="Italic"
@ -38,21 +121,80 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
<em>I</em> <em>I</em>
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('underline') ? 'bg-gray-300' : '' editor.isActive('underline') ? 'bg-gray-300' : ''
}`} }`}
title="Underline" title="Underline"
> >
<u>U</u> <u>U</u>
</button> </button>
<button
type="button"
onClick={openLinkPopover}
className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('link') ? 'bg-gray-300' : ''
}`}
title="Insert link"
>
<span className="text-sm font-medium">Link</span>
</button>
</div> </div>
{/* Link popover */}
{showLinkPopover && (
<div className={`${POPOVER_STYLE} min-w-[280px] right-0 left-auto`}>
<p className="text-sm font-medium text-gray-800 mb-2">Insert link</p>
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 mb-2"
autoFocus
/>
<div className="flex gap-2 justify-end flex-wrap">
{editor.isActive('link') && (
<button
type="button"
onClick={() => {
editor.chain().focus().unsetLink().run()
setShowLinkPopover(false)
setLinkUrl('')
toast.success('Link removed')
}}
className="px-3 py-1.5 text-sm rounded-md border border-gray-300 hover:bg-gray-100"
>
Remove link
</button>
)}
<button
type="button"
onClick={() => {
if (linkUrl.trim()) {
editor.chain().focus().setLink({ href: linkUrl.trim() }).run()
setShowLinkPopover(false)
setLinkUrl('')
toast.success('Link added')
} else {
toast.error('Enter a URL')
}
}}
className="px-3 py-1.5 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-700"
>
Set link
</button>
</div>
</div>
)}
{/* Headings */} {/* Headings */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2"> <div className="flex items-center gap-1 border-r border-gray-300 pr-2 flex-shrink-0">
<button <button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('heading', { level: 1 }) ? 'bg-gray-300' : '' editor.isActive('heading', { level: 1 }) ? 'bg-gray-300' : ''
}`} }`}
title="Heading 1" title="Heading 1"
@ -60,8 +202,9 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
H1 H1
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('heading', { level: 2 }) ? 'bg-gray-300' : '' editor.isActive('heading', { level: 2 }) ? 'bg-gray-300' : ''
}`} }`}
title="Heading 2" title="Heading 2"
@ -69,8 +212,9 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
H2 H2
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('heading', { level: 3 }) ? 'bg-gray-300' : '' editor.isActive('heading', { level: 3 }) ? 'bg-gray-300' : ''
}`} }`}
title="Heading 3" title="Heading 3"
@ -80,10 +224,11 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
</div> </div>
{/* Lists */} {/* Lists */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2"> <div className="flex items-center gap-1 border-r border-gray-300 pr-2 flex-shrink-0">
<button <button
type="button"
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('bulletList') ? 'bg-gray-300' : '' editor.isActive('bulletList') ? 'bg-gray-300' : ''
}`} }`}
title="Bullet List" title="Bullet List"
@ -91,8 +236,9 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleOrderedList().run()} onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('orderedList') ? 'bg-gray-300' : '' editor.isActive('orderedList') ? 'bg-gray-300' : ''
}`} }`}
title="Numbered List" title="Numbered List"
@ -102,10 +248,11 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
</div> </div>
{/* Quote & Code */} {/* Quote & Code */}
<div className="flex items-center gap-1 border-r border-gray-300 pr-2"> <div className="flex items-center gap-1 border-r border-gray-300 pr-2 flex-shrink-0">
<button <button
type="button"
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('blockquote') ? 'bg-gray-300' : '' editor.isActive('blockquote') ? 'bg-gray-300' : ''
}`} }`}
title="Quote" title="Quote"
@ -113,8 +260,9 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
" "
</button> </button>
<button <button
type="button"
onClick={() => editor.chain().focus().toggleCodeBlock().run()} onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('codeBlock') ? 'bg-gray-300' : '' editor.isActive('codeBlock') ? 'bg-gray-300' : ''
}`} }`}
title="Code Block" title="Code Block"
@ -124,54 +272,89 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
</div> </div>
{/* Color Picker */} {/* Color Picker */}
<div className="relative border-r border-gray-300 pr-2"> <div className="relative border-r border-gray-300 pr-2 flex-shrink-0">
<button <button
onClick={() => setShowColorPicker(!showColorPicker)} type="button"
className="p-2 rounded hover:bg-gray-200" onClick={openColorPicker}
className="p-2.5 rounded hover:bg-gray-200"
title="Text Color" title="Text Color"
> >
🎨 🎨
</button> </button>
{showColorPicker && ( {showColorPicker && (
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded shadow-lg p-2 z-10"> <div className={`${POPOVER_STYLE} min-w-[220px]`}>
<div className="grid grid-cols-5 gap-1"> <p className="text-xs font-medium text-gray-700 mb-2">Text color</p>
{colors.map((color) => ( <div className="grid grid-cols-9 gap-1 mb-3">
{presetColors.map((color) => (
<button <button
key={color} key={color}
onClick={() => { type="button"
editor.chain().focus().setColor(color).run() onClick={() => applyColor(color)}
setShowColorPicker(false) className="w-6 h-6 rounded border border-gray-300 hover:ring-2 hover:ring-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
}}
className="w-6 h-6 rounded border border-gray-300"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
title={color} title={color}
/> />
))} ))}
</div> </div>
<div className="flex gap-2 items-center flex-wrap">
<input
ref={customColorInputRef}
type="color"
className="sr-only w-0 h-0"
tabIndex={-1}
onChange={(e) => applyColor(e.target.value)}
/>
<input
type="text"
value={customHex}
onChange={(e) => setCustomHex(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyColor(customHex)}
placeholder="#000000"
className="flex-1 min-w-0 px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
maxLength={7}
/>
<button
type="button"
onClick={() => applyColor(customHex)}
className="px-2 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1"
>
Apply
</button>
</div>
<button
type="button"
onClick={openNativePicker}
className="mt-2 w-full px-2 py-1.5 text-sm text-gray-700 border border-gray-300 rounded hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
Choose custom color
</button>
</div> </div>
)} )}
</div> </div>
{/* Font Size */} {/* Font Size */}
<div className="relative border-r border-gray-300 pr-2"> <div className="relative border-r border-gray-300 pr-2 flex-shrink-0">
<button <button
onClick={() => setShowFontSize(!showFontSize)} type="button"
className="p-2 rounded hover:bg-gray-200" onClick={openFontSize}
className="p-2.5 rounded hover:bg-gray-200"
title="Font Size" title="Font Size"
> >
Aa Aa
</button> </button>
{showFontSize && ( {showFontSize && (
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded shadow-lg p-2 z-10"> <div className={POPOVER_STYLE}>
<p className="text-xs font-medium text-gray-600 mb-2">Font size</p>
{fontSizes.map((size) => ( {fontSizes.map((size) => (
<button <button
key={size} key={size}
type="button"
onClick={() => { onClick={() => {
const sizeValue = parseInt(size.replace('px', '')) const sizeValue = parseInt(size.replace('px', ''))
editor.chain().focus().setFontSize(sizeValue).run() editor.chain().focus().setFontSize(sizeValue).run()
setShowFontSize(false) setShowFontSize(false)
}} }}
className="block w-full text-left px-2 py-1 hover:bg-gray-100 rounded text-sm" className="block w-full text-left px-2 py-1.5 hover:bg-gray-100 rounded text-sm"
style={{ fontSize: size }} style={{ fontSize: size }}
> >
{size} {size}
@ -182,11 +365,11 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
</div> </div>
{/* Media Library - Insert Image */} {/* Media Library - Insert Image */}
<div> <div className="flex-shrink-0">
<button <button
type="button" type="button"
onClick={() => onOpenMediaLibrary?.()} onClick={() => onOpenMediaLibrary?.()}
className={`p-2 rounded hover:bg-gray-200 ${ className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('image') ? 'bg-gray-300' : '' editor.isActive('image') ? 'bg-gray-300' : ''
}`} }`}
title="Insert Image (Media Library)" title="Insert Image (Media Library)"
@ -194,6 +377,80 @@ const Toolbar = ({ editor, onImageUpload, onUploadFile, onOpenMediaLibrary, post
🖼 🖼
</button> </button>
</div> </div>
{/* YouTube */}
<div className="relative flex-shrink-0">
<button
type="button"
onClick={openYoutubeInput}
className={`p-2.5 rounded hover:bg-gray-200 ${
editor.isActive('youtube') ? 'bg-gray-300' : ''
}`}
title="Insert YouTube video"
>
</button>
{showYoutubeInput && (
<div className={`${POPOVER_STYLE} min-w-[300px] right-0 left-auto`}>
<p className="text-sm font-medium text-gray-800 mb-2">Insert YouTube video</p>
<label htmlFor="toolbar-youtube-url" className="sr-only">
YouTube URL or video ID
</label>
<input
id="toolbar-youtube-url"
type="url"
inputMode="url"
autoComplete="url"
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
placeholder="https://youtube.com/watch?v=... or video ID"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent mb-2"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const ok = editor.chain().focus().setYoutubeVideo({ src: youtubeUrl }).run()
if (ok) {
setShowYoutubeInput(false)
setYoutubeUrl('')
toast.success('YouTube video inserted')
} else {
toast.error('Invalid YouTube URL or video ID')
}
}
}}
/>
<p className="text-xs text-gray-500 mb-3">
Supports youtube.com/watch, youtu.be/..., or 11-character video ID
</p>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => { setShowYoutubeInput(false); setYoutubeUrl('') }}
className="px-3 py-1.5 text-sm rounded-md border border-gray-300 hover:bg-gray-100"
>
Cancel
</button>
<button
type="button"
onClick={() => {
const ok = editor.chain().focus().setYoutubeVideo({ src: youtubeUrl }).run()
if (ok) {
setShowYoutubeInput(false)
setYoutubeUrl('')
toast.success('YouTube video inserted')
} else {
toast.error('Invalid YouTube URL or video ID')
}
}}
className="px-3 py-1.5 text-sm rounded-md bg-red-600 text-white hover:bg-red-700"
>
Insert
</button>
</div>
</div>
)}
</div>
</div> </div>
) )
} }

View File

@ -0,0 +1,127 @@
import { Node } from '@tiptap/core'
/**
* Extract YouTube video ID from URL or return the string if it looks like a video ID.
* Supports: youtube.com/watch?v=ID, youtu.be/ID, youtube.com/embed/ID
*/
export function getYouTubeVideoId(urlOrId) {
if (!urlOrId || typeof urlOrId !== 'string') return null
const s = urlOrId.trim()
if (/^[a-zA-Z0-9_-]{11}$/.test(s)) return s
try {
const url = s.startsWith('http') ? new URL(s) : new URL(`https://${s}`)
const host = url.hostname.replace(/^www\./, '')
if (host === 'youtube.com' || host === 'youtu.be') {
if (host === 'youtu.be') return url.pathname.slice(1).split('/')[0] || null
if (url.pathname === '/watch' && url.searchParams.get('v')) return url.searchParams.get('v')
const m = url.pathname.match(/^\/embed\/([a-zA-Z0-9_-]+)/)
if (m) return m[1]
return url.searchParams.get('v') || null
}
} catch (_) {}
return null
}
export function getYouTubeEmbedUrl(videoId) {
if (!videoId) return ''
return `https://www.youtube.com/embed/${videoId}`
}
export const YouTube = Node.create({
name: 'youtube',
group: 'block',
atom: true,
addAttributes() {
return {
videoId: {
default: null,
parseHTML: (el) => {
const iframe = el.querySelector?.('iframe')
const src = iframe?.getAttribute('src') || el.getAttribute('data-youtube-src')
if (src) {
const m = src.match(/(?:embed\/|v=)([a-zA-Z0-9_-]{11})/)
return m ? m[1] : null
}
return el.getAttribute('data-youtube-video-id') || null
},
renderHTML: (attrs) => {
if (!attrs.videoId) return {}
return { 'data-youtube-video-id': attrs.videoId }
},
},
}
},
parseHTML() {
return [
{
tag: 'div[data-youtube-video-id]',
getAttrs: (el) => ({ videoId: el.getAttribute('data-youtube-video-id') }),
},
{
tag: 'iframe[src*="youtube.com/embed/"]',
getAttrs: (el) => {
const src = el.getAttribute('src') || ''
const m = src.match(/(?:embed\/)([a-zA-Z0-9_-]{11})/)
return m ? { videoId: m[1] } : false
},
},
]
},
renderHTML({ node }) {
const id = node.attrs.videoId
if (!id) return ['div', { class: 'youtube-placeholder' }, 'YouTube video']
const src = getYouTubeEmbedUrl(id)
return [
'div',
{ class: 'youtube-embed-wrapper', 'data-youtube-video-id': id },
['iframe', { src, class: 'youtube-embed', width: '100%', height: '315', frameborder: '0', allowfullscreen: 'true', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture' }],
]
},
addCommands() {
return {
setYoutubeVideo:
(attrs) =>
({ commands }) => {
const videoId = attrs.videoId || getYouTubeVideoId(attrs.src || attrs.url)
if (!videoId) return false
return commands.insertContent({ type: this.name, attrs: { videoId } })
},
}
},
addNodeView() {
return ({ node, editor }) => {
const div = document.createElement('div')
div.className = 'youtube-node-view'
div.setAttribute('data-youtube-video-id', node.attrs.videoId || '')
const videoId = node.attrs.videoId
if (videoId) {
const iframe = document.createElement('iframe')
iframe.src = getYouTubeEmbedUrl(videoId)
iframe.className = 'youtube-embed'
iframe.setAttribute('width', '100%')
iframe.setAttribute('height', '315')
iframe.setAttribute('frameborder', '0')
iframe.setAttribute('allowfullscreen', 'true')
iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture')
iframe.setAttribute('title', 'YouTube video')
iframe.style.display = 'block'
iframe.style.minHeight = '220px'
div.appendChild(iframe)
} else {
const placeholder = document.createElement('div')
placeholder.className = 'youtube-placeholder-inner'
placeholder.textContent = 'YouTube video (missing ID)'
div.appendChild(placeholder)
}
return { dom: div }
}
},
})

View File

@ -13,8 +13,14 @@ body {
.ProseMirror { .ProseMirror {
outline: none; outline: none;
min-height: 400px; min-height: 200px;
padding: 1rem; padding: 0.75rem 1rem;
}
@media (min-width: 640px) {
.ProseMirror {
min-height: 400px;
padding: 1rem;
}
} }
.ProseMirror p { .ProseMirror p {
@ -22,22 +28,39 @@ body {
} }
.ProseMirror h1 { .ProseMirror h1 {
font-size: 2rem;
font-weight: bold;
margin: 1rem 0;
}
.ProseMirror h2 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
margin: 0.75rem 0; margin: 0.75rem 0;
} }
@media (min-width: 640px) {
.ProseMirror h1 {
font-size: 2rem;
margin: 1rem 0;
}
}
.ProseMirror h3 { .ProseMirror h2 {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: bold; font-weight: bold;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
@media (min-width: 640px) {
.ProseMirror h2 {
font-size: 1.5rem;
margin: 0.75rem 0;
}
}
.ProseMirror h3 {
font-size: 1.125rem;
font-weight: bold;
margin: 0.5rem 0;
}
@media (min-width: 640px) {
.ProseMirror h3 {
font-size: 1.25rem;
}
}
.ProseMirror ul, .ProseMirror ul,
.ProseMirror ol { .ProseMirror ol {
@ -67,6 +90,15 @@ body {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
} }
.ProseMirror a {
color: #4f46e5;
text-decoration: underline;
cursor: pointer;
}
.ProseMirror a:hover {
color: #4338ca;
}
.ProseMirror img { .ProseMirror img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
@ -154,6 +186,52 @@ body {
transform: scale(1.1); transform: scale(1.1);
} }
/* YouTube embed in editor - centered and always visible */
.ProseMirror .youtube-node-view {
margin: 1rem auto;
border-radius: 8px;
overflow: hidden;
max-width: 640px;
width: 100%;
min-height: 220px;
display: block;
position: relative;
background: #0f0f0f;
border: 1px solid #e5e7eb;
}
.ProseMirror .youtube-node-view iframe,
.ProseMirror .youtube-embed-wrapper iframe {
display: block;
width: 100%;
height: 315px;
min-height: 220px;
aspect-ratio: 16 / 9;
max-height: 400px;
border: none;
vertical-align: top;
}
.ProseMirror .youtube-embed-wrapper {
margin: 1rem auto;
border-radius: 8px;
overflow: hidden;
max-width: 640px;
width: 100%;
min-height: 220px;
display: block;
background: #0f0f0f;
}
.ProseMirror .youtube-placeholder-inner {
padding: 2rem;
background: #f3f4f6;
color: #6b7280;
text-align: center;
border-radius: 8px;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
/* Mobile Preview Styles */ /* Mobile Preview Styles */
.mobile-preview-container { .mobile-preview-container {
scrollbar-width: thin; scrollbar-width: thin;

View File

@ -6,6 +6,7 @@ import Image from '@tiptap/extension-image'
import TextStyle from '@tiptap/extension-text-style' import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color' import Color from '@tiptap/extension-color'
import Underline from '@tiptap/extension-underline' import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
import { FontSize } from '../extensions/FontSize' import { FontSize } from '../extensions/FontSize'
import api from '../utils/api' import api from '../utils/api'
@ -21,6 +22,10 @@ export default function BlogPost() {
TextStyle, TextStyle,
Color, Color,
Underline, Underline,
Link.configure({
openOnClick: true,
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
}),
FontSize, FontSize,
], ],
content: null, content: null,
@ -59,10 +64,10 @@ export default function BlogPost() {
if (!post) { if (!post) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen px-4">
<div className="text-center"> <div className="text-center max-w-md">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Post not found</h1> <h1 className="text-xl sm:text-2xl font-bold text-gray-900 mb-4">Post not found</h1>
<p className="text-gray-600">The post you're looking for doesn't exist.</p> <p className="text-gray-600 text-sm sm:text-base">The post you're looking for doesn't exist.</p>
</div> </div>
</div> </div>
) )
@ -70,9 +75,9 @@ export default function BlogPost() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-12">
<header className="mb-8"> <header className="mb-6 sm:mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1> <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 mb-3 sm:mb-4 break-words">{post.title}</h1>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
Published on {new Date(post.created_at).toLocaleDateString('en-US', { Published on {new Date(post.created_at).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@ -82,7 +87,7 @@ export default function BlogPost() {
</div> </div>
</header> </header>
<div className="bg-white rounded-lg shadow-sm p-8"> <div className="bg-white rounded-lg shadow-sm p-4 sm:p-6 md:p-8 overflow-x-hidden">
{editor && <EditorContent editor={editor} />} {editor && <EditorContent editor={editor} />}
</div> </div>
</article> </article>

View File

@ -57,22 +57,24 @@ export default function Dashboard() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow"> <nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
<div className="flex justify-between h-16"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 py-3 sm:py-0 sm:h-16 sm:flex-nowrap">
<div className="flex items-center"> <div className="flex items-center min-w-0">
<h1 className="text-xl font-bold text-gray-900">Blog Editor</h1> <h1 className="text-lg sm:text-xl font-bold text-gray-900 truncate">Blog Editor</h1>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex flex-wrap items-center gap-2 sm:gap-4">
<span className="text-gray-700">{user?.phone_number || 'Guest'}</span> <span className="text-sm sm:text-base text-gray-700 truncate max-w-[140px] sm:max-w-none" title={user?.phone_number || 'Guest'}>
{user?.phone_number || 'Guest'}
</span>
<Link <Link
to="/editor" to="/editor"
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700" className="bg-indigo-600 text-white px-3 py-2 sm:px-4 rounded-md hover:bg-indigo-700 text-sm font-medium whitespace-nowrap"
> >
New Post New Post
</Link> </Link>
<button <button
onClick={logout} onClick={logout}
className="text-gray-700 hover:text-gray-900" className="text-gray-700 hover:text-gray-900 text-sm sm:text-base px-2 py-1 rounded hover:bg-gray-100"
> >
Logout Logout
</button> </button>
@ -81,29 +83,29 @@ export default function Dashboard() {
</div> </div>
</nav> </nav>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Your Posts</h2> <h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-4 sm:mb-6">Your Posts</h2>
{posts.length === 0 ? ( {posts.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-8 sm:py-12 px-4">
<p className="text-gray-500 mb-4">No posts yet. Create your first post!</p> <p className="text-gray-500 mb-4 text-sm sm:text-base">No posts yet. Create your first post!</p>
<Link <Link
to="/editor" to="/editor"
className="inline-block bg-indigo-600 text-white px-6 py-3 rounded-md hover:bg-indigo-700" className="inline-block bg-indigo-600 text-white px-5 py-2.5 sm:px-6 sm:py-3 rounded-md hover:bg-indigo-700 text-sm sm:text-base"
> >
Create Post Create Post
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => ( {posts.map((post) => (
<div key={post.id} className="bg-white rounded-lg shadow p-6"> <div key={post.id} className="bg-white rounded-lg shadow p-4 sm:p-6">
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start gap-2 mb-2">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-base sm:text-lg font-semibold text-gray-900 line-clamp-2 min-w-0">
{post.title || 'Untitled'} {post.title || 'Untitled'}
</h3> </h3>
<span <span
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded flex-shrink-0 ${
post.status === 'published' post.status === 'published'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800' : 'bg-gray-100 text-gray-800'
@ -115,10 +117,10 @@ export default function Dashboard() {
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-4">
{new Date(post.updated_at).toLocaleDateString()} {new Date(post.updated_at).toLocaleDateString()}
</p> </p>
<div className="flex space-x-2"> <div className="flex flex-wrap gap-2">
<Link <Link
to={`/editor/${post.id}`} to={`/editor/${post.id}`}
className="flex-1 text-center bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 text-sm" className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 text-center bg-indigo-600 text-white px-3 py-2 rounded-md hover:bg-indigo-700 text-sm"
> >
Edit Edit
</Link> </Link>
@ -126,20 +128,20 @@ export default function Dashboard() {
<Link <Link
to={`/blog/${post.slug}`} to={`/blog/${post.slug}`}
target="_blank" target="_blank"
className="flex-1 text-center bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300 text-sm" className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 text-center bg-gray-200 text-gray-700 px-3 py-2 rounded-md hover:bg-gray-300 text-sm"
> >
View View
</Link> </Link>
)} )}
<button <button
onClick={() => handlePublish(post)} onClick={() => handlePublish(post)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm" className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm"
> >
{post.status === 'published' ? 'Unpublish' : 'Publish'} {post.status === 'published' ? 'Unpublish' : 'Publish'}
</button> </button>
<button <button
onClick={() => handleDelete(post.id)} onClick={() => handleDelete(post.id)}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm" className="flex-1 min-w-[calc(50%-4px)] sm:min-w-0 px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm"
> >
Delete Delete
</button> </button>

View File

@ -164,30 +164,31 @@ export default function EditorPage() {
return ( return (
<div className="min-h-screen bg-gray-100 flex flex-col"> <div className="min-h-screen bg-gray-100 flex flex-col">
{/* Top bar */} {/* Top bar - responsive */}
<header className="bg-white border-b border-gray-200 flex-shrink-0"> <header className="bg-white border-b border-gray-200 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-3 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-14"> <div className="flex flex-wrap items-center justify-between gap-2 min-h-14 py-2 sm:py-0">
<button <button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
className="text-gray-600 hover:text-gray-900 font-medium" className="text-gray-600 hover:text-gray-900 font-medium text-sm sm:text-base order-1"
> >
Back to Dashboard <span className="hidden sm:inline"> Back to Dashboard</span>
<span className="sm:hidden"> Back</span>
</button> </button>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2 sm:gap-4 order-2 flex-shrink-0">
{saving && ( {saving && (
<span className="text-sm text-amber-600 font-medium">Saving...</span> <span className="text-xs sm:text-sm text-amber-600 font-medium whitespace-nowrap">Saving...</span>
)} )}
<button <button
onClick={() => setShowPreview(!showPreview)} onClick={() => setShowPreview(!showPreview)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${ className={`flex items-center gap-1.5 sm:gap-2 px-2.5 py-1.5 sm:px-3 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
showPreview showPreview
? 'bg-indigo-100 text-indigo-700' ? 'bg-indigo-100 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100' : 'text-gray-600 hover:bg-gray-100'
}`} }`}
title="Toggle Mobile Preview" title="Toggle Mobile Preview"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" 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" /> <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> </svg>
Preview Preview
@ -195,7 +196,7 @@ export default function EditorPage() {
<button <button
onClick={handlePublish} onClick={handlePublish}
disabled={saving} disabled={saving}
className="bg-indigo-600 text-white px-5 py-2 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed" className="bg-indigo-600 text-white px-3 py-1.5 sm:px-5 sm:py-2 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm whitespace-nowrap"
> >
Publish Publish
</button> </button>
@ -204,24 +205,24 @@ export default function EditorPage() {
</div> </div>
</header> </header>
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
{/* Main Editor Section */} {/* Main Editor Section */}
<main className={`flex-1 overflow-y-auto transition-all duration-300`}> <main className={`flex-1 overflow-y-auto transition-all duration-300 min-h-0 ${showPreview ? 'lg:border-r lg:border-gray-200' : ''}`}>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8">
{/* Card-style editor block */} {/* Card-style editor block */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-visible">
<div className="px-6 pt-6 pb-2"> <div className="px-4 sm:px-6 pt-4 sm:pt-6 pb-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Title</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Title</label>
<input <input
type="text" type="text"
placeholder="Enter post title..." placeholder="Enter post title..."
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
className="w-full text-2xl font-bold p-2 border-0 border-b-2 border-transparent hover:border-gray-200 focus:outline-none focus:border-indigo-500 bg-transparent transition-colors" className="w-full text-xl sm:text-2xl font-bold p-2 border-0 border-b-2 border-transparent hover:border-gray-200 focus:outline-none focus:border-indigo-500 bg-transparent transition-colors"
/> />
</div> </div>
<div className="px-2"> <div className="px-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide px-4 pt-4 pb-1">Content</label> <label className="block text-xs font-medium text-gray-500 uppercase tracking-wide px-2 sm:px-4 pt-4 pb-1">Content</label>
<Editor <Editor
content={content} content={content}
onChange={setContent} onChange={setContent}
@ -233,23 +234,31 @@ export default function EditorPage() {
</div> </div>
</main> </main>
{/* Mobile Preview Sidebar */} {/* Mobile Preview - sidebar on lg+, full-width panel on smaller screens */}
<aside {showPreview && (
className={`bg-gray-50 border-l border-gray-200 transition-all duration-300 overflow-hidden flex-shrink-0 ${ <aside className="bg-gray-50 border-t lg:border-t-0 lg:border-l border-gray-200 flex-shrink-0 w-full lg:w-[380px] flex flex-col min-h-[320px] lg:min-h-0 lg:max-h-full overflow-hidden">
showPreview ? 'w-[380px]' : 'w-0' <div className="flex-1 min-h-0 p-3 sm:p-4 overflow-auto">
}`} <div className="flex items-center justify-between mb-3">
> <span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Mobile Preview</span>
{showPreview && ( <button
<div className="h-full p-4"> type="button"
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Mobile Preview</div> onClick={() => setShowPreview(false)}
className="lg:hidden text-gray-500 hover:text-gray-700 p-1 rounded"
aria-label="Close preview"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<MobilePreview <MobilePreview
title={title} title={title}
content={content} content={content}
createdAt={createdAt} createdAt={createdAt}
/> />
</div> </div>
)} </aside>
</aside> )}
</div> </div>
</div> </div>
) )

View File

@ -110,21 +110,21 @@ export default function Login() {
</p> </p>
</div> </div>
<div className="flex space-x-4"> <div className="flex flex-col-reverse sm:flex-row gap-3 sm:gap-4">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setStep('phone') setStep('phone')
setOtp('') setOtp('')
}} }}
className="flex-1 py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" className="flex-1 w-full sm:w-auto py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
> >
Change Number Change Number
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading || otp.length !== 4} disabled={loading || otp.length !== 4}
className="flex-1 py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50" className="flex-1 w-full sm:w-auto py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
> >
{loading ? 'Verifying...' : 'Verify OTP'} {loading ? 'Verifying...' : 'Verify OTP'}
</button> </button>