working link

This commit is contained in:
chandresh 2026-02-10 03:23:37 +05:30
parent 71967f891e
commit f29c929717
10 changed files with 1166 additions and 75 deletions

View File

@ -0,0 +1,29 @@
import { pool } from '../config/database.js'
import dotenv from 'dotenv'
dotenv.config()
async function up() {
try {
console.log('Running add-link-post-columns migration...')
await pool.query(`
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS content_type VARCHAR(20) DEFAULT 'tiptap'
CHECK (content_type IN ('tiptap', 'link'));
`)
await pool.query(`
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS external_url TEXT NULL;
`)
console.log('✓ add-link-post-columns: content_type, external_url added')
process.exit(0)
} catch (err) {
console.error('Migration failed:', err)
process.exit(1)
}
}
up()

View File

@ -5,12 +5,21 @@ import logger from '../utils/logger.js'
const router = express.Router()
const MAX_EXTERNAL_URL_LENGTH = 2048
function isValidExternalUrl(url) {
if (typeof url !== 'string') return false
const trimmed = url.trim()
if (!trimmed || trimmed.length > MAX_EXTERNAL_URL_LENGTH) return false
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
}
// Get all posts for current user
// Note: authenticateToken middleware is applied at server level, so req.user is available
router.get('/', async (req, res) => {
try {
logger.transaction('FETCH_POSTS', { userId: req.user.id })
const query = 'SELECT id, title, slug, status, created_at, updated_at FROM posts WHERE user_id = $1 ORDER BY updated_at DESC'
const query = 'SELECT id, title, slug, status, content_type, external_url, created_at, updated_at FROM posts WHERE user_id = $1 ORDER BY updated_at DESC'
logger.db('SELECT', query, [req.user.id])
const result = await pool.query(query, [req.user.id])
@ -85,36 +94,53 @@ router.get('/slug/:slug', async (req, res) => {
// Create post
router.post('/', async (req, res) => {
try {
const { title, content_json, status } = req.body
const { title, content_json, content_type, external_url, status } = req.body
logger.transaction('CREATE_POST', {
userId: req.user.id,
title: title?.substring(0, 50),
status: status || 'draft'
status: status || 'draft',
content_type: content_type || 'tiptap'
})
if (!title || !content_json) {
logger.warn('POSTS', 'Missing required fields', {
hasTitle: !!title,
hasContent: !!content_json
})
return res.status(400).json({ message: 'Title and content are required' })
const isLinkPost = content_type === 'link'
if (isLinkPost) {
if (!title || typeof title !== 'string' || !title.trim()) {
return res.status(400).json({ message: 'Title is required' })
}
if (!external_url || !isValidExternalUrl(external_url)) {
return res.status(400).json({ message: 'Valid external URL is required (http:// or https://, max 2048 characters)' })
}
} else {
if (!title || !content_json) {
logger.warn('POSTS', 'Missing required fields', {
hasTitle: !!title,
hasContent: !!content_json
})
return res.status(400).json({ message: 'Title and content are required' })
}
}
const slug = slugify(title, { lower: true, strict: true }) + '-' + Date.now()
const postStatus = status || 'draft'
const contentType = isLinkPost ? 'link' : (content_type || 'tiptap')
const contentJson = isLinkPost ? {} : content_json
const externalUrl = isLinkPost ? external_url.trim() : null
const query = `INSERT INTO posts (user_id, title, content_json, slug, status)
VALUES ($1, $2, $3, $4, $5)
const query = `INSERT INTO posts (user_id, title, content_json, slug, status, content_type, external_url)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`
logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus])
logger.db('INSERT', query, [req.user.id, title, '[content_json]', slug, postStatus, contentType, externalUrl])
const result = await pool.query(query, [
req.user.id,
title,
JSON.stringify(content_json),
JSON.stringify(contentJson),
slug,
postStatus
postStatus,
contentType,
externalUrl
])
logger.transaction('CREATE_POST_SUCCESS', {
@ -132,7 +158,7 @@ router.post('/', async (req, res) => {
// Update post
router.put('/:id', async (req, res) => {
try {
const { title, content_json, status } = req.body
const { title, content_json, content_type, external_url, status } = req.body
logger.transaction('UPDATE_POST', {
postId: req.params.id,
@ -140,7 +166,9 @@ router.put('/:id', async (req, res) => {
updates: {
title: title !== undefined,
content: content_json !== undefined,
status: status !== undefined
status: status !== undefined,
content_type: content_type !== undefined,
external_url: external_url !== undefined
}
})
@ -158,6 +186,14 @@ router.put('/:id', async (req, res) => {
return res.status(404).json({ message: 'Post not found' })
}
const isLinkUpdate = content_type === 'link'
if (external_url !== undefined && isLinkUpdate && !isValidExternalUrl(external_url)) {
return res.status(400).json({ message: 'Valid external URL is required (http:// or https://, max 2048 characters)' })
}
if (content_type === 'link' && external_url === undefined) {
return res.status(400).json({ message: 'external_url is required when content_type is link' })
}
// Build update query dynamically
const updates = []
const values = []
@ -178,6 +214,20 @@ router.put('/:id', async (req, res) => {
values.push(status)
}
if (content_type !== undefined) {
updates.push(`content_type = $${paramCount++}`)
values.push(content_type)
}
// When content_type is set to non-link, clear external_url; when link, set URL
if (content_type !== undefined) {
updates.push(`external_url = $${paramCount++}`)
values.push(content_type === 'link' && external_url !== undefined ? external_url.trim() : null)
} else if (external_url !== undefined) {
updates.push(`external_url = $${paramCount++}`)
values.push(external_url.trim())
}
// Update slug if title changed
if (title !== undefined) {
const slug = slugify(title, { lower: true, strict: true }) + '-' + Date.now()

View File

@ -0,0 +1,245 @@
# Plan: Link and HTML/React Posts (Multi-Content-Type)
This document is the implementation plan for adding **link** (external URL) and **html** (stored HTML or React build) post types to the existing blog flow, **without breaking** current TipTap-only behavior. React SPAs are supported via the link type (deploy anywhere, store URL) or via the html type with a base URL for built assets.
---
## 1. Goals
- Support three content types: **tiptap** (current), **link**, **html**.
- **Link**: Store a URL; web and app open it (new tab / WebView). Supports any site, including React SPAs.
- **HTML**: Store HTML and optional base URL, or a URL to built output; app renders in WebView with correct base URL for React builds.
- Single DB and backend; no new server. Control (publish, show-in-app) stays in blog-editor dashboard and existing APIs.
- Existing TipTap posts and clients remain unchanged (backward compatible).
---
## 2. Current State Summary
| Component | Location | Relevant behavior |
|-----------|----------|-------------------|
| DB | Supabase `posts` | `content_json` (JSONB), `title`, `slug`, `status` (draft/published) |
| Blog-editor backend | `blog-editor/backend/routes/posts.js` | CRUD; GET by slug returns full row; create/update require `content_json` |
| Blog-editor frontend | Dashboard, Editor, `BlogPost.jsx` | TipTap editor only; public view at `/blog/:slug` renders TipTap |
| API-v1 | `api-v1/routes/blog-posts.route.js` | Reads `posts` from Supabase; returns `content` (from content_json), no content_type |
| Android app | `BlogApiClient.kt`, `BlogDetailScreen.kt`, `TipTapContentRenderer.kt` | Fetches from api-v1; renders only TipTap JSON |
---
## 3. Database Changes (Supabase)
**Add columns only; no data migration.**
Run once (new migration file or manual SQL on Supabase):
```sql
-- Content type: 'tiptap' (default), 'link', 'html'
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS content_type VARCHAR(20) DEFAULT 'tiptap'
CHECK (content_type IN ('tiptap', 'link', 'html'));
-- For link: URL to open in browser/WebView (any site, including React SPAs)
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS external_url TEXT NULL;
-- For html: raw HTML body, or NULL if content is served from URL
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS content_html TEXT NULL;
-- Optional: base URL for relative assets (React build, images, scripts)
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS content_base_url TEXT NULL;
-- Optional: show in app (separate from published on web)
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS show_in_app BOOLEAN DEFAULT true;
-- Index for filtering by content_type if needed
CREATE INDEX IF NOT EXISTS idx_posts_content_type ON posts(content_type);
```
**Backfill (optional):**
Existing rows will have `content_type = 'tiptap'` and NULL for new columns; no backfill required.
**Constraint note:**
If the table already has rows, the CHECK may need to be added in a way that allows existing NULLs (e.g. `content_type` default `'tiptap'` and allow NULL during transition, or run backfill first). Prefer setting default so existing rows get `'tiptap'`.
---
## 4. Blog-Editor Backend
**File:** `blog-editor/backend/routes/posts.js`
### 4.1 SELECTs include new columns
- **GET /** (list): Add `content_type`, `external_url`, `content_html`, `content_base_url`, `show_in_app` to the SELECT so the dashboard can show type and edit correctly.
- **GET /:id**: Uses `SELECT *`; no change needed once columns exist.
- **GET /slug/:slug**: Uses `SELECT *`; no change needed.
### 4.2 Create post accept new fields and validate by type
- **Body:** Accept `content_type`, `external_url`, `content_html`, `content_base_url`, `show_in_app` in addition to `title`, `content_json`, `status`.
- **Validation:**
- If `content_type === 'link'`: require `external_url` (non-empty string); allow `content_json` to be `{}` or omit.
- If `content_type === 'html'`: require at least one of `content_html` or `external_url` (URL to built HTML); allow `content_json` to be `{}` or omit.
- If `content_type === 'tiptap'` or not set: require `content_json` as today.
- **INSERT:** Add the new columns to the INSERT and values. For link/html, `content_json` can be `'{}'` to satisfy existing NOT NULL if applicable (or make `content_json` nullable in a separate migration if desired; this plan keeps it NOT NULL with `{}` for non-tiptap).
### 4.3 Update post allow updating new fields
- **Body:** Allow `content_type`, `external_url`, `content_html`, `content_base_url`, `show_in_app` in addition to existing fields.
- **Logic:** Same validation as create when these fields are present. Add dynamic update clauses for the new columns (only set if provided).
### 4.4 Public GET by slug
- No auth change; keep public GET by slug as-is. Response will include new columns automatically with `SELECT *`.
---
## 5. Blog-Editor Frontend
### 5.1 Dashboard list view
- **File:** `blog-editor/frontend/src/pages/Dashboard.jsx`
- **Change:** When rendering each post, read `content_type` (or `post.contentType` if backend camelCases). Show a small badge/label: “TipTap”, “Link”, or “HTML” (and optionally “React” for link if you want to hint that React URLs are supported).
- **Links:** “View” for published link posts could open `external_url` in a new tab instead of `/blog/:slug` if desired; or keep “View” as `/blog/:slug` and let BlogPost page handle redirect.
### 5.2 Editor post type selector and forms
- **File:** `blog-editor/frontend/src/pages/Editor.jsx` (and any shared editor component)
- **Changes:**
1. **Post type selector** (e.g. radio or dropdown): “TipTap” (default), “Link”, “HTML”.
2. **When “Link” selected:**
- Show: Title (required), URL (required), optional status.
- On save: send `content_type: 'link'`, `external_url: <url>`, `content_json: {}`, plus title/slug/status.
3. **When “HTML” selected:**
- Show: Title, one of:
- Textarea for raw HTML, and optional “Base URL” for assets (for React build), or
- Single “URL” to built HTML (e.g. S3); then send `content_type: 'html'`, `external_url` or `content_html` (and optional `content_base_url`).
- On save: send `content_type: 'html'`, `content_html` and/or `external_url`, `content_base_url`, `content_json: {}`.
4. **When “TipTap” selected:** Current behavior; send `content_json` from editor state, no `content_type` or `content_type: 'tiptap'`.
- **Loading:** When editing an existing post, set the selector from `post.content_type` and prefill URL or HTML fields.
### 5.3 Public blog page render by type
- **File:** `blog-editor/frontend/src/pages/BlogPost.jsx`
- **Changes:** After fetching post by slug:
- If `content_type === 'link'` and `external_url`: redirect to `external_url` (or open in new tab / render in iframe). Prefer redirect so “view in browser” = same as app (one URL).
- If `content_type === 'html'`: If `content_html` present, render with `dangerouslySetInnerHTML` inside a sandboxed container; set `<base href={content_base_url} />` in the document so relative assets (React build) resolve. If only `external_url` present, show an iframe with `src={external_url}` or a link to open it.
- Else (tiptap or missing): current TipTap rendering from `content_json`.
---
## 6. API-v1 (Blog-Posts Routes)
**File:** `api-v1/routes/blog-posts.route.js`
### 6.1 Same table
- Ensure api-v1 uses the same Supabase `posts` table as blog-editor (same DB). No schema change in api-v1; only response mapping.
### 6.2 Extend response mapping
- In every pipeline that returns a post (list, by id, by slug, by user):
- **Select:** Add to `.select()`: `content_type`, `external_url`, `content_html`, `content_base_url`, `show_in_app` (if you add it).
- **Map:** Add to the returned object: `contentType`, `externalUrl`, `contentHtml`, `contentBaseUrl`, `showInApp`. Keep existing `content` (from `content_json`) so existing app versions keep working.
- **Filter by show_in_app (optional):** If you add `show_in_app`, filter list (and optionally slug/id) with `.where('show_in_app', true)` for app-facing endpoints so only “show in app” posts are returned.
---
## 7. Android App
### 7.1 Model
- **File:** `android-app/app/src/main/java/com/livingai/android/api/BlogApiClient.kt` (or wherever `BlogPost` is defined)
- **Change:** Add optional fields to `BlogPost`:
- `contentType: String? = null` (or default `"tiptap"`)
- `externalUrl: String? = null`
- `contentHtml: String? = null`
- `contentBaseUrl: String? = null`
- `showInApp: Boolean? = null`
- Use default values or nullable so old API responses without these fields still parse.
### 7.2 Blog list screen
- **File:** e.g. `BlogsScreen.kt`
- **Change:** Optional: show a small icon or label for “Link” / “HTML” using `contentType`. No breaking change if you skip this.
### 7.3 Blog detail screen branch by content type
- **File:** `android-app/app/src/main/java/com/livingai/android/ui/screens/BlogDetailScreen.kt`
- **Logic:**
1. If `contentType == "link"` and `externalUrl != null`:
- Open `externalUrl` in a **WebView** (new composable or full-screen WebView). Use `AndroidView` + `WebView` and `webView.loadUrl(externalUrl)`. This supports React SPAs and any web page.
2. Else if `contentType == "html"`:
- If `externalUrl != null`: load in WebView with `webView.loadUrl(externalUrl)`.
- Else if `contentHtml != null`: use `webView.loadDataWithBaseURL(contentBaseUrl ?: "about:blank", contentHtml, "text/html", "UTF-8", null)` so relative paths (e.g. React build assets) resolve when `contentBaseUrl` points to the build root.
3. Else (tiptap or null/empty):
- Keep current behavior: render with **TipTapContentRenderer** using `content` (TipTap JSON).
### 7.4 WebView composable
- **New file (suggested):** e.g. `android-app/.../ui/components/BlogWebView.kt`
- **Content:** A composable that takes URL and/or HTML + base URL, and renders a WebView (with optional progress bar, back/forward if needed). Handle loading state and errors. Use same styling/layout as the rest of the detail screen (e.g. inside the same scaffold).
---
## 8. React Pages How They Are Supported
- **Link type:** User deploys a React app (Vercel, Netlify, S3+CloudFront, etc.) and enters the app URL in the dashboard. Stored as `content_type: 'link'`, `external_url: <url>`. Web and app open that URL; React runs in the browser/WebView. No extra work.
- **HTML type with React build:** User builds the React app (`npm run build`), uploads the build (e.g. to S3) or pastes the entry HTML. Store either:
- The URL to the built app (e.g. `https://bucket.../index.html`) in `external_url` and use link-like behavior in the app (load URL in WebView), or
- The HTML in `content_html` and the build root in `content_base_url`; app uses `loadDataWithBaseURL(contentBaseUrl, contentHtml, "text/html", "UTF-8", null)` so scripts and assets load and the React app runs in the WebView.
No separate “React” content type is required.
---
## 9. Implementation Order
| Step | Task | Breaks existing? |
|------|------|-------------------|
| 1 | DB: Add columns (content_type, external_url, content_html, content_base_url, show_in_app) and index | No |
| 2 | Blog-editor backend: Extend SELECTs, CREATE, UPDATE with new fields and validation | No |
| 3 | API-v1: Extend select and map for all blog-posts responses | No |
| 4 | Android: Extend BlogPost model with optional new fields | No |
| 5 | Android: Add WebView composable and branch in BlogDetailScreen by contentType | No |
| 6 | Blog-editor frontend: Dashboard badge for type; Editor type selector + Link/HTML forms; BlogPost render by type | No |
Recommended: do 1 → 2 → 3 → 4 → 5 → 6. After 13, the API and DB support new types; after 45, the app can show link/html; after 6, the dashboard supports creating and viewing them.
---
## 10. Testing Checklist
- [ ] Existing TipTap post: still lists, opens, and renders in dashboard, web `/blog/:slug`, and app.
- [ ] New link post: create in dashboard with URL; appears in list; “View” on web opens URL; app opens same URL in WebView.
- [ ] New HTML post (URL): create with URL to a built React app; web and app load it; React runs in WebView.
- [ ] New HTML post (raw HTML + base URL): create with HTML and base URL; app renders with `loadDataWithBaseURL`; relative assets load.
- [ ] Edit existing TipTap post: no regression; content_type remains tiptap.
- [ ] Optional: `show_in_app = false` excludes post from api-v1 list (if you implement the filter).
---
## 11. File Reference
| Area | File(s) |
|------|--------|
| DB migration | New SQL file or Supabase SQL editor; optional `blog-editor/backend/migrations/add-content-type-columns.js` |
| Blog-editor backend | `blog-editor/backend/routes/posts.js` |
| Blog-editor frontend | `blog-editor/frontend/src/pages/Dashboard.jsx`, `Editor.jsx`, `BlogPost.jsx` |
| API-v1 | `api-v1/routes/blog-posts.route.js` |
| Android model | `android-app/.../api/BlogApiClient.kt` (BlogPost data class) |
| Android detail | `android-app/.../ui/screens/BlogDetailScreen.kt` |
| Android WebView | New: `android-app/.../ui/components/BlogWebView.kt` (or equivalent) |
---
## 12. Optional: show_in_app
- **DB:** Column `show_in_app BOOLEAN DEFAULT true` (included in schema above).
- **Blog-editor dashboard:** Add toggle “Show in app” when creating/editing a post (for all types).
- **API-v1:** For GET list (and optionally get by id/slug), add `.where('show_in_app', true)` so the app only receives posts that are allowed in-app.
- **Web:** Unchanged; public GET by slug can ignore `show_in_app` so web can still show “published but not in app” posts if desired.
This gives you: Publish = visible on web; Show in app = visible in Android app.

510
docs/plan-link-posts.md Normal file
View File

@ -0,0 +1,510 @@
# Product & Implementation Plan: Link Posts (Already-Hosted Webpage)
**Document type:** Product / Implementation Plan
**Feature:** Link-only posts (external URL to already-hosted webpage)
**Scope:** Single content type extension; no HTML storage, no new servers.
**Status:** Complete
**Last updated:** 2025-02-10
---
## 1. Document control
| Version | Date | Author | Changes |
|---------|------------|--------|---------|
| 0.1 | 2025-02-10 | — | Initial full plan (link-only) |
| 0.2 | 2025-02-10 | — | Complete: decisions, implementation details, request/response examples, migration script, manual test script, Definition of Done, exact file paths |
**Stakeholders:** Product, Engineering (backend, frontend, mobile).
**Success owner:** Delivery of “publish link post in dashboard → viewable on web and in Android app” with zero regression on existing TipTap posts.
---
## 2. Executive summary
We are adding a second post type, **Link**, so authors can add a post that is only a **title + URL** pointing to an already-hosted webpage (any site: static HTML, React SPA, etc.). No content is stored beyond the URL. When a user publishes a link post:
- **Web:** Viewing the post (dashboard “View” or public `/blog/:slug`) sends the user to that URL (redirect or new tab).
- **Android app:** Opening the post shows the same URL inside an in-app WebView.
Existing **TipTap** posts (rich editor content stored in the DB) remain unchanged and fully supported. This plan defines scope, data model, APIs, UI/UX, implementation phases, testing, and rollout.
---
## 3. Problem & opportunity
**Problem:** Authors can only create posts by writing content in the TipTap editor. They cannot “add a post” that is simply a link to an external page they already host (e.g. a React app on Vercel, a landing page, a doc site).
**Opportunity:** One unified blog surface (dashboard + app) where some entries are rich editor content and others are links to external pages. Same publish/unpublish and listing; different rendering (TipTap vs WebView/redirect). No new infrastructure.
---
## 4. Goals and success criteria
| Goal | Success criteria |
|------|------------------|
| Support link posts | Author can create a post with only title + URL; it appears in dashboard and (when published) in app and on web. |
| Parity web ↔ app | If the URL is viewable in a browser, it is viewable in the Android app via WebView. |
| No regression | All existing TipTap posts continue to list, edit, and display correctly on web and in app. |
| Single source of control | Same backend and DB (blog-editor + Supabase); no new server or new app. |
**Out of scope for this plan:** Stored HTML, file uploads, “show in app” toggle, multiple blogs. Those can be separate follow-up plans.
---
## 5. Scope
### 5.1 In scope
- Database: Add `content_type` and `external_url` to `posts` (additive only).
- Blog-editor backend: Accept and return new fields; validate link posts (title + URL required).
- Blog-editor frontend: Post type selector (TipTap vs Link); Link form (title + URL); dashboard badge; public view redirect for link posts.
- API-v1: Return `contentType` and `externalUrl` in blog-posts responses.
- Android app: Extend model; for link posts, open URL in WebView instead of TipTap renderer.
### 5.2 Out of scope
- Stored HTML or raw HTML content.
- Uploading or hosting React/HTML builds; only storing a URL to an already-hosted page.
- “Show in app” vs “public only” (single visibility: published = web + app).
- New server or new deployment target; everything uses existing blog-editor backend and api-v1.
---
## 6. User personas and user stories
**Persona:** Blog author (uses dashboard at e.g. `http://localhost:4000/dashboard` and expects content to appear in the Android app.)
| ID | User story | Acceptance criteria |
|----|------------|---------------------|
| US-1 | As an author, I can create a “link” post with a title and URL so that I dont have to copy-paste content. | Create flow has “Link” option; form has Title + URL; save creates post with `content_type: 'link'` and `external_url` set. |
| US-2 | As an author, I can see which posts are links vs rich content in the dashboard. | List shows a “Link” badge (or similar) for link posts. |
| US-3 | As an author, I can edit or delete a link post like any other post. | Edit opens Link form with URL; Update/Delete work via existing API. |
| US-4 | As a reader on the web, when I open a published link post I am taken to the external page. | Public `/blog/:slug` for a link post redirects to `external_url` (or opens in new tab). |
| US-5 | As a reader in the Android app, when I open a published link post I see the external page inside the app. | Detail screen opens the URL in a WebView for link posts; TipTap posts still use TipTap renderer. |
| US-6 | As an author, my existing TipTap posts still work. | No change in list, edit, or view for posts without `content_type: 'link'` (or with `content_type: 'tiptap'`). |
---
## 7. Current state (as-is)
| Layer | Component | Behavior |
|-------|-----------|----------|
| DB | Supabase `posts` | Columns: `id`, `user_id`, `title`, `content_json` (JSONB), `slug`, `status` (draft/published), `created_at`, `updated_at`. All posts are editor content. |
| Backend | blog-editor `routes/posts.js` | GET list returns selected columns (no content_type). Create requires `title` + `content_json`. Update accepts title, content_json, status. GET by slug returns full row (public). |
| Frontend | Dashboard, Editor, BlogPost | Single editor type; create/update always send `content_json`. Public `/blog/:slug` renders TipTap from `content_json`. |
| API-v1 | `blog-posts.route.js` | Reads `posts` from Supabase; returns `content` (from content_json), no content_type or external_url. |
| Android | BlogApiClient, BlogDetailScreen | Fetches post; always renders body with `TipTapContentRenderer(content)`. |
---
## 8. Target state (to-be)
| Layer | Change |
|-------|--------|
| DB | `posts` has `content_type` (default `'tiptap'`), `external_url` (nullable). Existing rows default to tiptap. |
| Backend | List returns content_type, external_url. Create/update accept content_type and external_url; for link, require external_url and allow minimal/empty content_json. |
| Frontend | Editor: type selector (TipTap / Link). Link = title + URL only. Dashboard: badge for Link. Public: link post → redirect to external_url. |
| API-v1 | All blog-posts responses include `contentType`, `externalUrl`. |
| Android | Model has contentType, externalUrl. Detail: if link → WebView(externalUrl); else → TipTapContentRenderer. |
---
## 9. Data model and schema
### 9.1 New columns (Supabase `posts`)
| Column | Type | Default | Nullable | Description |
|--------|------|---------|----------|-------------|
| `content_type` | VARCHAR(20) | `'tiptap'` | No | Allowed: `'tiptap'`, `'link'`. |
| `external_url` | TEXT | — | Yes | For link posts: the URL to open. |
### 9.2 Migration SQL
Run once (Supabase SQL editor or migration script):
```sql
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS content_type VARCHAR(20) DEFAULT 'tiptap'
CHECK (content_type IN ('tiptap', 'link'));
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS external_url TEXT NULL;
```
- Existing rows: `content_type = 'tiptap'`, `external_url = NULL`.
- New link post: `content_type = 'link'`, `external_url = 'https://...'`. Keep `content_json` as `'{}'` if the column is NOT NULL.
### 9.3 Validation rules
- **Link post:** `content_type = 'link'``external_url` must be non-empty and should be a valid URL (scheme http/https). Backend may normalize (trim, add https if missing) per product choice.
- **TipTap post:** `content_type = 'tiptap'` or omitted ⇒ `content_json` required as today.
---
## 10. API contract
### 10.1 Blog-editor backend (existing routes)
**GET /api/posts** (list, authenticated)
- **Response (per post):** Include `content_type`, `external_url` in each object (in addition to existing fields).
**GET /api/posts/:id** (single, authenticated)
- **Response:** Full row; will include `content_type`, `external_url` once columns exist (e.g. via `SELECT *`).
**GET /api/posts/slug/:slug** (public)
- **Response:** Full row; same as above.
**POST /api/posts** (create, authenticated)
- **Request body (link):** `{ "title": "...", "content_type": "link", "external_url": "https://...", "status": "draft" }`. `content_json` may be omitted or `{}`.
- **Request body (tiptap):** Unchanged; `title` and `content_json` required; `content_type` optional or `'tiptap'`.
- **Response:** Created row including `content_type`, `external_url`.
**PUT /api/posts/:id** (update, authenticated)
- **Request body:** May include `content_type`, `external_url`. Same validation as create when present.
- **Response:** Updated row including new columns.
### 10.2 API-v1 (blog-posts)
**GET /blog-posts**, **GET /blog-posts/:id**, **GET /blog-posts/slug/:slug**, **GET /blog-posts/user/:userId**
- **Response mapping (add):** `contentType: post.content_type`, `externalUrl: post.external_url`. Keep existing `content` (from content_json) for backward compatibility.
---
## 11. UI/UX flows
### 11.1 Dashboard (list)
- **Display:** Each card shows title, status, date. For link posts, show a **“Link”** badge (or icon) so authors can distinguish from TipTap.
- **Actions:** Edit, (Un)Publish, Delete, View (for published). For link posts, “View” can open `external_url` in a new tab, or open `/blog/:slug` (which then redirects); both are acceptable; document choice.
### 11.2 Editor (create / edit)
- **Entry:** “New Post” or “Edit” opens editor. First or prominent choice: **Post type** — “TipTap” (default) or “Link”.
- **If TipTap:** Current UI (title + TipTap editor). Save sends `content_json`; optional `content_type: 'tiptap'`.
- **If Link:** Show only **Title** (required) and **URL** (required). Optional: slug override, status. No rich editor. Save sends `content_type: 'link'`, `external_url: <trimmed URL>`, `content_json: {}`. Publish button same as today (sets status to published).
- **Edit existing:** On load, if `content_type === 'link'`, show Link form and prefill URL; otherwise show TipTap editor.
### 11.3 Public blog page (web)
- **Route:** `/blog/:slug` (unchanged).
- **Behavior:** After fetching post by slug: if `content_type === 'link'` and `external_url` present ⇒ **redirect** to `external_url` (e.g. `window.location.href = post.external_url`). Else ⇒ render TipTap content as today.
### 11.4 Android app (detail)
- **Entry:** User taps a post in the blog list.
- **Behavior:** If `contentType == "link"` and `externalUrl != null` ⇒ open **WebView** with `externalUrl` (same URL as web). Else ⇒ render with **TipTapContentRenderer** using `content` (unchanged). Top bar (back, title) remains; content area is either WebView or TipTap.
---
## 12. Implementation phases
### Phase 1: Data and backend (no UI change)
| Task ID | Task | Owner | Acceptance criteria | Dependency |
|---------|------|--------|---------------------|------------|
| 1.1 | Run DB migration: add `content_type`, `external_url` | Backend | Columns exist; existing rows have default tiptap, NULL url | — |
| 1.2 | Blog-editor backend: extend GET list to return `content_type`, `external_url` | Backend | List response includes new fields | 1.1 |
| 1.3 | Blog-editor backend: create post — accept `content_type`, `external_url`; for link require URL, allow empty/minimal content_json | Backend | Create link post via API succeeds; tiptap create unchanged | 1.1 |
| 1.4 | Blog-editor backend: update post — accept and persist `content_type`, `external_url` | Backend | Update link post via API succeeds | 1.1 |
| 1.5 | API-v1: add `content_type`, `external_url` to select and response map (all blog-posts endpoints) | Backend | App can receive contentType, externalUrl | 1.1 |
**Phase 1 exit:** Backend and DB support link posts; existing clients ignore new fields; no regression.
---
### Phase 2: Android app (consume link posts)
| Task ID | Task | Owner | Acceptance criteria | Dependency |
|---------|------|--------|---------------------|------------|
| 2.1 | Android: add `contentType`, `externalUrl` to BlogPost model (nullable) | Mobile | Old API response still parses | Phase 1 |
| 2.2 | Android: add WebView composable (e.g. BlogWebView) that takes URL and loads in WebView | Mobile | Composable loads URL and shows loading state | — |
| 2.3 | Android: BlogDetailScreen — if link post, show WebView with externalUrl; else TipTapContentRenderer | Mobile | Link post opens in WebView; tiptap post unchanged | 2.1, 2.2 |
**Phase 2 exit:** Published link posts open in WebView in app; TipTap posts unchanged.
---
### Phase 3: Blog-editor frontend (create and view link posts)
| Task ID | Task | Owner | Acceptance criteria | Dependency |
|---------|------|--------|---------------------|------------|
| 3.1 | Dashboard: show “Link” badge (or icon) for posts where content_type === 'link' | Frontend | Author can see which posts are links | Phase 1 |
| 3.2 | Editor: add post type selector (TipTap / Link); when Link, show Title + URL form only; save with content_type and external_url | Frontend | Author can create and save link post from UI | Phase 1 |
| 3.3 | Editor: on load existing post, if link type show Link form and prefill URL | Frontend | Author can edit link post | 3.2 |
| 3.4 | BlogPost.jsx: if post is link and has external_url, redirect to external_url | Frontend | Public view of link post goes to external page | Phase 1 |
| 3.5 | Dashboard: “View” for link post — open external_url in new tab (or keep View as /blog/:slug; ensure 3.4 is in place) | Frontend | Consistent with product choice (new tab vs redirect) | 3.1 |
**Phase 3 exit:** Full flow: author creates link post in dashboard → publishes → sees it on web (redirect) and in app (WebView); existing TipTap flow unchanged.
### 12.4 Definition of Done (per phase)
| Phase | Done when |
|-------|-----------|
| Phase 1 | Migration applied; blog-editor and api-v1 return `content_type` and `external_url`; link post can be created/updated via API (e.g. cURL/Postman); existing tiptap create/update still works. |
| Phase 2 | App builds; link post opens in WebView; tiptap post still uses TipTapContentRenderer; no crash when `contentType` is null or missing. |
| Phase 3 | Author can select “Link” in editor, enter URL, save and publish; dashboard shows Link badge; opening published link post on web redirects to URL; “View” for link in dashboard opens URL (per decision below). |
---
## 13. Testing strategy
| Type | Scope | Key cases |
|------|--------|-----------|
| **Regression** | TipTap posts | List, create, edit, delete, publish, view on web, view in app. No behavior change. |
| **Link post API** | Backend + api-v1 | Create link (title + URL); GET list/id/slug return contentType, externalUrl; update link URL; link post has empty/minimal content_json. |
| **Link post Web** | Blog-editor frontend | Create link post; dashboard shows badge; edit link post; public /blog/:slug redirects to URL. |
| **Link post App** | Android | List shows link post; open link post → WebView loads URL; back and TipTap post still render with TipTap. |
| **Edge cases** | All | Invalid URL (backend validation); missing external_url for link type (400); old app version (ignores new fields, no crash). |
No new server or environment; use existing local/Supabase and api-v1.
---
## 14. Rollout and release
| Step | Action |
|------|--------|
| 1 | Deploy DB migration (add columns). |
| 2 | Deploy blog-editor backend and api-v1 (backward compatible). |
| 3 | Deploy Android app (new model fields + WebView branch). |
| 4 | Deploy blog-editor frontend (type selector, Link form, redirect, badge). |
| 5 | Smoke: create one link post, publish, view on web and in app; verify one TipTap post still works. |
**Rollback:** Remove new columns only if no link posts exist (or add a follow-up migration to clear link posts). Backend/frontend/app can be reverted to previous versions; default content_type and nullable external_url prevent breakage for existing rows.
---
## 15. Risks and mitigations
| Risk | Mitigation |
|------|------------|
| Existing posts break if content_type default wrong | Default `'tiptap'` and nullable external_url; no backfill required. |
| App crashes on unknown content_type | App treats only `"link"` as WebView; everything else (null, tiptap, future) uses TipTap. |
| Invalid or malicious URL stored | Backend: validate scheme (http/https), length, and optionally allowlist domains if needed. |
| WebView security (Android) | Use standard WebView; consider certificate pinning or safe-browsing only if required later. |
---
## 16. Decisions (resolved)
| Decision | Choice | Rationale |
|----------|--------|-----------|
| URL validation | **Moderate:** Non-empty string, must start with `http://` or `https://`. Trim whitespace. Max length 2048. | Balances safety and flexibility; no allowlist so any already-hosted page works. |
| Dashboard “View” for link post | **Open `external_url` in new tab** (target="_blank"). | Author expects to see the live page; redirect from /blog/:slug is for shared/public links; dashboard is author-facing. |
| Public `/blog/:slug` for link post | **Redirect** (window.location.href = external_url). | Shared link behavior matches “one URL”; app and web both end on the same external page. |
| content_json for link posts | **Store `{}`** (empty JSON object). | Keeps `content_json` NOT NULL without schema change; backend sends `'{}'` when creating/updating link post. |
| Serialization (API-v1 / Android) | **camelCase** in JSON: `contentType`, `externalUrl`. | Matches existing api-v1 style (e.g. createdAt, updatedAt). |
**Future work (out of scope):** Stored HTML, “show in app” toggle, multiple blogs — separate PRDs/plans.
---
## 17. Appendix
### 17.1 File reference (exact paths)
| Area | File(s) |
|------|--------|
| DB migration | `blog-editor/backend/migrations/add-link-post-columns.js` (new; see §20) or Supabase SQL editor |
| Blog-editor backend | `blog-editor/backend/routes/posts.js` |
| Blog-editor frontend | `blog-editor/frontend/src/pages/Dashboard.jsx`, `blog-editor/frontend/src/pages/Editor.jsx`, `blog-editor/frontend/src/pages/BlogPost.jsx` |
| API-v1 | `api-v1/routes/blog-posts.route.js` |
| Android model | `android-app/app/src/main/java/com/livingai/android/api/BlogApiClient.kt` (BlogPost data class) |
| Android detail | `android-app/app/src/main/java/com/livingai/android/ui/screens/BlogDetailScreen.kt` |
| Android WebView | New: `android-app/app/src/main/java/com/livingai/android/ui/components/BlogWebView.kt` |
### 17.2 Glossary
| Term | Meaning |
|------|--------|
| Link post | Post with `content_type: 'link'` and `external_url` set; no rich body stored. |
| TipTap post | Post with rich content in `content_json`, rendered by TipTap (web) and TipTapContentRenderer (app). |
| Already-hosted | The URL points to a page the author hosts elsewhere (Vercel, S3, etc.); we do not host the page. |
---
## 18. Implementation details (per file)
### 18.1 Blog-editor backend (`blog-editor/backend/routes/posts.js`)
- **GET /** (list): Change SELECT to include new columns:
- From: `SELECT id, title, slug, status, created_at, updated_at`
- To: `SELECT id, title, slug, status, content_type, external_url, created_at, updated_at`
- **POST /** (create):
- Read `content_type`, `external_url` from `req.body`.
- If `content_type === 'link'`: require `title` and non-empty `external_url`; validate URL (starts with http:// or https://, length ≤ 2048, trim); set `content_json = '{}'` for INSERT. Return 400 if URL missing or invalid.
- Else (tiptap or omitted): require `title` and `content_json` as today.
- INSERT: add `content_type` and `external_url` to column list and values (e.g. `$6`, `$7`). Use default `'tiptap'` and NULL when not link.
- **PUT /:id** (update):
- Read `content_type`, `external_url` from `req.body`.
- If `content_type === 'link'` (or external_url provided): validate URL as above; add to dynamic updates: `content_type = $n`, `external_url = $n+1`.
- Append these to the existing dynamic update logic; ensure slug/title/content_json/status logic is unchanged.
- **GET /:id** and **GET /slug/:slug**: Use `SELECT *` (or add `content_type`, `external_url` to SELECT). No change if already `SELECT *`.
### 18.2 API-v1 (`api-v1/routes/blog-posts.route.js`)
- In **every** blog-posts endpoint (GET list, GET :id, GET slug/:slug, GET user/:userId):
- **build/select:** Add `content_type`, `external_url` to the `.select()` list (e.g. `.select('id', 'title', 'slug', 'content_json', 'status', 'created_at', 'updated_at', 'user_id', 'content_type', 'external_url')`). If using `.first()` without explicit select, ensure the table has the columns and the returned row includes them.
- **map:** Add to the returned object: `contentType: post.content_type`, `externalUrl: post.external_url`. Use optional chaining or default so missing columns dont break (e.g. `contentType: post.content_type ?? 'tiptap'`, `externalUrl: post.external_url ?? null`).
### 18.3 Android app
- **BlogPost model** (`BlogApiClient.kt`): Add to `BlogPost` data class:
- `val contentType: String? = null`
- `val externalUrl: String? = null`
- Use `@SerialName("contentType")` and `@SerialName("externalUrl")` if the serializer is strict about naming.
- **BlogDetailScreen.kt:** In `BlogPostContent` (or the composable that chooses content): accept `post: BlogPost`. If `post.contentType == "link"` and `post.externalUrl != null`, render a full-screen WebView (or new composable `BlogWebView(post.externalUrl)`). Else, keep existing `TipTapContentRenderer(content = post.content, ...)`.
- **BlogWebView.kt** (new): Composable that takes `url: String`, uses `AndroidView` + `WebView`, calls `webView.loadUrl(url)`, enables JavaScript, optionally shows a loading indicator until `onPageFinished`. Use `Modifier.fillMaxSize()` inside the content slot of the existing scaffold.
### 18.4 Blog-editor frontend
- **Dashboard.jsx:** When mapping over `posts`, for each post if `post.content_type === 'link'` (or `post.contentType` if backend camelCases) render a small badge “Link” next to the status. For “View” button when post is link, use `<a href={post.external_url} target="_blank" rel="noopener noreferrer">View</a>` (or equivalent) so it opens in new tab.
- **Editor.jsx:** Add state: `contentType: 'tiptap' | 'link'` (default `'tiptap'`), `externalUrl: string` (default `''`). On mount when editing (`id` present), after `fetchPost` set `contentType` from `res.data.content_type` and `externalUrl` from `res.data.external_url`. Add a type selector at the top (e.g. two buttons or radio: “Article” / “Link”). When “Link” is selected: show Title input + URL input (type="url" or type="text"); hide the TipTap Editor component. On save (auto-save and Publish): if `contentType === 'link'` send `content_type: 'link'`, `external_url: externalUrl.trim()`, `content_json: {}`; else send current payload. For link, require `title` and `externalUrl` before save/publish.
- **BlogPost.jsx:** After `setPost(res.data)` (or in a useEffect that runs when `post` is set), if `post.content_type === 'link'` and `post.external_url` then `window.location.href = post.external_url` (redirect). Else keep existing TipTap rendering (editor and setContent).
**Note:** If the public slug endpoint requires authentication, the blog-editor frontend may need to call a different endpoint for unauthenticated public view (e.g. api-v1 GET blog-posts/slug/:slug with no auth, or blog-editor backend may expose an unauthenticated slug route). Resolve per current auth setup.
---
## 19. Request/response examples
### 19.1 Create link post (blog-editor backend)
**Request:** `POST /api/posts` (with Authorization header)
```json
{
"title": "Our React Demo",
"content_type": "link",
"external_url": "https://my-app.vercel.app",
"status": "draft"
}
```
**Response:** `201 Created`
```json
{
"id": "uuid",
"user_id": "uuid",
"title": "Our React Demo",
"content_json": {},
"slug": "our-react-demo-1234567890",
"status": "draft",
"content_type": "link",
"external_url": "https://my-app.vercel.app",
"created_at": "...",
"updated_at": "..."
}
```
### 19.2 Create TipTap post (unchanged)
**Request:** `POST /api/posts`
```json
{
"title": "My Article",
"content_json": { "type": "doc", "content": [...] },
"status": "draft"
}
```
### 19.3 API-v1 response (single post) with link fields
**Response:** `GET /blog-posts/:id` or `GET /blog-posts/slug/:slug`
```json
{
"id": "uuid",
"title": "Our React Demo",
"slug": "our-react-demo-1234567890",
"content": {},
"status": "published",
"createdAt": "...",
"updatedAt": "...",
"userId": "uuid",
"contentType": "link",
"externalUrl": "https://my-app.vercel.app"
}
```
---
## 20. Migration script
Create `blog-editor/backend/migrations/add-link-post-columns.js` and run from `blog-editor/backend`: `node migrations/add-link-post-columns.js`. Ensure `.env` is loaded (same as existing `migrate.js`).
```javascript
import { pool } from '../config/database.js'
import dotenv from 'dotenv'
dotenv.config()
async function up() {
await pool.query(`
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS content_type VARCHAR(20) DEFAULT 'tiptap'
CHECK (content_type IN ('tiptap', 'link'));
`)
await pool.query(`
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS external_url TEXT NULL;
`)
console.log('✓ add-link-post-columns: content_type, external_url added')
process.exit(0)
}
up().catch(err => {
console.error(err)
process.exit(1)
})
```
Alternatively run the SQL in §9.2 directly in the Supabase SQL editor.
---
## 21. Manual test script
Use this after each phase to verify behavior.
### Phase 1 (backend only)
1. Run migration; verify columns exist in Supabase.
2. **Create link post (API):** `curl -X POST http://localhost:5001/api/posts -H "Content-Type: application/json" -H "Authorization: Bearer <token>" -d '{"title":"Test Link","content_type":"link","external_url":"https://example.com","status":"draft"}'`. Expect 201 and response with `content_type`, `external_url`.
3. **List posts:** GET /api/posts; response includes `content_type`, `external_url` for each.
4. **Create TipTap post (API):** Same as before (title + content_json); expect 201. No regression.
5. **API-v1:** With auth, GET /blog-posts; each post has `contentType`, `externalUrl` in response.
### Phase 2 (Android)
1. Install app; open Blogs list; tap a **TipTap** post → content renders as before.
2. Publish one **link** post (via API or Phase 3 UI). In app, tap that post → WebView opens with the URL; back returns to list.
3. Tap TipTap post again → still TipTap renderer. No crash.
### Phase 3 (frontend)
1. **Dashboard:** Create new post; select “Link”; enter title and URL; save. Card shows “Link” badge. “View” opens URL in new tab.
2. **Editor:** Edit the link post; URL is prefilled; change URL and save; confirm update.
3. **Public view:** Open `/blog/:slug` for the link post (in incognito or different browser if slug is public) → redirects to external_url.
4. **TipTap:** Create/edit a normal post; list, edit, view unchanged.
5. **App:** Same as Phase 2; link post in WebView, TipTap in renderer.
### Regression checklist
- [ ] Existing TipTap post lists in dashboard and in app.
- [ ] Existing TipTap post opens in editor with content; save works.
- [ ] Existing TipTap post view on web shows content; in app shows TipTap renderer.
- [ ] Publish / Unpublish / Delete work for both link and TipTap posts.
---
*End of plan.*

View File

@ -4,7 +4,7 @@ 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 { Link } from '../extensions/Link'
import { FontSize } from '../extensions/FontSize'
import { ImageResize } from '../extensions/ImageResize'
import { YouTube } from '../extensions/YouTube'

View File

@ -0,0 +1,90 @@
/**
* Minimal Link mark for TipTap when @tiptap/extension-link is not installed.
* Run: cd blog-editor/frontend && npm install
* to use the full official extension instead.
*/
import { Mark, mergeAttributes } from '@tiptap/core'
function isAllowedUri(uri) {
if (!uri || typeof uri !== 'string') return false
const trimmed = uri.trim()
return /^https?:\/\//i.test(trimmed) || /^mailto:/i.test(trimmed) || /^tel:/i.test(trimmed)
}
export const Link = Mark.create({
name: 'link',
addOptions() {
return {
openOnClick: true,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
}
},
addAttributes() {
return {
href: {
default: null,
parseHTML: (element) => element.getAttribute('href'),
renderHTML: (attrs) => (attrs.href ? { href: attrs.href } : {}),
},
target: {
default: this.options.HTMLAttributes?.target ?? '_blank',
},
rel: {
default: this.options.HTMLAttributes?.rel ?? 'noopener noreferrer nofollow',
},
title: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: 'a[href]',
getAttrs: (dom) => {
const href = dom.getAttribute('href')
if (!href || !isAllowedUri(href)) return false
return null
},
},
]
},
renderHTML({ HTMLAttributes }) {
const href = HTMLAttributes.href
if (!href || !isAllowedUri(href)) {
return ['a', mergeAttributes(this.options.HTMLAttributes || {}, { ...HTMLAttributes, href: '' }), 0]
}
return ['a', mergeAttributes(this.options.HTMLAttributes || {}, HTMLAttributes), 0]
},
addCommands() {
return {
setLink:
(attributes) =>
({ chain }) => {
const { href } = attributes || {}
if (!href || !isAllowedUri(href)) return false
return chain().setMark(this.name, attributes).run()
},
toggleLink:
(attributes) =>
({ chain }) => {
const href = attributes?.href
if (href && !isAllowedUri(href)) return false
return chain().toggleMark(this.name, attributes, { extendEmptyMarkRange: true }).run()
},
unsetLink:
() =>
({ chain }) => {
return chain().unsetMark(this.name, { extendEmptyMarkRange: true }).run()
},
}
},
})

View File

@ -6,7 +6,7 @@ import Image from '@tiptap/extension-image'
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 { Link } from '../extensions/Link'
import { FontSize } from '../extensions/FontSize'
import api from '../utils/api'
@ -48,13 +48,21 @@ export default function BlogPost() {
}, [post, editor])
const fetchPost = async () => {
let redirecting = false
try {
const res = await api.get(`/posts/slug/${slug}`)
setPost(res.data)
const data = res.data
// Link post: redirect to external URL so reader sees the same page as in app
if (data.content_type === 'link' && data.external_url) {
redirecting = true
window.location.href = data.external_url
return
}
setPost(data)
} catch (error) {
console.error('Failed to load post:', error)
console.error('Failed to load post', error)
} finally {
setLoading(false)
if (!redirecting) setLoading(false)
}
}

View File

@ -104,15 +104,22 @@ export default function Dashboard() {
<h3 className="text-base sm:text-lg font-semibold text-gray-900 line-clamp-2 min-w-0">
{post.title || 'Untitled'}
</h3>
<span
className={`px-2 py-1 text-xs rounded flex-shrink-0 ${
post.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{post.status}
</span>
<div className="flex flex-shrink-0 items-center gap-1.5 flex-wrap justify-end">
{post.content_type === 'link' && (
<span className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800">
Link
</span>
)}
<span
className={`px-2 py-1 text-xs rounded ${
post.status === 'published'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{post.status}
</span>
</div>
</div>
<p className="text-sm text-gray-500 mb-4">
{new Date(post.updated_at).toLocaleDateString()}
@ -125,13 +132,24 @@ export default function Dashboard() {
Edit
</Link>
{post.status === 'published' && (
<Link
to={`/blog/${post.slug}`}
target="_blank"
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
</Link>
post.content_type === 'link' && post.external_url ? (
<a
href={post.external_url}
target="_blank"
rel="noopener noreferrer"
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
</a>
) : (
<Link
to={`/blog/${post.slug}`}
target="_blank"
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
</Link>
)
)}
<button
onClick={() => handlePublish(post)}

View File

@ -11,6 +11,8 @@ export default function EditorPage() {
const [title, setTitle] = useState('')
const [content, setContent] = useState(null)
const [createdAt, setCreatedAt] = useState(null)
const [contentType, setContentType] = useState('tiptap') // 'tiptap' | 'link'
const [externalUrl, setExternalUrl] = useState('')
const [loading, setLoading] = useState(!!id)
const [saving, setSaving] = useState(false)
const [showPreview, setShowPreview] = useState(false)
@ -33,40 +35,58 @@ export default function EditorPage() {
}
}, [id])
// Build post payload based on content type
const buildPostData = useCallback((overrides = {}) => {
const isLink = contentType === 'link'
const base = {
title: title?.trim() || 'Untitled',
status: overrides.status ?? 'draft',
}
if (isLink) {
return {
...base,
content_type: 'link',
external_url: externalUrl.trim(),
content_json: {},
...overrides,
}
}
return {
...base,
content_json: content || {},
...overrides,
}
}, [title, content, contentType, externalUrl])
// Debounced auto-save function
const handleAutoSave = useCallback(async () => {
// Don't save if nothing has changed
if (!title && !content) return
// Don't save during initial load
if (isInitialLoadRef.current) return
const isLink = contentType === 'link'
if (isLink) {
if (!title?.trim() || !externalUrl?.trim()) return
} else {
if (!title && !content) return
}
try {
setSaving(true)
const postData = {
title: title || 'Untitled',
content_json: content || {},
status: 'draft',
}
const postData = buildPostData({ status: 'draft' })
let postId = currentPostIdRef.current
if (postId) {
// Update existing post
await api.put(`/posts/${postId}`, postData)
} else {
// Create new post
const res = await api.post('/posts', postData)
postId = res.data.id
currentPostIdRef.current = postId
// Update URL without reload
window.history.replaceState({}, '', `/editor/${postId}`)
}
} catch (error) {
console.error('Auto-save failed:', error)
// Don't show error toast for auto-save failures to avoid annoying user
} finally {
setSaving(false)
}
}, [title, content])
}, [title, content, contentType, externalUrl, buildPostData])
// Debounced save on content change
useEffect(() => {
@ -91,7 +111,7 @@ export default function EditorPage() {
clearTimeout(autoSaveTimeoutRef.current)
}
}
}, [title, content, handleAutoSave])
}, [title, content, contentType, externalUrl, handleAutoSave])
const fetchPost = async () => {
try {
@ -100,6 +120,8 @@ export default function EditorPage() {
setTitle(post.title || '')
setContent(post.content_json || null)
setCreatedAt(post.created_at || null)
setContentType(post.content_type === 'link' ? 'link' : 'tiptap')
setExternalUrl(post.external_url || '')
isInitialLoadRef.current = true // Reset after loading
} catch (error) {
toast.error('Failed to load post')
@ -119,18 +141,22 @@ export default function EditorPage() {
return
}
if (!content) {
toast.error('Please add some content')
return
if (contentType === 'link') {
const url = externalUrl.trim()
if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) {
toast.error('Please enter a valid URL (http:// or https://)')
return
}
} else {
if (!content) {
toast.error('Please add some content')
return
}
}
try {
setSaving(true)
const postData = {
title: title.trim(),
content_json: content,
status: 'published',
}
const postData = buildPostData({ status: 'published' })
let postId = currentPostIdRef.current || id
if (postId) {
@ -209,6 +235,31 @@ export default function EditorPage() {
{/* Main Editor Section */}
<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-3 sm:px-6 lg:px-8 py-4 sm:py-8">
{/* Post type selector */}
<div className="mb-4 flex gap-2">
<button
type="button"
onClick={() => setContentType('tiptap')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${
contentType === 'tiptap'
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
Article
</button>
<button
type="button"
onClick={() => setContentType('link')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${
contentType === 'link'
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
Link
</button>
</div>
{/* Card-style editor block */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-visible">
<div className="px-4 sm:px-6 pt-4 sm:pt-6 pb-2">
@ -221,23 +272,37 @@ export default function EditorPage() {
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 className="px-2">
<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
content={content}
onChange={setContent}
postId={id || currentPostIdRef.current}
sessionId={!id ? sessionIdRef.current : null}
/>
</div>
{contentType === 'link' ? (
<div className="px-4 sm:px-6 pb-6">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">URL</label>
<input
type="url"
placeholder="https://..."
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-500">The page readers will see when they open this post.</p>
</div>
) : (
<div className="px-2">
<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
content={content}
onChange={setContent}
postId={id || currentPostIdRef.current}
sessionId={!id ? sessionIdRef.current : null}
/>
</div>
)}
</div>
</div>
</main>
{/* Mobile Preview - sidebar on lg+, full-width panel on smaller screens */}
{showPreview && (
<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="flex-1 min-h-0 p-3 sm:p-4 overflow-auto">
<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 lg:min-h-0 lg:max-h-full overflow-hidden">
<div className="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
@ -251,11 +316,21 @@ export default function EditorPage() {
</svg>
</button>
</div>
<MobilePreview
title={title}
content={content}
createdAt={createdAt}
/>
{contentType === 'link' ? (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<span className="text-xs font-medium text-blue-600 uppercase tracking-wide">Link</span>
<p className="mt-2 font-semibold text-gray-900">{title || 'Untitled'}</p>
{externalUrl && (
<p className="mt-1 text-sm text-gray-500 break-all">Opens: {externalUrl}</p>
)}
</div>
) : (
<MobilePreview
title={title}
content={content}
createdAt={createdAt}
/>
)}
</div>
</aside>
)}

66
test-link-post-api.md Normal file
View File

@ -0,0 +1,66 @@
# Testing Link Post Flow
## Quick verification checklist
### 1. Check post status in dashboard
- Open: http://localhost:4000/dashboard
- Find "Cow" post
- Status should show "published" (green badge)
- Should also show "Link" badge (blue)
- If status is "draft", click "Publish"
### 2. Test API-v1 is returning the new fields
Run this in a terminal (with a valid JWT token from the app):
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3200/blog-posts
```
Expected response should include for the Cow post:
```json
{
"id": "52d09d04-1b01-459b-bb54-4f59c303912a",
"title": "Cow",
"contentType": "link", // NEW FIELD
"externalUrl": "https://yourfamilyfarmer.com/blog/...", // NEW FIELD
"status": "published",
...
}
```
If `contentType` and `externalUrl` are **missing**, api-v1 wasn't restarted after the code changes.
### 3. Android app checklist
For the Android app to show the link post:
- [ ] Post status is **published** (not draft)
- [ ] api-v1 is running with the updated code (returns contentType, externalUrl)
- [ ] Android app was **rebuilt** after the BlogPost model was changed (added contentType, externalUrl fields)
- [ ] Android app opened the "Blogs" tab
### 4. If still not working
Check logcat from Android Studio:
```
adb logcat | grep -i "blog"
```
Look for:
- "Successfully fetched N blog posts"
- Any deserialization errors (would mean the model doesn't match the API response)
- "Blog post not found" or 404 errors
### 5. Quick debug: Check what the API returns
Without auth, check if the post exists:
```bash
# From blog-editor backend (may need auth token):
curl http://localhost:5001/api/posts/slug/cow-1770673121207
# From api-v1 (needs auth):
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3200/blog-posts/slug/cow-1770673121207
```
Should return the full post with `content_type: "link"` and `external_url: "https://..."`.