+ No body content for {lang.toUpperCase()} locale +
+ )} +diff --git a/frontend/app/[locale]/admin/blog/[id]/page.tsx b/frontend/app/[locale]/admin/blog/[id]/page.tsx new file mode 100644 index 00000000..bb6aff17 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/[id]/page.tsx @@ -0,0 +1,66 @@ +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { BlogPostForm } from '@/components/admin/blog/BlogPostForm'; +import { + getAdminBlogAuthors, + getAdminBlogCategories, + getAdminBlogPostById, +} from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Edit Post | DevLovers', +}; + +export default async function AdminBlogEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const [post, authors, categories] = await Promise.all([ + getAdminBlogPostById(id), + getAdminBlogAuthors(), + getAdminBlogCategories(), + ]); + + if (!post) notFound(); + + const title = post.translations.en?.title ?? post.slug; + + const csrfTokenPost = issueCsrfToken('admin:blog:update'); + const csrfTokenCategory = issueCsrfToken('admin:blog-category:create'); + const csrfTokenAuthor = issueCsrfToken('admin:blog-author:create'); + const csrfTokenImage = issueCsrfToken('admin:blog:image'); + + return ( +
+ No body content for {lang.toUpperCase()} locale +
+ )} ++ Manage blog authors and their profiles +
++ Manage blog posts, drafts, and publishing +
+| Photo | +Name | +Job Title | +Posts | +Actions | +
|---|---|---|---|---|
|
+ {author.imageUrl ? (
+
+ ?
+
+ )}
+ |
+ + {author.name} + | ++ {author.jobTitle ?? '-'} + | ++ {author.postCount} + | +
+
+
+ Edit
+
+
+
+ |
+
+ Manage blog categories and display order +
+New Category
+ +{createError}
} + + +{editError}
} + +{error}
} +| Title | +Author | +Status | +Published | +Updated | +Actions | +
|---|---|---|---|---|---|
|
+
+ {post.title}
+
+ |
+ + {post.authorName ?? '-'} + | +
+ |
+ + {formatDate(post.publishedAt)} + | ++ {formatDate(post.updatedAt)} + | +
+
+
+
+ Preview
+
+
+ Edit
+
+
+
+ |
+
New Author
+ +{error}
} + +New Blog Category
+ +{error}
} + + {
+ const rows = await db
+ .select({
+ id: blogPosts.id,
+ slug: blogPosts.slug,
+ title: blogPostTranslations.title,
+ authorName: blogAuthorTranslations.name,
+ isPublished: blogPosts.isPublished,
+ publishedAt: blogPosts.publishedAt,
+ scheduledPublishAt: blogPosts.scheduledPublishAt,
+ createdAt: blogPosts.createdAt,
+ updatedAt: blogPosts.updatedAt,
+ })
+ .from(blogPosts)
+ .leftJoin(
+ blogPostTranslations,
+ sql`${blogPostTranslations.postId} = ${blogPosts.id} AND ${blogPostTranslations.locale} = ${ADMIN_LOCALE}`
+ )
+ .leftJoin(blogAuthors, eq(blogAuthors.id, blogPosts.authorId))
+ .leftJoin(
+ blogAuthorTranslations,
+ sql`${blogAuthorTranslations.authorId} = ${blogAuthors.id} AND ${blogAuthorTranslations.locale} = ${ADMIN_LOCALE}`
+ )
+ .orderBy(
+ sql`${blogPosts.isPublished} ASC`,
+ sql`${blogPosts.updatedAt} DESC`
+ );
+
+ return rows.map(row => ({
+ id: row.id,
+ slug: row.slug,
+ title: row.title ?? '(untitled)',
+ authorName: row.authorName,
+ isPublished: row.isPublished,
+ publishedAt: row.publishedAt,
+ scheduledPublishAt: row.scheduledPublishAt,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ }));
+}
+
+// ── Detail (for editing) ────────────────────────────────────────
+
+export interface AdminBlogTranslation {
+ title: string;
+ body: unknown;
+}
+
+export interface AdminBlogPostFull {
+ id: string;
+ slug: string;
+ authorId: string | null;
+ mainImageUrl: string | null;
+ mainImagePublicId: string | null;
+ tags: string[];
+ resourceLink: string | null;
+ isPublished: boolean;
+ publishedAt: Date | null;
+ scheduledPublishAt: Date | null;
+ createdAt: Date;
+ updatedAt: Date;
+ translations: Record;
+ categoryIds: string[];
+}
+
+export async function getAdminBlogPostById(
+ postId: string
+): Promise {
+ const [post] = await db
+ .select({
+ id: blogPosts.id,
+ slug: blogPosts.slug,
+ authorId: blogPosts.authorId,
+ mainImageUrl: blogPosts.mainImageUrl,
+ mainImagePublicId: blogPosts.mainImagePublicId,
+ tags: blogPosts.tags,
+ resourceLink: blogPosts.resourceLink,
+ isPublished: blogPosts.isPublished,
+ publishedAt: blogPosts.publishedAt,
+ scheduledPublishAt: blogPosts.scheduledPublishAt,
+ createdAt: blogPosts.createdAt,
+ updatedAt: blogPosts.updatedAt,
+ })
+ .from(blogPosts)
+ .where(eq(blogPosts.id, postId))
+ .limit(1);
+
+ if (!post) return null;
+
+ const transRows = await db
+ .select({
+ locale: blogPostTranslations.locale,
+ title: blogPostTranslations.title,
+ body: blogPostTranslations.body,
+ })
+ .from(blogPostTranslations)
+ .where(eq(blogPostTranslations.postId, postId));
+
+ const translations: Record = {};
+ for (const t of transRows) {
+ translations[t.locale] = { title: t.title, body: t.body };
+ }
+
+ const catRows = await db
+ .select({ categoryId: blogPostCategories.categoryId })
+ .from(blogPostCategories)
+ .where(eq(blogPostCategories.postId, postId));
+
+ const categoryIds = catRows.map(r => r.categoryId);
+
+ return { ...post, translations, categoryIds };
+}
+
+// ── Create ──────────────────────────────────────────────────────
+
+export interface CreateBlogPostInput {
+ slug: string;
+ authorId: string | null;
+ mainImageUrl: string | null;
+ mainImagePublicId: string | null;
+ tags: string[];
+ resourceLink: string | null;
+ translations: Record;
+ categoryIds: string[];
+}
+
+export async function createBlogPost(
+ input: CreateBlogPostInput
+): Promise {
+ const [created] = await db
+ .insert(blogPosts)
+ .values({
+ slug: input.slug,
+ authorId: input.authorId,
+ mainImageUrl: input.mainImageUrl,
+ mainImagePublicId: input.mainImagePublicId,
+ tags: input.tags,
+ resourceLink: input.resourceLink,
+ isPublished: false,
+ })
+ .returning({ id: blogPosts.id });
+
+ const postId = created.id;
+
+ try {
+ for (const [locale, trans] of Object.entries(input.translations)) {
+ await db
+ .insert(blogPostTranslations)
+ .values({
+ postId,
+ locale,
+ title: trans.title,
+ body: trans.body,
+ })
+ .onConflictDoUpdate({
+ target: [blogPostTranslations.postId, blogPostTranslations.locale],
+ set: { title: trans.title, body: trans.body },
+ });
+ }
+
+ if (input.categoryIds.length > 0) {
+ await db.insert(blogPostCategories).values(
+ input.categoryIds.map(categoryId => ({
+ postId,
+ categoryId,
+ }))
+ );
+ }
+ } catch (error) {
+ await db.delete(blogPosts).where(eq(blogPosts.id, postId));
+ throw error;
+ }
+
+ return postId;
+}
+
+// ── Update ──────────────────────────────────────────────────────
+
+export interface UpdateBlogPostInput {
+ slug?: string;
+ authorId?: string | null;
+ mainImageUrl?: string | null;
+ mainImagePublicId?: string | null;
+ tags?: string[];
+ resourceLink?: string | null;
+ translations?: Record;
+ categoryIds?: string[];
+}
+
+export async function updateBlogPost(
+ postId: string,
+ input: UpdateBlogPostInput
+): Promise {
+ const baseUpdate: Record = {};
+ if (input.slug !== undefined) baseUpdate.slug = input.slug;
+ if (input.authorId !== undefined) baseUpdate.authorId = input.authorId;
+ if (input.mainImageUrl !== undefined)
+ baseUpdate.mainImageUrl = input.mainImageUrl;
+ if (input.mainImagePublicId !== undefined)
+ baseUpdate.mainImagePublicId = input.mainImagePublicId;
+ if (input.tags !== undefined) baseUpdate.tags = input.tags;
+ if (input.resourceLink !== undefined)
+ baseUpdate.resourceLink = input.resourceLink;
+
+ baseUpdate.updatedAt = new Date();
+ await db
+ .update(blogPosts)
+ .set(baseUpdate)
+ .where(eq(blogPosts.id, postId));
+
+ if (input.translations) {
+ for (const [locale, trans] of Object.entries(input.translations)) {
+ await db
+ .insert(blogPostTranslations)
+ .values({
+ postId,
+ locale,
+ title: trans.title,
+ body: trans.body,
+ })
+ .onConflictDoUpdate({
+ target: [blogPostTranslations.postId, blogPostTranslations.locale],
+ set: { title: trans.title, body: trans.body },
+ });
+ }
+ }
+
+ if (input.categoryIds !== undefined) {
+ await db
+ .delete(blogPostCategories)
+ .where(eq(blogPostCategories.postId, postId));
+
+ if (input.categoryIds.length > 0) {
+ await db.insert(blogPostCategories).values(
+ input.categoryIds.map(categoryId => ({
+ postId,
+ categoryId,
+ }))
+ );
+ }
+ }
+}
+
+// ── Delete ──────────────────────────────────────────────────────
+
+export async function deleteBlogPost(postId: string): Promise {
+ await db.delete(blogPosts).where(eq(blogPosts.id, postId));
+}
+
+// ── Publish toggle ──────────────────────────────────────────────
+
+interface PublishOptions {
+ isPublished: boolean;
+ scheduledPublishAt?: Date | null;
+}
+
+export async function toggleBlogPostPublish(
+ postId: string,
+ opts: PublishOptions
+): Promise {
+ const now = new Date();
+ const isScheduling =
+ opts.isPublished &&
+ opts.scheduledPublishAt != null &&
+ opts.scheduledPublishAt > now;
+ await db
+ .update(blogPosts)
+ .set({
+ isPublished: opts.isPublished && !isScheduling,
+ publishedAt: opts.isPublished && !isScheduling ? now : null,
+ scheduledPublishAt: isScheduling ? opts.scheduledPublishAt : null,
+ updatedAt: now,
+ })
+ .where(eq(blogPosts.id, postId));
+}
+
+// ── Dropdown data ───────────────────────────────────────────────
+
+export interface AdminBlogAuthorOption {
+ id: string;
+ name: string;
+}
+
+export async function getAdminBlogAuthors(): Promise {
+ const rows = await db
+ .select({
+ id: blogAuthors.id,
+ name: blogAuthorTranslations.name,
+ })
+ .from(blogAuthors)
+ .leftJoin(
+ blogAuthorTranslations,
+ sql`${blogAuthorTranslations.authorId} = ${blogAuthors.id} AND ${blogAuthorTranslations.locale} = ${ADMIN_LOCALE}`
+ )
+ .orderBy(blogAuthors.displayOrder);
+
+ return rows.map(r => ({
+ id: r.id,
+ name: r.name ?? '(unnamed)',
+ }));
+}
+
+export interface AdminBlogCategoryOption {
+ id: string;
+ slug: string;
+ title: string;
+}
+
+export async function getAdminBlogCategories(): Promise<
+ AdminBlogCategoryOption[]
+> {
+ const rows = await db
+ .select({
+ id: blogCategories.id,
+ slug: blogCategories.slug,
+ title: blogCategoryTranslations.title,
+ })
+ .from(blogCategories)
+ .leftJoin(
+ blogCategoryTranslations,
+ sql`${blogCategoryTranslations.categoryId} = ${blogCategories.id} AND ${blogCategoryTranslations.locale} = ${ADMIN_LOCALE}`
+ )
+ .orderBy(blogCategories.displayOrder);
+
+ return rows.map(r => ({
+ id: r.id,
+ slug: r.slug,
+ title: r.title ?? '(untitled)',
+ }));
+}
+
+// ── Create category (inline from post form) ─────────────────────
+
+export interface CreateBlogCategoryInput {
+ slug: string;
+ translations: Record;
+}
+
+export async function createBlogCategory(
+ input: CreateBlogCategoryInput
+): Promise<{ id: string; slug: string; title: string }> {
+ const [maxRow] = await db
+ .select({ max: sql`COALESCE(MAX(${blogCategories.displayOrder}), -1)` })
+ .from(blogCategories);
+
+ const [created] = await db
+ .insert(blogCategories)
+ .values({
+ slug: input.slug,
+ displayOrder: (maxRow?.max ?? -1) + 1,
+ })
+ .returning({ id: blogCategories.id });
+
+ const categoryId = created.id;
+
+ try {
+ for (const [locale, trans] of Object.entries(input.translations)) {
+ await db.insert(blogCategoryTranslations).values({
+ categoryId,
+ locale,
+ title: trans.title,
+ description: trans.description ?? null,
+ });
+ }
+ } catch (error) {
+ await db.delete(blogCategories).where(eq(blogCategories.id, categoryId));
+ throw error;
+ }
+
+ return {
+ id: categoryId,
+ slug: input.slug,
+ title: input.translations[ADMIN_LOCALE]?.title ?? input.slug,
+ };
+}
+
+// ── Create author (inline from post form) ────────────────────────
+
+export interface CreateBlogAuthorInput {
+ slug: string;
+ imageUrl?: string | null;
+ imagePublicId?: string | null;
+ socialMedia?: { platform: string; url: string }[];
+ translations: Record;
+}
+
+export async function createBlogAuthor(
+ input: CreateBlogAuthorInput
+): Promise<{ id: string; name: string }> {
+ const [maxRow] = await db
+ .select({ max: sql`COALESCE(MAX(${blogAuthors.displayOrder}), -1)` })
+ .from(blogAuthors);
+
+ const [created] = await db
+ .insert(blogAuthors)
+ .values({
+ slug: input.slug,
+ displayOrder: (maxRow?.max ?? -1) + 1,
+ ...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }),
+ ...(input.imagePublicId !== undefined && { imagePublicId: input.imagePublicId }),
+ ...(input.socialMedia !== undefined && { socialMedia: input.socialMedia }),
+ })
+ .returning({ id: blogAuthors.id });
+
+ const authorId = created.id;
+
+ try {
+ for (const [locale, trans] of Object.entries(input.translations)) {
+ await db.insert(blogAuthorTranslations).values({
+ authorId,
+ locale,
+ name: trans.name,
+ bio: trans.bio ?? null,
+ jobTitle: trans.jobTitle ?? null,
+ company: trans.company ?? null,
+ city: trans.city ?? null,
+ });
+ }
+ } catch (error) {
+ await db.delete(blogAuthors).where(eq(blogAuthors.id, authorId));
+ throw error;
+ }
+
+ return {
+ id: authorId,
+ name: input.translations[ADMIN_LOCALE]?.name ?? input.slug,
+ };
+}
+
+// ── Preview helpers ─────────────────────────────────────────────
+
+export async function getBlogAuthorName(
+ authorId: string,
+ locale: string
+): Promise {
+ const [row] = await db
+ .select({ name: blogAuthorTranslations.name })
+ .from(blogAuthorTranslations)
+ .where(
+ and(
+ eq(blogAuthorTranslations.authorId, authorId),
+ eq(blogAuthorTranslations.locale, locale)
+ )
+ )
+ .limit(1);
+ return row?.name ?? null;
+}
+
+export async function getBlogPostCategoryName(
+ postId: string,
+ locale: string
+): Promise {
+ const [row] = await db
+ .select({ title: blogCategoryTranslations.title })
+ .from(blogPostCategories)
+ .innerJoin(
+ blogCategoryTranslations,
+ and(
+ eq(blogCategoryTranslations.categoryId, blogPostCategories.categoryId),
+ eq(blogCategoryTranslations.locale, locale)
+ )
+ )
+ .innerJoin(
+ blogCategories,
+ eq(blogCategories.id, blogPostCategories.categoryId)
+ )
+ .where(eq(blogPostCategories.postId, postId))
+ .orderBy(blogCategories.displayOrder)
+ .limit(1);
+ return row?.title ?? null;
+}
+
+// ── Authors management ──────────────────────────────────────────
+
+export interface AdminBlogAuthorListItem {
+ id: string;
+ slug: string;
+ name: string;
+ imageUrl: string | null;
+ jobTitle: string | null;
+ postCount: number;
+}
+
+export async function getAdminBlogAuthorsFull(): Promise {
+ const postCountSq = db
+ .select({
+ authorId: blogPosts.authorId,
+ cnt: sql`COUNT(*)`.as('cnt'),
+ })
+ .from(blogPosts)
+ .groupBy(blogPosts.authorId)
+ .as('post_counts');
+
+ const rows = await db
+ .select({
+ id: blogAuthors.id,
+ slug: blogAuthors.slug,
+ name: blogAuthorTranslations.name,
+ imageUrl: blogAuthors.imageUrl,
+ jobTitle: blogAuthorTranslations.jobTitle,
+ postCount: postCountSq.cnt,
+ })
+ .from(blogAuthors)
+ .leftJoin(
+ blogAuthorTranslations,
+ sql`${blogAuthorTranslations.authorId} = ${blogAuthors.id} AND ${blogAuthorTranslations.locale} = ${ADMIN_LOCALE}`
+ )
+ .leftJoin(postCountSq, eq(postCountSq.authorId, blogAuthors.id))
+ .orderBy(blogAuthors.displayOrder);
+
+ return rows.map(r => ({
+ id: r.id,
+ slug: r.slug,
+ name: r.name ?? '(unnamed)',
+ imageUrl: r.imageUrl,
+ jobTitle: r.jobTitle ?? null,
+ postCount: r.postCount ?? 0,
+ }));
+}
+
+export interface AdminBlogAuthorTranslation {
+ name: string;
+ bio: string | null;
+ jobTitle: string | null;
+ company: string | null;
+ city: string | null;
+}
+
+export interface AdminBlogAuthorFull {
+ id: string;
+ slug: string;
+ imageUrl: string | null;
+ imagePublicId: string | null;
+ socialMedia: { platform: string; url: string }[];
+ translations: Record;
+}
+
+export async function getAdminBlogAuthorById(
+ authorId: string
+): Promise {
+ const [author] = await db
+ .select({
+ id: blogAuthors.id,
+ slug: blogAuthors.slug,
+ imageUrl: blogAuthors.imageUrl,
+ imagePublicId: blogAuthors.imagePublicId,
+ socialMedia: blogAuthors.socialMedia,
+ })
+ .from(blogAuthors)
+ .where(eq(blogAuthors.id, authorId))
+ .limit(1);
+
+ if (!author) return null;
+
+ const transRows = await db
+ .select({
+ locale: blogAuthorTranslations.locale,
+ name: blogAuthorTranslations.name,
+ bio: blogAuthorTranslations.bio,
+ jobTitle: blogAuthorTranslations.jobTitle,
+ company: blogAuthorTranslations.company,
+ city: blogAuthorTranslations.city,
+ })
+ .from(blogAuthorTranslations)
+ .where(eq(blogAuthorTranslations.authorId, authorId));
+
+ const translations: Record = {};
+ for (const t of transRows) {
+ translations[t.locale] = {
+ name: t.name,
+ bio: t.bio,
+ jobTitle: t.jobTitle,
+ company: t.company,
+ city: t.city,
+ };
+ }
+
+ return {
+ ...author,
+ socialMedia: (author.socialMedia as { platform: string; url: string }[]) ?? [],
+ translations,
+ };
+}
+
+export interface UpdateBlogAuthorInput {
+ slug: string;
+ imageUrl: string | null;
+ imagePublicId: string | null;
+ socialMedia: { platform: string; url: string }[];
+ translations: Record;
+}
+
+export async function updateBlogAuthor(
+ authorId: string,
+ input: UpdateBlogAuthorInput
+): Promise {
+ await db
+ .update(blogAuthors)
+ .set({
+ slug: input.slug,
+ imageUrl: input.imageUrl,
+ imagePublicId: input.imagePublicId,
+ socialMedia: input.socialMedia,
+ updatedAt: new Date(),
+ })
+ .where(eq(blogAuthors.id, authorId));
+
+ for (const [locale, trans] of Object.entries(input.translations)) {
+ await db
+ .insert(blogAuthorTranslations)
+ .values({
+ authorId,
+ locale,
+ name: trans.name,
+ bio: trans.bio ?? null,
+ jobTitle: trans.jobTitle ?? null,
+ company: trans.company ?? null,
+ city: trans.city ?? null,
+ })
+ .onConflictDoUpdate({
+ target: [blogAuthorTranslations.authorId, blogAuthorTranslations.locale],
+ set: {
+ name: trans.name,
+ bio: trans.bio ?? null,
+ jobTitle: trans.jobTitle ?? null,
+ company: trans.company ?? null,
+ city: trans.city ?? null,
+ },
+ });
+ }
+}
+
+export async function deleteBlogAuthor(authorId: string): Promise {
+ const [row] = await db
+ .select({ cnt: sql`COUNT(*)` })
+ .from(blogPosts)
+ .where(eq(blogPosts.authorId, authorId));
+
+ if ((row?.cnt ?? 0) > 0) {
+ throw new Error('AUTHOR_HAS_POSTS');
+ }
+
+ await db.delete(blogAuthors).where(eq(blogAuthors.id, authorId));
+}
+
+// ── Categories management ───────────────────────────────────────
+
+export interface AdminBlogCategoryTranslation {
+ title: string;
+ description: string | null;
+}
+
+export interface AdminBlogCategoryListItem {
+ id: string;
+ slug: string;
+ title: string;
+ description: string | null;
+ postCount: number;
+ displayOrder: number;
+ translations: Record;
+}
+
+export async function getAdminBlogCategoriesFull(): Promise {
+ const postCountSq = db
+ .select({
+ categoryId: blogPostCategories.categoryId,
+ cnt: sql`COUNT(*)`.as('cnt'),
+ })
+ .from(blogPostCategories)
+ .groupBy(blogPostCategories.categoryId)
+ .as('cat_post_counts');
+
+ const rows = await db
+ .select({
+ id: blogCategories.id,
+ slug: blogCategories.slug,
+ displayOrder: blogCategories.displayOrder,
+ postCount: postCountSq.cnt,
+ })
+ .from(blogCategories)
+ .leftJoin(postCountSq, eq(postCountSq.categoryId, blogCategories.id))
+ .orderBy(blogCategories.displayOrder);
+
+ const allTrans = await db
+ .select({
+ categoryId: blogCategoryTranslations.categoryId,
+ locale: blogCategoryTranslations.locale,
+ title: blogCategoryTranslations.title,
+ description: blogCategoryTranslations.description,
+ })
+ .from(blogCategoryTranslations);
+
+ const transMap = new Map>();
+ for (const t of allTrans) {
+ if (!transMap.has(t.categoryId)) transMap.set(t.categoryId, {});
+ transMap.get(t.categoryId)![t.locale] = {
+ title: t.title,
+ description: t.description,
+ };
+ }
+
+ return rows.map(r => {
+ const trans = transMap.get(r.id) ?? {};
+ return {
+ id: r.id,
+ slug: r.slug,
+ title: trans[ADMIN_LOCALE]?.title ?? '(untitled)',
+ description: trans[ADMIN_LOCALE]?.description ?? null,
+ postCount: r.postCount ?? 0,
+ displayOrder: r.displayOrder,
+ translations: trans,
+ };
+ });
+}
+
+export interface AdminBlogCategoryFull {
+ id: string;
+ slug: string;
+ displayOrder: number;
+ translations: Record;
+}
+
+export async function getAdminBlogCategoryById(
+ categoryId: string
+): Promise {
+ const [category] = await db
+ .select({
+ id: blogCategories.id,
+ slug: blogCategories.slug,
+ displayOrder: blogCategories.displayOrder,
+ })
+ .from(blogCategories)
+ .where(eq(blogCategories.id, categoryId))
+ .limit(1);
+
+ if (!category) return null;
+
+ const transRows = await db
+ .select({
+ locale: blogCategoryTranslations.locale,
+ title: blogCategoryTranslations.title,
+ description: blogCategoryTranslations.description,
+ })
+ .from(blogCategoryTranslations)
+ .where(eq(blogCategoryTranslations.categoryId, categoryId));
+
+ const translations: Record = {};
+ for (const t of transRows) {
+ translations[t.locale] = { title: t.title, description: t.description };
+ }
+
+ return { ...category, translations };
+}
+
+export interface UpdateBlogCategoryInput {
+ slug: string;
+ translations: Record;
+}
+
+export async function updateBlogCategory(
+ categoryId: string,
+ input: UpdateBlogCategoryInput
+): Promise {
+ await db
+ .update(blogCategories)
+ .set({ slug: input.slug })
+ .where(eq(blogCategories.id, categoryId));
+
+ for (const [locale, trans] of Object.entries(input.translations)) {
+ await db
+ .insert(blogCategoryTranslations)
+ .values({
+ categoryId,
+ locale,
+ title: trans.title,
+ description: trans.description ?? null,
+ })
+ .onConflictDoUpdate({
+ target: [blogCategoryTranslations.categoryId, blogCategoryTranslations.locale],
+ set: {
+ title: trans.title,
+ description: trans.description ?? null,
+ },
+ });
+ }
+}
+
+export async function deleteBlogCategory(categoryId: string): Promise {
+ const [row] = await db
+ .select({ cnt: sql`COUNT(*)` })
+ .from(blogPostCategories)
+ .where(eq(blogPostCategories.categoryId, categoryId));
+
+ if ((row?.cnt ?? 0) > 0) {
+ throw new Error('CATEGORY_HAS_POSTS');
+ }
+
+ await db.delete(blogCategories).where(eq(blogCategories.id, categoryId));
+}
+
+export async function swapBlogCategoryOrder(
+ id1: string,
+ id2: string
+): Promise {
+ const rows = await db
+ .select({ id: blogCategories.id, displayOrder: blogCategories.displayOrder })
+ .from(blogCategories)
+ .where(sql`${blogCategories.id} IN (${id1}, ${id2})`);
+
+ if (rows.length !== 2) throw new Error('CATEGORIES_NOT_FOUND');
+
+ const order1 = rows.find(r => r.id === id1)!.displayOrder;
+ const order2 = rows.find(r => r.id === id2)!.displayOrder;
+
+ await db
+ .update(blogCategories)
+ .set({ displayOrder: order2 })
+ .where(eq(blogCategories.id, id1));
+
+ await db
+ .update(blogCategories)
+ .set({ displayOrder: order1 })
+ .where(eq(blogCategories.id, id2));
+}
diff --git a/frontend/db/queries/blog/blog-authors.ts b/frontend/db/queries/blog/blog-authors.ts
index c5bf1588..53a0420f 100644
--- a/frontend/db/queries/blog/blog-authors.ts
+++ b/frontend/db/queries/blog/blog-authors.ts
@@ -1,4 +1,7 @@
import { eq, sql } from 'drizzle-orm';
+import { unstable_cache } from 'next/cache';
+
+import { STATIC_PAGE_REVALIDATE } from '@/lib/constants/cache';
import { db } from '../../index';
import { blogAuthors, blogAuthorTranslations } from '../../schema/blog';
@@ -57,3 +60,12 @@ export async function getBlogAuthorByName(
socialMedia: (row.socialMedia as unknown[]) ?? [],
};
}
+
+export const getCachedBlogAuthorByName = unstable_cache(
+ async (name: string, locale: string) => getBlogAuthorByName(name, locale),
+ ['blog-author-by-name'],
+ {
+ revalidate: STATIC_PAGE_REVALIDATE,
+ tags: ['blog-authors'],
+ }
+);
\ No newline at end of file
diff --git a/frontend/db/queries/blog/blog-categories.ts b/frontend/db/queries/blog/blog-categories.ts
index 656ecf34..69c55af3 100644
--- a/frontend/db/queries/blog/blog-categories.ts
+++ b/frontend/db/queries/blog/blog-categories.ts
@@ -1,6 +1,8 @@
import { sql } from 'drizzle-orm';
import { unstable_cache } from 'next/cache';
+import { STATIC_PAGE_REVALIDATE } from '@/lib/constants/cache';
+
import { db } from '../../index';
import { blogCategories, blogCategoryTranslations } from '../../schema/blog';
@@ -37,7 +39,7 @@ export const getCachedBlogCategories = unstable_cache(
async (locale: string): Promise => getBlogCategories(locale),
['blog-categories'],
{
- revalidate: 60 * 60 * 24 * 7,
+ revalidate: STATIC_PAGE_REVALIDATE,
tags: ['blog-categories'],
}
);
diff --git a/frontend/db/queries/blog/blog-posts.ts b/frontend/db/queries/blog/blog-posts.ts
index 17b087ef..c6b3663d 100644
--- a/frontend/db/queries/blog/blog-posts.ts
+++ b/frontend/db/queries/blog/blog-posts.ts
@@ -1,6 +1,9 @@
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
+import { unstable_cache } from 'next/cache';
+import { cache } from 'react';
import type { SocialLink } from '@/components/blog/BlogFilters';
+import { STATIC_PAGE_REVALIDATE } from '@/lib/constants/cache';
import { db } from '../../index';
import {
@@ -182,7 +185,16 @@ export async function getBlogPosts(locale: string): Promise {
);
}
-export async function getBlogPostBySlug(
+export const getCachedBlogPosts = unstable_cache(
+ async (locale: string) => getBlogPosts(locale),
+ ['blog-posts'],
+ {
+ revalidate: STATIC_PAGE_REVALIDATE,
+ tags: ['blog-posts'],
+ }
+);
+
+export const getBlogPostBySlug = cache(async function getBlogPostBySlug(
slug: string,
locale: string
): Promise {
@@ -212,7 +224,16 @@ export async function getBlogPostBySlug(
? { ...base.author, bio: (row as RawPostRow).authorBio }
: undefined,
};
-}
+});
+
+export const getCachedBlogPostBySlug = unstable_cache(
+ async (slug: string, locale: string) => getBlogPostBySlug(slug, locale),
+ ['blog-post-by-slug'],
+ {
+ revalidate: STATIC_PAGE_REVALIDATE,
+ tags: ['blog-posts'],
+ }
+);
export async function getBlogPostsByCategory(
categorySlug: string,
@@ -262,6 +283,16 @@ export async function getBlogPostsByCategory(
);
}
+export const getCachedBlogPostsByCategory = unstable_cache(
+ async (categorySlug: string, locale: string) =>
+ getBlogPostsByCategory(categorySlug, locale),
+ ['blog-posts-by-category'],
+ {
+ revalidate: STATIC_PAGE_REVALIDATE,
+ tags: ['blog-posts', 'blog-categories'],
+ }
+);
+
export async function getBlogPostSlugs(): Promise<{ slug: string }[]> {
return db
.select({ slug: blogPosts.slug })
diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json
index 2ca5ecad..23d55722 100644
--- a/frontend/drizzle/meta/_journal.json
+++ b/frontend/drizzle/meta/_journal.json
@@ -234,4 +234,4 @@
"breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/frontend/lib/constants/cache.ts b/frontend/lib/constants/cache.ts
new file mode 100644
index 00000000..3eb7827a
--- /dev/null
+++ b/frontend/lib/constants/cache.ts
@@ -0,0 +1,4 @@
+/** ISR revalidation for static content pages (blog, categories).
+ * Admin mutations call revalidatePath() for instant updates —
+ * this TTL is only a safety-net fallback. */
+export const STATIC_PAGE_REVALIDATE = 604800; // 7 days
diff --git a/frontend/lib/tests/blog/blog-author-route.test.ts b/frontend/lib/tests/blog/blog-author-route.test.ts
index c3e1262f..b72a7f4c 100644
--- a/frontend/lib/tests/blog/blog-author-route.test.ts
+++ b/frontend/lib/tests/blog/blog-author-route.test.ts
@@ -10,7 +10,7 @@ vi.mock('@/client', () => ({
},
}));
-import { GET } from '@/app/api/blog-author/route';
+import { GET } from '@/app/api/blog/author/route';
afterEach(() => {
fetchMock.mockReset();
@@ -19,7 +19,7 @@ afterEach(() => {
describe('GET /api/blog-author', () => {
it('returns 400 when name is missing', async () => {
const response = await GET(
- new Request('http://localhost/api/blog-author?locale=uk')
+ new Request('http://localhost/api/blog/author?locale=uk')
);
expect(response.status).toBe(400);
@@ -30,7 +30,7 @@ describe('GET /api/blog-author', () => {
const response = await GET(
new Request(
- 'http://localhost/api/blog-author?name=%D0%90%D0%BD%D0%BD%D0%B0&locale=uk'
+ 'http://localhost/api/blog/author?name=%D0%90%D0%BD%D0%BD%D0%B0&locale=uk'
)
);
const data = await response.json();
diff --git a/frontend/lib/tests/blog/blog-search-route.test.ts b/frontend/lib/tests/blog/blog-search-route.test.ts
index f53efb91..cb64ee61 100644
--- a/frontend/lib/tests/blog/blog-search-route.test.ts
+++ b/frontend/lib/tests/blog/blog-search-route.test.ts
@@ -10,7 +10,7 @@ vi.mock('@/client', () => ({
},
}));
-import { GET } from '@/app/api/blog-search/route';
+import { GET } from '@/app/api/blog/search/route';
afterEach(() => {
fetchMock.mockReset();
@@ -23,7 +23,7 @@ describe('GET /api/blog-search', () => {
]);
const response = await GET(
- new Request('http://localhost/api/blog-search?locale=uk')
+ new Request('http://localhost/api/blog/search?locale=uk')
);
const data = await response.json();
diff --git a/frontend/lib/validation/admin-blog.ts b/frontend/lib/validation/admin-blog.ts
new file mode 100644
index 00000000..e618132c
--- /dev/null
+++ b/frontend/lib/validation/admin-blog.ts
@@ -0,0 +1,110 @@
+import { z } from 'zod';
+
+const blogTranslationSchema = z.object({
+ title: z.string().min(1, 'Title is required'),
+ body: z.unknown().default(null),
+});
+
+export const createBlogPostSchema = z
+ .object({
+ slug: z.string().min(1, 'Slug is required'),
+ authorId: z.string().uuid().nullable(),
+ mainImageUrl: z.string().nullable(),
+ mainImagePublicId: z.string().nullable(),
+ tags: z.array(z.string()),
+ resourceLink: z.string().nullable(),
+ translations: z.object({
+ en: blogTranslationSchema,
+ uk: blogTranslationSchema,
+ pl: blogTranslationSchema,
+ }),
+ categoryIds: z.array(z.string().uuid()),
+ publishMode: z.enum(['draft', 'publish', 'schedule']),
+ scheduledPublishAt: z.string().nullable(),
+ })
+ .refine(
+ data =>
+ data.publishMode !== 'schedule' || (data.scheduledPublishAt && data.scheduledPublishAt.length > 0),
+ { message: 'Scheduled date is required', path: ['scheduledPublishAt'] }
+ );
+
+export type CreateBlogPostPayload = z.infer;
+
+// ── Inline category creation ─────────────────────────────────────
+
+const blogCategoryTranslationSchema = z.object({
+ title: z.string().min(1, 'Title is required'),
+ description: z.string().optional(),
+});
+
+export const createBlogCategorySchema = z.object({
+ slug: z.string().min(1, 'Slug is required'),
+ translations: z.object({
+ en: blogCategoryTranslationSchema,
+ uk: blogCategoryTranslationSchema,
+ pl: blogCategoryTranslationSchema,
+ }),
+});
+
+// ── Inline author creation ───────────────────────────────────────
+
+const blogAuthorTranslationSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ bio: z.string().optional(),
+ jobTitle: z.string().optional(),
+ company: z.string().optional(),
+ city: z.string().optional(),
+});
+
+const socialMediaEntrySchema = z.object({
+ platform: z.string().min(1),
+ url: z.string().url(),
+});
+
+export const createBlogAuthorSchema = z.object({
+ slug: z.string().min(1, 'Slug is required'),
+ imageUrl: z.string().nullable().optional(),
+ imagePublicId: z.string().nullable().optional(),
+ socialMedia: z.array(socialMediaEntrySchema).optional(),
+ translations: z.object({
+ en: blogAuthorTranslationSchema,
+ uk: blogAuthorTranslationSchema,
+ pl: blogAuthorTranslationSchema,
+ }),
+});
+
+// ── Update author (full form) ────────────────────────────────────
+
+export const updateBlogAuthorSchema = z.object({
+ slug: z.string().min(1, 'Slug is required'),
+ imageUrl: z.string().nullable(),
+ imagePublicId: z.string().nullable(),
+ socialMedia: z.array(socialMediaEntrySchema),
+ translations: z.object({
+ en: blogAuthorTranslationSchema,
+ uk: blogAuthorTranslationSchema,
+ pl: blogAuthorTranslationSchema,
+ }),
+});
+
+export type UpdateBlogAuthorPayload = z.infer;
+
+// ── Update category (full form) ──────────────────────────────────
+
+export const updateBlogCategorySchema = z.object({
+ slug: z.string().min(1, 'Slug is required'),
+ translations: z.object({
+ en: blogCategoryTranslationSchema,
+ uk: blogCategoryTranslationSchema,
+ pl: blogCategoryTranslationSchema,
+ }),
+});
+
+export type UpdateBlogCategoryPayload = z.infer;
+
+// ── Category reorder ─────────────────────────────────────────────
+
+export const swapCategoryOrderSchema = z.object({
+ id1: z.string().uuid(),
+ id2: z.string().uuid(),
+});
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5e6523e4..d72b46fb 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -20,8 +20,12 @@
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.0",
"@swc/helpers": "^0.5.18",
- "@tiptap/core": "^3.19.0",
+ "@tiptap/core": "^3.20.0",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
+ "@tiptap/extension-image": "^3.20.2",
+ "@tiptap/extension-link": "^3.20.2",
+ "@tiptap/extension-task-item": "^3.20.4",
+ "@tiptap/extension-task-list": "^3.20.4",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@upstash/redis": "^1.36.1",
@@ -5466,16 +5470,16 @@
}
},
"node_modules/@tiptap/core": {
- "version": "3.20.0",
- "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
- "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
+ "version": "3.20.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz",
+ "integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/pm": "^3.20.0"
+ "@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-blockquote": {
@@ -5674,6 +5678,19 @@
"@tiptap/pm": "^3.20.0"
}
},
+ "node_modules/@tiptap/extension-image": {
+ "version": "3.20.2",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.2.tgz",
+ "integrity": "sha512-STo7T3NQ1TcF93NXRQDhb5YkepBRpYHY54yfBUmHl5cygYZzOMaGlM0nh8NeX54mh3wJ6+nxpApuM3Jbmg0I+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.2"
+ }
+ },
"node_modules/@tiptap/extension-italic": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz",
@@ -5688,9 +5705,9 @@
}
},
"node_modules/@tiptap/extension-link": {
- "version": "3.20.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz",
- "integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==",
+ "version": "3.20.2",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.2.tgz",
+ "integrity": "sha512-vnC72CFMUiCJuAt7Hi4T/hKvbY4DqBjqo9G6dkBfNJHXHmqGiGKvkgzm1m7P/R1EX1XYk8nifeCpW6q2uliFRQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
@@ -5700,22 +5717,22 @@
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^3.20.0",
- "@tiptap/pm": "^3.20.0"
+ "@tiptap/core": "^3.20.2",
+ "@tiptap/pm": "^3.20.2"
}
},
"node_modules/@tiptap/extension-list": {
- "version": "3.20.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz",
- "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==",
+ "version": "3.20.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.4.tgz",
+ "integrity": "sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^3.20.0",
- "@tiptap/pm": "^3.20.0"
+ "@tiptap/core": "^3.20.4",
+ "@tiptap/pm": "^3.20.4"
}
},
"node_modules/@tiptap/extension-list-item": {
@@ -5783,6 +5800,32 @@
"@tiptap/core": "^3.20.0"
}
},
+ "node_modules/@tiptap/extension-task-item": {
+ "version": "3.20.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.20.4.tgz",
+ "integrity": "sha512-mEWyAtZ61USZnKyLDxi2DtnSREfW0yUFXDOFWstNg1i6hva197BuAy6VRQMQxTOq+cFAgAt1MEZKanW0Obsa+g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.20.4"
+ }
+ },
+ "node_modules/@tiptap/extension-task-list": {
+ "version": "3.20.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.20.4.tgz",
+ "integrity": "sha512-QvLrpffkxkr7TTgMmk6fnPAE34HYrUosHiuZJpRK008MuJDOoANblS221M4lLuRE73w3KI7hd/fi2CliBcCC4A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.20.4"
+ }
+ },
"node_modules/@tiptap/extension-text": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz",
@@ -5824,9 +5867,9 @@
}
},
"node_modules/@tiptap/pm": {
- "version": "3.20.0",
- "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
- "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
+ "version": "3.20.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz",
+ "integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
diff --git a/frontend/package.json b/frontend/package.json
index 67c0bd20..bfb83bee 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -35,8 +35,12 @@
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.0",
"@swc/helpers": "^0.5.18",
- "@tiptap/core": "^3.19.0",
+ "@tiptap/core": "^3.20.0",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
+ "@tiptap/extension-image": "^3.20.2",
+ "@tiptap/extension-link": "^3.20.2",
+ "@tiptap/extension-task-item": "^3.20.4",
+ "@tiptap/extension-task-list": "^3.20.4",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@upstash/redis": "^1.36.1",