Decent functionality
This commit is contained in:
parent
d512196fb7
commit
71967f891e
|
|
@ -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.
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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={() => {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
{/* Headings */}
|
{/* Link popover */}
|
||||||
<div className="flex items-center gap-1 border-r border-gray-300 pr-2">
|
{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
|
<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 */}
|
||||||
|
<div className="flex items-center gap-1 border-r border-gray-300 pr-2 flex-shrink-0">
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -13,8 +13,14 @@ body {
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.ProseMirror {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
padding: 1rem;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
className={`bg-gray-50 border-l border-gray-200 transition-all duration-300 overflow-hidden flex-shrink-0 ${
|
|
||||||
showPreview ? 'w-[380px]' : 'w-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="h-full p-4">
|
<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">
|
||||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Mobile Preview</div>
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue