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 ( +
+
+ + ← Back to posts + +
+ +

+ Edit: {title} +

+ + +
+ ); +} diff --git a/frontend/app/[locale]/admin/blog/[id]/preview/page.tsx b/frontend/app/[locale]/admin/blog/[id]/preview/page.tsx new file mode 100644 index 00000000..afc94510 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/[id]/preview/page.tsx @@ -0,0 +1,141 @@ +import { Metadata } from 'next'; +import Image from 'next/image'; +import { notFound } from 'next/navigation'; + +import BlogPostRenderer from '@/components/blog/BlogPostRenderer'; +import { + getAdminBlogPostById, + getBlogAuthorName, + getBlogPostCategoryName, +} from '@/db/queries/blog/admin-blog'; +import { + blogAuthorTranslations, + blogCategoryTranslations, + blogPostCategories, +} from '@/db/schema'; +import { Link } from '@/i18n/routing'; +import { formatBlogDate } from '@/lib/blog/date'; +import { shouldBypassImageOptimization } from '@/lib/blog/image'; +import { cn } from '@/lib/utils'; + +export const metadata: Metadata = { + title: 'Preview Post | DevLovers', + robots: 'noindex, nofollow', +}; + +const LOCALES = ['en', 'uk', 'pl'] as const; + +export default async function AdminBlogPreviewPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise>; +}) { + const { id } = await params; + const sp = await searchParams; + const lang = LOCALES.includes(sp.lang as any) + ? (sp.lang as string) + : 'en'; + + const post = await getAdminBlogPostById(id); + if (!post) notFound(); + + const translation = post.translations[lang]; + const title = translation?.title ?? post.translations.en?.title ?? post.slug; + const body = translation?.body ?? post.translations.en?.body ?? null; + + const authorName = post.authorId + ? await getBlogAuthorName(post.authorId, lang) + : null; + + const categoryName = await getBlogPostCategoryName(id, lang); + + return ( +
+ {/* Preview banner */} +
+ Preview Mode — This is how the post will appear on the public site +
+ + {/* Admin controls bar */} +
+ + ← Back to edit + + + {/* Locale tabs */} +
+ {LOCALES.map(l => ( + + {l.toUpperCase()} + + ))} +
+
+ + {/* Post content — matches PostDetails.tsx styling */} +
+
+ {categoryName && ( +
+ {categoryName} +
+ )} + +

+ {title} +

+ + {(authorName || post.publishedAt) && ( +
+ {authorName && {authorName}} + {authorName && post.publishedAt && ·} + {post.publishedAt && ( + + )} +
+ )} +
+ + {post.mainImageUrl && ( +
+ {title} +
+ )} + +
+
+ {body ? ( + + ) : ( +

+ No body content for {lang.toUpperCase()} locale +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/app/[locale]/admin/blog/authors/[id]/page.tsx b/frontend/app/[locale]/admin/blog/authors/[id]/page.tsx new file mode 100644 index 00000000..602793ce --- /dev/null +++ b/frontend/app/[locale]/admin/blog/authors/[id]/page.tsx @@ -0,0 +1,45 @@ +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { BlogAuthorForm } from '@/components/admin/blog/BlogAuthorForm'; +import { getAdminBlogAuthorById } from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Edit Author | DevLovers', +}; + +export default async function AdminBlogAuthorEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const author = await getAdminBlogAuthorById(id); + if (!author) notFound(); + + const csrfTokenAuthor = issueCsrfToken('admin:blog-author:update'); + const csrfTokenImage = issueCsrfToken('admin:blog:image'); + + return ( +
+
+ + ← Back to authors + +
+ +

Edit Author

+ + +
+ ); +} diff --git a/frontend/app/[locale]/admin/blog/authors/new/page.tsx b/frontend/app/[locale]/admin/blog/authors/new/page.tsx new file mode 100644 index 00000000..e132dff2 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/authors/new/page.tsx @@ -0,0 +1,34 @@ +import { Metadata } from 'next'; + +import { BlogAuthorForm } from '@/components/admin/blog/BlogAuthorForm'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'New Author | DevLovers', +}; + +export default async function AdminBlogAuthorNewPage() { + const csrfTokenAuthor = issueCsrfToken('admin:blog-author:create'); + const csrfTokenImage = issueCsrfToken('admin:blog:image'); + + return ( +
+
+ + ← Back to authors + +
+ +

New Author

+ + +
+ ); +} diff --git a/frontend/app/[locale]/admin/blog/authors/page.tsx b/frontend/app/[locale]/admin/blog/authors/page.tsx new file mode 100644 index 00000000..442de87b --- /dev/null +++ b/frontend/app/[locale]/admin/blog/authors/page.tsx @@ -0,0 +1,39 @@ +import { Metadata } from 'next'; + +import { BlogAuthorListTable } from '@/components/admin/blog/BlogAuthorListTable'; +import { getAdminBlogAuthorsFull } from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Authors | Admin | DevLovers', +}; + +export default async function AdminBlogAuthorsPage() { + const authors = await getAdminBlogAuthorsFull(); + const csrfTokenDelete = issueCsrfToken('admin:blog-author:delete'); + + return ( +
+
+
+

Authors

+

+ Manage blog authors and their profiles +

+
+ + + New Author + +
+ +
+ +
+
+ ); +} + diff --git a/frontend/app/[locale]/admin/blog/categories/page.tsx b/frontend/app/[locale]/admin/blog/categories/page.tsx new file mode 100644 index 00000000..f2adff3a --- /dev/null +++ b/frontend/app/[locale]/admin/blog/categories/page.tsx @@ -0,0 +1,30 @@ +import { Metadata } from 'next'; + +import { BlogCategoryManager } from '@/components/admin/blog/BlogCategoryManager'; +import { getAdminBlogCategoriesFull } from '@/db/queries/blog/admin-blog'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Categories | Admin | DevLovers', +}; + +export default async function AdminBlogCategoriesPage() { + const categories = await getAdminBlogCategoriesFull(); + const csrfTokenCreate = issueCsrfToken('admin:blog-category:create'); + const csrfTokenUpdate = issueCsrfToken('admin:blog-category:update'); + const csrfTokenDelete = issueCsrfToken('admin:blog-category:delete'); + const csrfTokenReorder = issueCsrfToken('admin:blog-category:reorder'); + + return ( +
+ +
+ ); +} + diff --git a/frontend/app/[locale]/admin/blog/new/page.tsx b/frontend/app/[locale]/admin/blog/new/page.tsx new file mode 100644 index 00000000..705652c4 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/new/page.tsx @@ -0,0 +1,49 @@ +import { Metadata } from 'next'; + +import { BlogPostForm } from '@/components/admin/blog/BlogPostForm'; +import { + getAdminBlogAuthors, + getAdminBlogCategories, +} from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'New Post | DevLovers', +}; + +export default async function AdminBlogNewPage() { + const [authors, categories] = await Promise.all([ + getAdminBlogAuthors(), + getAdminBlogCategories(), + ]); + + const csrfTokenPost = issueCsrfToken('admin:blog:create'); + const csrfTokenCategory = issueCsrfToken('admin:blog-category:create'); + const csrfTokenAuthor = issueCsrfToken('admin:blog-author:create'); + const csrfTokenImage = issueCsrfToken('admin:blog:image'); + + return ( +
+
+ + ← Back to posts + +
+ +

New Post

+ + +
+ ); +} diff --git a/frontend/app/[locale]/admin/blog/page.tsx b/frontend/app/[locale]/admin/blog/page.tsx new file mode 100644 index 00000000..e8f751d0 --- /dev/null +++ b/frontend/app/[locale]/admin/blog/page.tsx @@ -0,0 +1,39 @@ +import { Metadata } from 'next'; + +import { BlogPostListTable } from '@/components/admin/blog/BlogPostListTable'; +import { getAdminBlogList } from '@/db/queries/blog/admin-blog'; +import { Link } from '@/i18n/routing'; +import { issueCsrfToken } from '@/lib/security/csrf'; + +export const metadata: Metadata = { + title: 'Blog Posts | Admin | DevLovers', +}; + +export default async function AdminBlogPage() { + const posts = await getAdminBlogList(); + const csrfTokenDelete = issueCsrfToken('admin:blog:delete'); + const csrfTokenPublish = issueCsrfToken('admin:blog:toggle-publish'); + + return ( +
+
+
+

Blog Posts

+

+ Manage blog posts, drafts, and publishing +

+
+ + + New Post + +
+ +
+ +
+
+ ); +} diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index ebf76440..b0bb53b4 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -4,7 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import BlogPostRenderer from '@/components/blog/BlogPostRenderer'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; -import { getBlogPostBySlug, getBlogPosts } from '@/db/queries/blog/blog-posts'; +import { getCachedBlogPosts } from '@/db/queries/blog/blog-posts'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; @@ -52,11 +52,8 @@ export default async function PostDetails({ const slugParam = String(slug || '').trim(); if (!slugParam) return notFound(); - const [post, allPosts] = await Promise.all([ - getBlogPostBySlug(slugParam, locale), - getBlogPosts(locale), - ]); - + const allPosts = await getCachedBlogPosts(locale); + const post = allPosts.find(p => p.slug === slugParam); if (!post) return notFound(); const recommendedPosts = seededShuffle( diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index 265c6870..cfe89f9b 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -1,10 +1,10 @@ import { setRequestLocale } from 'next-intl/server'; -import { getBlogPostBySlug } from '@/db/queries/blog/blog-posts'; +import { getCachedBlogPostBySlug } from '@/db/queries/blog/blog-posts'; import PostDetails from './PostDetails'; -export const dynamic = 'force-dynamic'; +export const revalidate = 604800; // 7 days export async function generateMetadata({ params, @@ -12,7 +12,7 @@ export async function generateMetadata({ params: Promise<{ slug: string; locale: string }>; }) { const { slug, locale } = await params; - const post = await getBlogPostBySlug(slug, locale); + const post = await getCachedBlogPostBySlug(slug, locale); return { title: post?.title ?? 'Post' }; } diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index a0c257e5..f855b49b 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -5,12 +5,11 @@ import { getTranslations } from 'next-intl/server'; import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid'; import { FeaturedPostCtaButton } from '@/components/blog/FeaturedPostCtaButton'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; -import { getBlogCategories } from '@/db/queries/blog/blog-categories'; -import { getBlogPostsByCategory } from '@/db/queries/blog/blog-posts'; +import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getCachedBlogPostsByCategory } from '@/db/queries/blog/blog-posts'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; - -export const revalidate = 3600; +export const revalidate = 604800; // 7 days function getCategoryLabel(categoryName: string, t: (key: string) => string) { const key = categoryName.toLowerCase(); @@ -32,8 +31,8 @@ export default async function BlogCategoryPage({ const tNav = await getTranslations({ locale, namespace: 'navigation' }); const [categories, posts] = await Promise.all([ - getBlogCategories(locale), - getBlogPostsByCategory(category, locale), + getCachedBlogCategories(locale), + getCachedBlogPostsByCategory(category, locale), ]); const matchedCategory = categories.find(c => c.slug === category); diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index 6562b358..47d58463 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -3,10 +3,9 @@ import { getTranslations } from 'next-intl/server'; import BlogFilters from '@/components/blog/BlogFilters'; import { BlogPageHeader } from '@/components/blog/BlogPageHeader'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; -import { getBlogCategories } from '@/db/queries/blog/blog-categories'; -import { getBlogPosts } from '@/db/queries/blog/blog-posts'; - -export const revalidate = 3600; +import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories'; +import { getCachedBlogPosts } from '@/db/queries/blog/blog-posts'; +export const revalidate = 604800; // 7 days export async function generateMetadata({ params, @@ -31,8 +30,8 @@ export default async function BlogPage({ const t = await getTranslations({ locale, namespace: 'blog' }); const [posts, categories] = await Promise.all([ - getBlogPosts(locale), - getBlogCategories(locale), + getCachedBlogPosts(locale), + getCachedBlogCategories(locale), ]); const featuredPost = posts[0]; diff --git a/frontend/app/api/admin/blog/[id]/route.ts b/frontend/app/api/admin/blog/[id]/route.ts new file mode 100644 index 00000000..99d0968f --- /dev/null +++ b/frontend/app/api/admin/blog/[id]/route.ts @@ -0,0 +1,243 @@ +import { revalidatePath, revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { + deleteBlogPost, + getAdminBlogPostById, + toggleBlogPostPublish, + updateBlogPost, +} from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { createBlogPostSchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog:update'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = createBlogPostSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Validation failed', + code: 'VALIDATION_ERROR', + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const data = parsed.data; + + const existing = await getAdminBlogPostById(id); + if (!existing) { + return noStoreJson( + { error: 'Post not found', code: 'NOT_FOUND' }, + { status: 404 } + ); + } + + await updateBlogPost(id, { + slug: data.slug, + authorId: data.authorId, + mainImageUrl: data.mainImageUrl, + mainImagePublicId: data.mainImagePublicId, + tags: data.tags, + resourceLink: data.resourceLink, + translations: data.translations as Record, + categoryIds: data.categoryIds, + }); + + await toggleBlogPostPublish(id, { + isPublished: data.publishMode === 'publish', + scheduledPublishAt: + data.publishMode === 'schedule' && data.scheduledPublishAt + ? new Date(data.scheduledPublishAt) + : null, + }); + + revalidatePath('/[locale]/blog', 'page'); + revalidatePath('/[locale]/blog/[slug]', 'page'); + revalidateTag('blog-authors', 'default'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_post_update_failed', error, {}); + return noStoreJson( + { error: 'Failed to update post', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog:delete'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + + const post = await getAdminBlogPostById(id); + if (!post) { + return noStoreJson( + { error: 'Post not found', code: 'NOT_FOUND' }, + { status: 404 } + ); + } + + if (post.isPublished) { + return noStoreJson( + { + error: 'Cannot delete a published post. Unpublish first.', + code: 'PUBLISHED_POST', + }, + { status: 400 } + ); + } + + await deleteBlogPost(id); + + revalidatePath('/[locale]/blog', 'page'); + revalidateTag('blog-authors', 'default'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_post_delete_failed', error, {}); + return noStoreJson( + { error: 'Failed to delete post', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog:toggle-publish'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + + const post = await getAdminBlogPostById(id); + if (!post) { + return noStoreJson( + { error: 'Post not found', code: 'NOT_FOUND' }, + { status: 404 } + ); + } + + const newPublished = !post.isPublished; + + await toggleBlogPostPublish(id, { + isPublished: newPublished, + scheduledPublishAt: null, + }); + + revalidatePath('/[locale]/blog', 'page'); + revalidatePath('/[locale]/blog/[slug]', 'page'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ + success: true, + isPublished: newPublished, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_post_toggle_publish_failed', error, {}); + return noStoreJson( + { error: 'Failed to toggle publish', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/authors/[id]/route.ts b/frontend/app/api/admin/blog/authors/[id]/route.ts new file mode 100644 index 00000000..3c04098c --- /dev/null +++ b/frontend/app/api/admin/blog/authors/[id]/route.ts @@ -0,0 +1,140 @@ +import { revalidatePath, revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { deleteBlogAuthor, updateBlogAuthor } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { updateBlogAuthorSchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-author:update'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = updateBlogAuthorSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + await updateBlogAuthor(id, parsed.data); + revalidatePath('/[locale]/admin/blog/authors', 'page'); + revalidatePath('/[locale]/blog', 'page'); + revalidatePath('/[locale]/blog/[slug]', 'page'); + revalidateTag('blog-authors', 'default'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_author_update_failed', error, {}); + return noStoreJson( + { error: 'Failed to update author', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-author:delete'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + await deleteBlogAuthor(id); + revalidatePath('/[locale]/admin/blog/authors', 'page'); + revalidatePath('/[locale]/blog', 'page'); + revalidatePath('/[locale]/blog/[slug]', 'page'); + revalidateTag('blog-authors', 'default'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === 'AUTHOR_HAS_POSTS') { + return noStoreJson( + { error: 'Author has posts assigned', code: 'HAS_POSTS' }, + { status: 409 } + ); + } + + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_author_delete_failed', error, {}); + return noStoreJson( + { error: 'Failed to delete author', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/authors/route.ts b/frontend/app/api/admin/blog/authors/route.ts new file mode 100644 index 00000000..13188120 --- /dev/null +++ b/frontend/app/api/admin/blog/authors/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { createBlogAuthor } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { createBlogAuthorSchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf( + request, + 'admin:blog-author:create' + ); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = createBlogAuthorSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + const author = await createBlogAuthor(parsed.data); + + return noStoreJson({ success: true, author }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_author_create_failed', error, {}); + return noStoreJson( + { error: 'Failed to create author', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/categories/[id]/route.ts b/frontend/app/api/admin/blog/categories/[id]/route.ts new file mode 100644 index 00000000..1e94b7d9 --- /dev/null +++ b/frontend/app/api/admin/blog/categories/[id]/route.ts @@ -0,0 +1,140 @@ +import { revalidatePath, revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { deleteBlogCategory, updateBlogCategory } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { updateBlogCategorySchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-category:update'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = updateBlogCategorySchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + await updateBlogCategory(id, parsed.data); + revalidatePath('/[locale]/admin/blog/categories', 'page'); + revalidatePath('/[locale]/blog', 'page'); + revalidatePath('/[locale]/blog/[slug]', 'page'); + revalidateTag('blog-categories', 'default'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_category_update_failed', error, {}); + return noStoreJson( + { error: 'Failed to update category', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-category:delete'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const { id } = await params; + await deleteBlogCategory(id); + revalidatePath('/[locale]/admin/blog/categories', 'page'); + revalidatePath('/[locale]/blog', 'page'); + revalidatePath('/[locale]/blog/[slug]', 'page'); + revalidateTag('blog-categories', 'default'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === 'CATEGORY_HAS_POSTS') { + return noStoreJson( + { error: 'Category has posts assigned', code: 'HAS_POSTS' }, + { status: 409 } + ); + } + + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_category_delete_failed', error, {}); + return noStoreJson( + { error: 'Failed to delete category', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/categories/reorder/route.ts b/frontend/app/api/admin/blog/categories/reorder/route.ts new file mode 100644 index 00000000..71ab4961 --- /dev/null +++ b/frontend/app/api/admin/blog/categories/reorder/route.ts @@ -0,0 +1,89 @@ +import { revalidatePath, revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { swapBlogCategoryOrder } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { swapCategoryOrderSchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog-category:reorder'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = swapCategoryOrderSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + await swapBlogCategoryOrder(parsed.data.id1, parsed.data.id2); + revalidatePath('/[locale]/admin/blog/categories', 'page'); + revalidateTag('blog-categories', 'default'); + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === 'CATEGORIES_NOT_FOUND') { + return noStoreJson( + { error: 'One or both categories not found', code: 'NOT_FOUND' }, + { status: 404 } + ); + } + + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_category_reorder_failed', error, {}); + return noStoreJson( + { error: 'Failed to reorder categories', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/categories/route.ts b/frontend/app/api/admin/blog/categories/route.ts new file mode 100644 index 00000000..4f2d878f --- /dev/null +++ b/frontend/app/api/admin/blog/categories/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { createBlogCategory } from '@/db/queries/blog/admin-blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { createBlogCategorySchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf( + request, + 'admin:blog-category:create' + ); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = createBlogCategorySchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Invalid payload', + code: 'INVALID_PAYLOAD', + details: parsed.error.format(), + }, + { status: 400 } + ); + } + + const category = await createBlogCategory(parsed.data); + + return noStoreJson({ success: true, category }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_category_create_failed', error, {}); + return noStoreJson( + { error: 'Failed to create category', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/images/route.ts b/frontend/app/api/admin/blog/images/route.ts new file mode 100644 index 00000000..80986f95 --- /dev/null +++ b/frontend/app/api/admin/blog/images/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { uploadImage } from '@/lib/cloudinary'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return noStoreJson( + { error: 'Invalid form data', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const csrfResult = requireAdminCsrf( + request, + 'admin:blog:image', + formData + ); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + const file = formData.get('file'); + if (!(file instanceof File) || file.size === 0) { + return noStoreJson( + { error: 'File is required', code: 'MISSING_FILE' }, + { status: 400 } + ); + } + + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + return noStoreJson( + { error: 'File too large (max 5 MB)', code: 'FILE_TOO_LARGE' }, + { status: 400 } + ); + } + + const result = await uploadImage(file, { folder: 'blog/posts' }); + + return noStoreJson({ url: result.url, publicId: result.publicId }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_image_upload_failed', error, {}); + return noStoreJson( + { error: 'Upload failed', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/admin/blog/route.ts b/frontend/app/api/admin/blog/route.ts new file mode 100644 index 00000000..71638425 --- /dev/null +++ b/frontend/app/api/admin/blog/route.ts @@ -0,0 +1,133 @@ +import { eq } from 'drizzle-orm'; +import { revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +import { db } from '@/db'; +import { + createBlogPost, + toggleBlogPostPublish, +} from '@/db/queries/blog/admin-blog'; +import { blogPosts } from '@/db/schema/blog'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { createBlogPostSchema } from '@/lib/validation/admin-blog'; + +export const runtime = 'nodejs'; + +function noStoreJson(body: unknown, init?: { status?: number }) { + const res = NextResponse.json(body, { status: init?.status ?? 200 }); + res.headers.set('Cache-Control', 'no-store'); + return res; +} + +export async function POST(request: NextRequest): Promise { + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + blocked.headers.set('Cache-Control', 'no-store'); + return blocked; + } + + try { + await requireAdminApi(request); + + const csrfResult = requireAdminCsrf(request, 'admin:blog:create'); + if (csrfResult) { + csrfResult.headers.set('Cache-Control', 'no-store'); + return csrfResult; + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return noStoreJson( + { error: 'Invalid JSON body', code: 'INVALID_BODY' }, + { status: 400 } + ); + } + + const parsed = createBlogPostSchema.safeParse(rawBody); + if (!parsed.success) { + return noStoreJson( + { + error: 'Validation failed', + code: 'VALIDATION_ERROR', + details: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const data = parsed.data; + + // Check slug uniqueness + const [existing] = await db + .select({ id: blogPosts.id }) + .from(blogPosts) + .where(eq(blogPosts.slug, data.slug)) + .limit(1); + + if (existing) { + return noStoreJson( + { error: 'Slug already exists', code: 'DUPLICATE_SLUG' }, + { status: 409 } + ); + } + + let postId: string; + try { + postId = await createBlogPost({ + slug: data.slug, + authorId: data.authorId, + mainImageUrl: data.mainImageUrl, + mainImagePublicId: data.mainImagePublicId, + tags: data.tags, + resourceLink: data.resourceLink, + translations: data.translations as Record, + categoryIds: data.categoryIds, + }); + } catch (err) { + if (err instanceof Error && err.message.includes('duplicate key')) { + return noStoreJson( + { error: 'Slug already exists', code: 'DUPLICATE_SLUG' }, + { status: 409 } + ); + } + throw err; + } + + // Apply publish state if not draft + if (data.publishMode !== 'draft') { + await toggleBlogPostPublish(postId, { + isPublished: data.publishMode === 'publish', + scheduledPublishAt: + data.publishMode === 'schedule' && data.scheduledPublishAt + ? new Date(data.scheduledPublishAt) + : null, + }); + } + revalidateTag('blog-posts', 'default'); + + return noStoreJson({ success: true, postId }); + } catch (error) { + if (error instanceof AdminApiDisabledError) + return noStoreJson({ code: error.code }, { status: 403 }); + if (error instanceof AdminUnauthorizedError) + return noStoreJson({ code: error.code }, { status: 401 }); + if (error instanceof AdminForbiddenError) + return noStoreJson({ code: error.code }, { status: 403 }); + + logError('admin_blog_post_create_failed', error, {}); + return noStoreJson( + { error: 'Failed to create post', code: 'INTERNAL_ERROR' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/blog-author/route.ts b/frontend/app/api/blog/author/route.ts similarity index 68% rename from frontend/app/api/blog-author/route.ts rename to frontend/app/api/blog/author/route.ts index 3452bafd..00c7002c 100644 --- a/frontend/app/api/blog-author/route.ts +++ b/frontend/app/api/blog/author/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { getBlogAuthorByName } from '@/db/queries/blog/blog-authors'; +import { getCachedBlogAuthorByName } from '@/db/queries/blog/blog-authors'; export const revalidate = 0; @@ -13,9 +13,9 @@ export async function GET(request: Request) { return NextResponse.json(null, { status: 400 }); } - const author = await getBlogAuthorByName(name, locale); + const author = await getCachedBlogAuthorByName(name, locale); return NextResponse.json(author || null, { - headers: { 'Cache-Control': 'no-store' }, - }); -} + headers: { 'Cache-Control': 'no-store' }, +}); +} \ No newline at end of file diff --git a/frontend/app/api/blog-search/route.ts b/frontend/app/api/blog/search/route.ts similarity index 74% rename from frontend/app/api/blog-search/route.ts rename to frontend/app/api/blog/search/route.ts index 372a52c3..d1bb9ce7 100644 --- a/frontend/app/api/blog-search/route.ts +++ b/frontend/app/api/blog/search/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server'; -import { getBlogPosts } from '@/db/queries/blog/blog-posts'; +import { getCachedBlogPosts } from '@/db/queries/blog/blog-posts'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const locale = searchParams.get('locale') || 'en'; - const posts = await getBlogPosts(locale); + const posts = await getCachedBlogPosts(locale); const items = posts.map(p => ({ id: p.id, diff --git a/frontend/components/admin/AdminSidebar.tsx b/frontend/components/admin/AdminSidebar.tsx index c29a6d34..a8eb03c4 100644 --- a/frontend/components/admin/AdminSidebar.tsx +++ b/frontend/components/admin/AdminSidebar.tsx @@ -2,15 +2,19 @@ import { BarChart3, + BookOpen, ClipboardList, FileQuestion, + FolderOpen, LayoutDashboard, MessageSquare, Package, PanelLeftClose, PanelLeftOpen, + PenLine, Plus, ShoppingBag, + Users, } from 'lucide-react'; import { useSyncExternalStore } from 'react'; @@ -51,6 +55,17 @@ const NAV_SECTIONS: NavSection[] = [ { label: 'Statistics', href: '/admin/quiz/statistics', icon: BarChart3 }, { label: 'New Quiz', href: '/admin/quiz/new', icon: Plus }, ], + }, + { + label: 'Blog', + icon: BookOpen, + basePath: '/admin/blog', + items: [ + { label: 'Posts', href: '/admin/blog', icon: PenLine }, + { label: 'New Post', href: '/admin/blog/new', icon: Plus }, + { label: 'Authors', href: '/admin/blog/authors', icon: Users }, + { label: 'Categories', href: '/admin/blog/categories', icon: FolderOpen }, + ], }, { label: 'Q&A', diff --git a/frontend/components/admin/blog/BlogAuthorForm.tsx b/frontend/components/admin/blog/BlogAuthorForm.tsx new file mode 100644 index 00000000..cb297d8d --- /dev/null +++ b/frontend/components/admin/blog/BlogAuthorForm.tsx @@ -0,0 +1,416 @@ +'use client'; + +import { useRef, useState } from 'react'; + +import type { AdminBlogAuthorFull } from '@/db/queries/blog/admin-blog'; +import { useRouter } from '@/i18n/routing'; +import { slugify } from '@/lib/shop/slug'; + +import { type AdminLocale, LocaleTabs } from '../quiz/LocaleTabs'; +import { BlogImageUpload } from './BlogImageUpload'; + +const LOCALES: AdminLocale[] = ['en', 'uk', 'pl']; + +const PLATFORMS = [ + { value: 'github', label: 'GitHub' }, + { value: 'linkedin', label: 'LinkedIn' }, + { value: 'x', label: 'X' }, + { value: 'website', label: 'Website' }, + { value: 'youtube', label: 'YouTube' }, + { value: 'instagram', label: 'Instagram' }, + { value: 'facebook', label: 'Facebook' }, + { value: 'behance', label: 'Behance' }, + { value: 'dribbble', label: 'Dribbble' }, +] as const; + +interface AuthorTranslation { + name: string; + bio: string; + jobTitle: string; + company: string; + city: string; +} + +interface SocialEntry { + platform: string; + url: string; +} + +const emptyTranslation = (): AuthorTranslation => ({ + name: '', + bio: '', + jobTitle: '', + company: '', + city: '', +}); + +const emptyTranslations = (): Record => ({ + en: emptyTranslation(), + uk: emptyTranslation(), + pl: emptyTranslation(), +}); + +interface BlogAuthorFormProps { + initialData?: AdminBlogAuthorFull; + csrfTokenAuthor: string; + csrfTokenImage: string; +} + +export function BlogAuthorForm({ + initialData, + csrfTokenAuthor, + csrfTokenImage, +}: BlogAuthorFormProps) { + const router = useRouter(); + const isEditMode = !!initialData; + + const [activeLocale, setActiveLocale] = useState('en'); + const [translations, setTranslations] = useState>(() => { + if (!initialData) return emptyTranslations(); + const t = initialData.translations; + return { + en: { + name: t.en?.name ?? '', + bio: t.en?.bio ?? '', + jobTitle: t.en?.jobTitle ?? '', + company: t.en?.company ?? '', + city: t.en?.city ?? '', + }, + uk: { + name: t.uk?.name ?? '', + bio: t.uk?.bio ?? '', + jobTitle: t.uk?.jobTitle ?? '', + company: t.uk?.company ?? '', + city: t.uk?.city ?? '', + }, + pl: { + name: t.pl?.name ?? '', + bio: t.pl?.bio ?? '', + jobTitle: t.pl?.jobTitle ?? '', + company: t.pl?.company ?? '', + city: t.pl?.city ?? '', + }, + }; + }); + + const [slug, setSlug] = useState(initialData?.slug ?? ''); + const [slugTouched, setSlugTouched] = useState(!!initialData); + + const [image, setImage] = useState<{ url: string; publicId: string } | null>( + initialData?.imageUrl + ? { url: initialData.imageUrl, publicId: initialData.imagePublicId ?? '' } + : null + ); + + const [socialMedia, setSocialMedia] = useState( + initialData?.socialMedia ?? [] + ); + + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const initialSnapshot = useRef(() => { + if (!initialData) return null; + return JSON.stringify({ + slug: initialData.slug, + imageUrl: initialData.imageUrl ?? null, + socialMedia: initialData.socialMedia ?? [], + translations: Object.fromEntries( + LOCALES.map(l => [l, { + name: initialData.translations[l]?.name ?? '', + bio: initialData.translations[l]?.bio ?? '', + jobTitle: initialData.translations[l]?.jobTitle ?? '', + company: initialData.translations[l]?.company ?? '', + city: initialData.translations[l]?.city ?? '', + }]) + ), + }); + }); + + function isDirty(): boolean { + if (!isEditMode) return true; + const snap = initialSnapshot.current(); + if (!snap) return true; + const current = JSON.stringify({ + slug, + imageUrl: image?.url ?? null, + socialMedia, + translations: Object.fromEntries( + LOCALES.map(l => [l, translations[l]]) + ), + }); + return current !== snap; + } + + function handleNameChange(value: string) { + setTranslations(prev => ({ + ...prev, + [activeLocale]: { ...prev[activeLocale], name: value }, + })); + if (activeLocale === 'en' && !slugTouched) { + setSlug(slugify(value)); + } + } + + function handleFieldChange(field: keyof AuthorTranslation, value: string) { + setTranslations(prev => ({ + ...prev, + [activeLocale]: { ...prev[activeLocale], [field]: value }, + })); + } + + function addSocialEntry() { + setSocialMedia(prev => [...prev, { platform: PLATFORMS[0].value, url: '' }]); + } + + function removeSocialEntry(index: number) { + setSocialMedia(prev => prev.filter((_, i) => i !== index)); + } + + function updateSocialEntry(index: number, field: keyof SocialEntry, value: string) { + setSocialMedia(prev => + prev.map((entry, i) => (i === index ? { ...entry, [field]: value } : entry)) + ); + } + + function isFormValid(): boolean { + if (!slug.trim()) return false; + return LOCALES.every(l => translations[l].name.trim().length > 0); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSubmitting(true); + + const body = { + slug: slug.trim(), + imageUrl: image?.url ?? null, + imagePublicId: image?.publicId ?? null, + socialMedia: socialMedia.filter(s => s.url.trim()), + translations: Object.fromEntries( + LOCALES.map(l => [l, { + name: translations[l].name.trim(), + bio: translations[l].bio.trim() || undefined, + jobTitle: translations[l].jobTitle.trim() || undefined, + company: translations[l].company.trim() || undefined, + city: translations[l].city.trim() || undefined, + }]) + ), + }; + + try { + const url = isEditMode + ? `/api/admin/blog/authors/${initialData.id}` + : '/api/admin/blog/authors'; + const method = isEditMode ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfTokenAuthor, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error ?? 'Failed to save author'); + return; + } + + router.push('/admin/blog/authors'); + } catch { + setError('Network error'); + } finally { + setSubmitting(false); + } + } + + const inputClass = + 'border-border bg-background text-foreground w-full rounded-md border px-3 py-2 text-sm'; + const labelClass = 'text-foreground mb-1 block text-sm font-medium'; + + const current = translations[activeLocale]; + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {/* Profile photo */} +
+ + +
+ + {/* Locale tabs + translation fields */} +
+
+ + +
+ +
+
+ + handleNameChange(e.target.value)} + className={inputClass} + placeholder="Author name" + /> +
+
+ + handleFieldChange('jobTitle', e.target.value)} + className={inputClass} + placeholder="e.g. Senior Developer" + /> +
+
+
+ + handleFieldChange('company', e.target.value)} + className={inputClass} + /> +
+
+ + handleFieldChange('city', e.target.value)} + className={inputClass} + /> +
+
+
+ +