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/)
-
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
-
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
-
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
-
BlogImageUpload.tsx — file input for main image, uploads to Cloudinary, previews result. Supports initialUrl for edit mode.
-
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
-
InlineBlogCategoryForm.tsx — inline 3-locale category creation from post form
-
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):
- Create post → save as draft → NOT visible on public /blog
- Publish immediately → appears on /blog after revalidate
- Edit published post → changes reflected after revalidate
- Unpublish from list page → status changes to Draft
- Delete draft → removed
- Preview draft → renders correctly with locale switching
- Inline category/author creation → appears in dropdowns immediately
- Dirty tracking → Update button disabled until changes made
- Task list items → render as checklists (not bullets) on preview
- 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
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 postapp/[locale]/admin/blog/[id]/page.tsx— edit postapp/[locale]/admin/blog/[id]/preview/page.tsx— preview post (locale-aware, any status)Components (
components/admin/blog/)BlogPostListTable.tsx— responsive table (desktop + mobile cards):BlogPostForm.tsx— combined create/edit form:BlogTiptapEditor.tsx— Tiptap instance with:BlogImageUpload.tsx— file input for main image, uploads to Cloudinary, previews result. Supports initialUrl for edit mode.BlogPublishControls.tsx— publish workflow:isPublished: falseisPublished: true,scheduledPublishAt: nullisPublished: true,scheduledPublishAt: <datetime picker>InlineBlogCategoryForm.tsx— inline 3-locale category creation from post formInlineBlogAuthorForm.tsx— inline 3-locale author creation from post formAPI 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 creationauthors/route.ts— POST: inline author creationAll 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.tsgetAdminBlogList()— all posts sorted by isPublished ASC, updatedAt DESCgetAdminBlogPostById(id)— single post with all locale translations + categoryIdscreateBlogPost()— insert post + translations + category junctionsupdateBlogPost()— update post + upsert translations + sync categoriesdeleteBlogPost()— delete (cascade handles translations + categories)toggleBlogPostPublish()— set isPublished + publishedAt + scheduledPublishAtgetAdminBlogAuthors()/getAdminBlogCategories()— dropdown datacreateBlogCategory()/createBlogAuthor()— inline creationValidation —
lib/validation/admin-blog.tscreateBlogPostSchema— Zod schema with 3-locale translations, nullable fields, publishMode enum, schedule date refinecreateBlogCategorySchema/createBlogAuthorSchema— inline creation schemasBlogPostRenderer update —
components/blog/BlogPostRenderer.tsxtaskListnode rendering (no bullets, space-y-3,[&_li_p]:mb-0)taskItemnode rendering (flex + baseline alignment, checkmark icon)AdminSidebar update
Added Blog section to NAV_SECTIONS in
components/admin/AdminSidebar.tsx: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):
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