Skip to content

(SP: 3) [Frontend] Admin Blog: Posts CRUD with Tiptap editor (draft + scheduled publish) #387

@LesiaUKR

Description

@LesiaUKR

Goal

Admin can create, edit, publish, schedule, and delete blog posts through the existing admin panel. Blog post body is edited via Tiptap WYSIWYG editor with image insertion. Supports draft/publish/scheduled workflows with multilingual content (uk/en/pl).

Scope

Admin routes

  • app/[locale]/admin/blog/page.tsx — post list table (sort: drafts first, then by updatedAt DESC)
  • app/[locale]/admin/blog/new/page.tsx — create post
  • app/[locale]/admin/blog/[id]/page.tsx — edit post
  • app/[locale]/admin/blog/[id]/preview/page.tsx — preview post (locale-aware, any status)

Components (components/admin/blog/)

  1. BlogPostListTable.tsx — responsive table (desktop + mobile cards):

    • Status badges: Draft (amber) / Scheduled (sky) / Published (emerald)
    • Actions: Publish/Unpublish toggle (fixed width), Preview (new tab), Edit, Delete (disabled with tooltip for published posts)
    • Centered Actions column
  2. BlogPostForm.tsx — combined create/edit form:

    • Locale tabs (uk/en/pl) for: title (text input) + body (Tiptap editor)
    • Single-locale fields: slug (auto-generated from EN title, editable), tags (comma-separated), resourceLink
    • Category checkboxes + inline creation (InlineBlogCategoryForm)
    • Author dropdown + inline creation (InlineBlogAuthorForm)
    • Main image upload (via BlogImageUpload)
    • Publish controls (BlogPublishControls)
    • Validation: all 3 locale titles + bodies required (no fallback to EN)
    • Edit mode: dirty tracking via JSON snapshot comparison (useRef), button disabled until changes made
    • Create mode: button disabled until required fields valid
  3. BlogTiptapEditor.tsx — Tiptap instance with:

    • Extensions: StarterKit, CodeBlockLowlight (github-dark theme), @tiptap/extension-image, @tiptap/extension-link, TaskList + TaskItem (checklists)
    • Toolbar (sticky): Bold | Italic | Strike | Code | H2 | H3 | Blockquote | HR | Bullet List | Ordered List | Checklist | Code Block | Link | Image
    • Image button: opens file picker → uploads via POST /api/admin/blog/images → inserts Cloudinary URL
  4. BlogImageUpload.tsx — file input for main image, uploads to Cloudinary, previews result. Supports initialUrl for edit mode.

  5. BlogPublishControls.tsx — publish workflow:

    • "Save Draft" — isPublished: false
    • "Publish Now" — isPublished: true, scheduledPublishAt: null
    • "Schedule" — isPublished: true, scheduledPublishAt: <datetime picker>
    • Shows current status when editing existing post
  6. InlineBlogCategoryForm.tsx — inline 3-locale category creation from post form

  7. InlineBlogAuthorForm.tsx — inline 3-locale author creation from post form

API routes (app/api/admin/blog/)

  • route.ts — POST (create with publish mode handling)
  • [id]/route.ts — PUT (update), DELETE (draft only), PATCH (publish toggle + revalidatePath)
  • images/route.ts — POST: receive File → uploadImage(file, { folder: 'blog/posts' }) → return { url, publicId }
  • categories/route.ts — POST: inline category creation
  • authors/route.ts — POST: inline author creation

All routes: origin guard + CSRF validation + admin auth. Protected via layout guardAdminPage().

Upsert pattern for translations (Neon HTTP, no transactions):

db.insert(blogPostTranslations).values({ postId, locale, title, body }).onConflictDoUpdate({ target: [postId, locale], set: { title, body } })

Query layer — db/queries/blog/admin-blog.ts

  • getAdminBlogList() — all posts sorted by isPublished ASC, updatedAt DESC
  • getAdminBlogPostById(id) — single post with all locale translations + categoryIds
  • createBlogPost() — insert post + translations + category junctions
  • updateBlogPost() — update post + upsert translations + sync categories
  • deleteBlogPost() — delete (cascade handles translations + categories)
  • toggleBlogPostPublish() — set isPublished + publishedAt + scheduledPublishAt
  • getAdminBlogAuthors() / getAdminBlogCategories() — dropdown data
  • createBlogCategory() / createBlogAuthor() — inline creation

Validation — lib/validation/admin-blog.ts

  • createBlogPostSchema — Zod schema with 3-locale translations, nullable fields, publishMode enum, schedule date refine
  • createBlogCategorySchema / createBlogAuthorSchema — inline creation schemas

BlogPostRenderer update — components/blog/BlogPostRenderer.tsx

  • Added taskList node rendering (no bullets, space-y-3, [&_li_p]:mb-0)
  • Added taskItem node rendering (flex + baseline alignment, checkmark icon)

AdminSidebar update

Added Blog section to NAV_SECTIONS in components/admin/AdminSidebar.tsx:

  • Posts → /admin/blog
  • New Post → /admin/blog/new
  • Authors → /admin/blog/authors
  • Categories → /admin/blog/categories

Delete constraint: button always visible, disabled with tooltip ("Unpublish first to delete") for published posts. Draft posts can be deleted with confirmation.

Verification (all passed):

  1. Create post → save as draft → NOT visible on public /blog
  2. Publish immediately → appears on /blog after revalidate
  3. Edit published post → changes reflected after revalidate
  4. Unpublish from list page → status changes to Draft
  5. Delete draft → removed
  6. Preview draft → renders correctly with locale switching
  7. Inline category/author creation → appears in dropdowns immediately
  8. Dirty tracking → Update button disabled until changes made
  9. Task list items → render as checklists (not bullets) on preview
  10. Code blocks → syntax highlighting with github-dark theme

Depends on: Issue #384, #386

Expected impact

Admin can write and manage all blog content without Sanity. Full draft → schedule → publish workflow. Preview before publishing. Inline category/author creation without leaving the form.

Out of scope

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions