Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions frontend/app/[locale]/admin/blog/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-6">
<Link
href="/admin/blog"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
&larr; Back to posts
</Link>
</div>

<h1 className="text-foreground mb-6 text-2xl font-bold">
Edit: {title}
</h1>

<BlogPostForm
postId={id}
initialData={post}
authors={authors}
categories={categories}
csrfTokenPost={csrfTokenPost}
csrfTokenCategory={csrfTokenCategory}
csrfTokenAuthor={csrfTokenAuthor}
csrfTokenImage={csrfTokenImage}
/>
</div>
);
}
141 changes: 141 additions & 0 deletions frontend/app/[locale]/admin/blog/[id]/preview/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | string[] | undefined>>;
}) {
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 (
<div className="min-h-screen bg-gray-50 dark:bg-transparent">
{/* Preview banner */}
<div className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-center text-sm font-medium text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400">
Preview Mode — This is how the post will appear on the public site
</div>

{/* Admin controls bar */}
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<Link
href={`/admin/blog/${id}`}
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
&larr; Back to edit
</Link>

{/* Locale tabs */}
<div className="flex gap-1 rounded-md border border-border p-0.5">
{LOCALES.map(l => (
<a
key={l}
href={`?lang=${l}`}
className={cn(
'rounded px-3 py-1 text-xs font-medium transition-colors',
l === lang
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground'
)}
>
{l.toUpperCase()}
</a>
))}
</div>
</div>

{/* Post content — matches PostDetails.tsx styling */}
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-3xl">
{categoryName && (
<div className="text-center text-sm font-medium text-[var(--accent-primary)]">
{categoryName}
</div>
)}

<h1 className="mt-3 text-center text-4xl font-bold text-gray-900 dark:text-gray-100">
{title}
</h1>

{(authorName || post.publishedAt) && (
<div className="mt-4 flex justify-center gap-2 text-sm text-gray-500 dark:text-gray-400">
{authorName && <span>{authorName}</span>}
{authorName && post.publishedAt && <span>&middot;</span>}
{post.publishedAt && (
<time dateTime={new Date(post.publishedAt).toISOString()}>
{formatBlogDate(new Date(post.publishedAt).toISOString())}
</time>
)}
</div>
)}
</div>

{post.mainImageUrl && (
<div className="relative my-8 h-[520px] w-full overflow-hidden rounded-2xl">
<Image
src={post.mainImageUrl}
alt={title}
fill
unoptimized={shouldBypassImageOptimization(post.mainImageUrl)}
className="object-contain"
/>
</div>
)}

<div className="mx-auto w-full max-w-3xl">
<article className="prose prose-gray max-w-none">
{body ? (
<BlogPostRenderer content={body as any} />
) : (
<p className="text-muted-foreground italic">
No body content for {lang.toUpperCase()} locale
</p>
)}
</article>
</div>
</main>
</div>
);
}
45 changes: 45 additions & 0 deletions frontend/app/[locale]/admin/blog/authors/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-6">
<Link
href="/admin/blog/authors"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
&larr; Back to authors
</Link>
</div>

<h1 className="text-foreground mb-6 text-2xl font-bold">Edit Author</h1>

<BlogAuthorForm
initialData={author}
csrfTokenAuthor={csrfTokenAuthor}
csrfTokenImage={csrfTokenImage}
/>
</div>
);
}
34 changes: 34 additions & 0 deletions frontend/app/[locale]/admin/blog/authors/new/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-6">
<Link
href="/admin/blog/authors"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
&larr; Back to authors
</Link>
</div>

<h1 className="text-foreground mb-6 text-2xl font-bold">New Author</h1>

<BlogAuthorForm
csrfTokenAuthor={csrfTokenAuthor}
csrfTokenImage={csrfTokenImage}
/>
</div>
);
}
39 changes: 39 additions & 0 deletions frontend/app/[locale]/admin/blog/authors/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-5xl px-6 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-foreground text-2xl font-bold">Authors</h1>
<p className="text-muted-foreground mt-1 text-sm">
Manage blog authors and their profiles
</p>
</div>
<Link
href="/admin/blog/authors/new"
className="bg-foreground text-background hover:bg-foreground/90 inline-flex items-center rounded-md px-4 py-2 text-sm font-medium transition-colors"
>
+ New Author
</Link>
</div>

<div className="mt-6">
<BlogAuthorListTable authors={authors} csrfTokenDelete={csrfTokenDelete} />
</div>
</div>
);
}

30 changes: 30 additions & 0 deletions frontend/app/[locale]/admin/blog/categories/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-5xl px-6 py-8">
<BlogCategoryManager
categories={categories}
csrfTokenCreate={csrfTokenCreate}
csrfTokenUpdate={csrfTokenUpdate}
csrfTokenDelete={csrfTokenDelete}
csrfTokenReorder={csrfTokenReorder}
/>
</div>
);
}

Loading
Loading