diff --git a/docs/blog-layout-spec.md b/docs/blog-layout-spec.md new file mode 100644 index 0000000..4178874 --- /dev/null +++ b/docs/blog-layout-spec.md @@ -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. diff --git a/frontend/package.json b/frontend/package.json index f1ae042..4b3af30 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@tiptap/extension-bubble-menu": "^3.19.0", "@tiptap/extension-color": "^2.1.13", "@tiptap/extension-image": "^2.1.13", + "@tiptap/extension-link": "^2.1.13", "@tiptap/extension-text-style": "^2.1.13", "@tiptap/extension-underline": "^2.1.13", "@tiptap/react": "^2.1.13", diff --git a/frontend/src/components/Editor.jsx b/frontend/src/components/Editor.jsx index 1600241..d618692 100644 --- a/frontend/src/components/Editor.jsx +++ b/frontend/src/components/Editor.jsx @@ -4,8 +4,10 @@ import StarterKit from '@tiptap/starter-kit' import TextStyle from '@tiptap/extension-text-style' import Color from '@tiptap/extension-color' import Underline from '@tiptap/extension-underline' +import Link from '@tiptap/extension-link' import { FontSize } from '../extensions/FontSize' import { ImageResize } from '../extensions/ImageResize' +import { YouTube } from '../extensions/YouTube' import Toolbar from './Toolbar' import ImageBubbleMenu from './ImageBubbleMenu' import MediaLibraryModal from './MediaLibraryModal' @@ -131,9 +133,11 @@ export default function Editor({ content, onChange, onImageUpload, postId, sessi extensions: [ StarterKit, ImageResize, + YouTube, TextStyle, Color, Underline, + Link.configure({ openOnClick: false }), FontSize, ], content: content || '', @@ -195,7 +199,7 @@ export default function Editor({ content, onChange, onImageUpload, postId, sessi } return ( -
+
{formattedDate}
+ {showCaption && ( +
{title}
)} @@ -232,7 +256,7 @@ function CodeBlockNode({ content }) { function HorizontalRuleNode() { return ( -{element}
break
diff --git a/frontend/src/components/Toolbar.jsx b/frontend/src/components/Toolbar.jsx
index 6d67f82..8ea0d68 100644
--- a/frontend/src/components/Toolbar.jsx
+++ b/frontend/src/components/Toolbar.jsx
@@ -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 [showColorPicker, setShowColorPicker] = 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) {
return null
}
- const colors = [
- '#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB',
- '#EF4444', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6',
+ const openColorPicker = () => {
+ setShowFontSize(false)
+ 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']
return (
- Insert link
+ 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 + /> +Text color
+Font size
{fontSizes.map((size) => (Insert YouTube video
+ + 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') + } + } + }} + /> ++ Supports youtube.com/watch, youtu.be/..., or 11-character video ID +
+The post you're looking for doesn't exist.
+The post you're looking for doesn't exist.