diff --git a/src/app/(pages)/admin/article/[externalId]/page.tsx b/src/app/(pages)/admin/article/[externalId]/page.tsx
new file mode 100644
index 0000000..2d32d0e
--- /dev/null
+++ b/src/app/(pages)/admin/article/[externalId]/page.tsx
@@ -0,0 +1,40 @@
+import { getArticleByExternalId } from '@/lib/feature/article/article.external';
+import { UpdateArticleForm } from '@/ui/components/internal/update-article-form';
+import { UUIDv4 } from '@/utils/types/uuid';
+import { ArrowLeftIcon } from 'lucide-react';
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+
+interface UpdateArticlePageProps {
+ params: Promise<{ externalId: string }>;
+}
+
+const UpdateArticlePage = async ({ params }: UpdateArticlePageProps) => {
+ const { externalId } = await params;
+
+ const result = await getArticleByExternalId(externalId as UUIDv4);
+ if (!result.ok) throw result.error;
+
+ const article = result.value;
+ if (!article) notFound();
+
+ return (
+
-
-
Create New Article
-
+
+
Articles
+
+
}
+ >
+
+
);
};
diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx
index def28f9..bb16a9a 100644
--- a/src/app/(pages)/home/page.tsx
+++ b/src/app/(pages)/home/page.tsx
@@ -2,16 +2,6 @@ import { ArticleList } from '@/ui/components/internal/article/article-list';
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
import { Suspense } from 'react';
-
-
-
-
-
-
-
-
-
-
const PAGE_SIZE = 4;
type HomeProps = {
diff --git a/src/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts
index 83d6c57..df536d5 100644
--- a/src/lib/feature/article/article.external.ts
+++ b/src/lib/feature/article/article.external.ts
@@ -10,6 +10,7 @@ import * as service from '@/lib/feature/article/article.service';
import { getSessionData } from '@/lib/session/session-storage';
import { TypedResult, wrap } from '@/utils/types/results';
import { UUIDv4 } from '@/utils/types/uuid';
+import { revalidatePath } from 'next/cache';
export const getArticleByExternalId: (
externalId: UUIDv4
@@ -82,6 +83,7 @@ export const updateArticle: (
const result = await service.updateArticle(articleId, article);
if (!result.ok) throw result.error;
+ revalidatePath('/admin');
return result.value;
}
);
@@ -97,4 +99,5 @@ export const deleteArticle: (articleId: string) => Promise
> =
const result = await service.deleteArticle(articleId);
if (!result.ok) throw result.error;
+ revalidatePath('/admin');
});
diff --git a/src/proxy.ts b/src/proxy.ts
index 426efa0..ebc2257 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -2,11 +2,6 @@ import { getSessionData } from '@/lib/session/session-storage';
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
-
-
-
-
-
const isPublic = createRouteMatcher([
'/home(.*)?',
'/about(.*)?',
diff --git a/src/ui/components/internal/article/admin-article-card.tsx b/src/ui/components/internal/article/admin-article-card.tsx
new file mode 100644
index 0000000..6497b75
--- /dev/null
+++ b/src/ui/components/internal/article/admin-article-card.tsx
@@ -0,0 +1,72 @@
+import { ArticleModel } from '@/lib/feature/article/article.model';
+import { DeleteArticleButton } from '@/ui/components/internal/article/delete-article-button';
+import { Button } from '@/ui/components/shadcn/button';
+import { CalendarIcon, PencilIcon } from 'lucide-react';
+import Link from 'next/link';
+
+interface AdminArticleCardProps {
+ article: ArticleModel;
+}
+
+const formatDate = (date: Date) =>
+ new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+
+export const AdminArticleCard = ({ article }: AdminArticleCardProps) => {
+ return (
+
+
+ {article.coverImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+ {'{ }'}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {article.title}
+
+
+
+ {article.description}
+
+
+
+
+
+ );
+};
diff --git a/src/ui/components/internal/article/admin-article-list-skeleton.tsx b/src/ui/components/internal/article/admin-article-list-skeleton.tsx
new file mode 100644
index 0000000..a3f1562
--- /dev/null
+++ b/src/ui/components/internal/article/admin-article-list-skeleton.tsx
@@ -0,0 +1,67 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/ui/components/shadcn/table';
+
+const AdminArticleRowSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const AdminArticleListSkeleton = ({
+ skeletonSize,
+}: {
+ skeletonSize: number;
+}) => (
+ <>
+
+
+
+
+
+ Cover
+ Title
+ Description
+ Published
+
+ Actions
+
+
+
+
+ {Array.from({ length: skeletonSize }).map((_, i) => (
+
+ ))}
+
+
+
+ >
+);
diff --git a/src/ui/components/internal/article/admin-article-list.tsx b/src/ui/components/internal/article/admin-article-list.tsx
new file mode 100644
index 0000000..f7e6e52
--- /dev/null
+++ b/src/ui/components/internal/article/admin-article-list.tsx
@@ -0,0 +1,172 @@
+import { getArticlesPaginated } from '@/lib/feature/article/article.external';
+import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
+import { DeleteArticleButton } from '@/ui/components/internal/article/delete-article-button';
+import { Button } from '@/ui/components/shadcn/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/ui/components/shadcn/table';
+import { FileTextIcon, PencilIcon } from 'lucide-react';
+import Link from 'next/link';
+
+type AdminArticleListProps = {
+ searchParams: Promise<{ page?: string; pageSize?: string }>;
+ defaultPageSize: number;
+};
+
+const formatDate = (date: Date) =>
+ new Intl.DateTimeFormat('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+
+export const AdminArticleList = async ({
+ searchParams,
+ defaultPageSize,
+}: AdminArticleListProps) => {
+ const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
+ const page = Math.max(1, Number(pageParam) || 1);
+ const pageSize = Number(pageSizeParam) || defaultPageSize;
+
+ const paginationResult = await getArticlesPaginated(page, pageSize);
+
+ if (!paginationResult.ok) {
+ return (
+
+
+
+ Failed to load articles. Please try again later.
+
+
+ );
+ }
+
+ const {
+ data: articles,
+ totalPages,
+ total,
+ page: currentPage,
+ } = paginationResult.value;
+
+ if (articles.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ {total} article{total === 1 ? '' : 's'} published
+
+
+
+
+
+
+ Cover
+ Title
+ Description
+ Published
+
+ Actions
+
+
+
+
+ {articles.map((article) => (
+
+
+
+ {article.coverImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+ {'{}'}
+
+
+ )}
+
+
+
+
+
+ {article.title}
+
+
+ /{article.slug}
+
+
+
+
+
+ {article.description}
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {totalPages > 1 && (
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+ )}
+ >
+ );
+};
diff --git a/src/ui/components/internal/article/article-list.tsx b/src/ui/components/internal/article/article-list.tsx
index 1ede796..248d596 100644
--- a/src/ui/components/internal/article/article-list.tsx
+++ b/src/ui/components/internal/article/article-list.tsx
@@ -4,20 +4,6 @@ import { ArticleCard } from '@/ui/components/internal/article-card';
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
import { FileTextIcon } from 'lucide-react';
-
-
-
-
-
-
-
-
-
-
-
-
-
-
type ArticleListProps = {
searchParams: Promise<{ page?: string; pageSize?: string }>;
defaultPageSize: number;
diff --git a/src/ui/components/internal/article/delete-article-button.tsx b/src/ui/components/internal/article/delete-article-button.tsx
new file mode 100644
index 0000000..1223808
--- /dev/null
+++ b/src/ui/components/internal/article/delete-article-button.tsx
@@ -0,0 +1,84 @@
+'use client';
+
+import { deleteArticle } from '@/lib/feature/article/article.external';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/ui/components/shadcn/alert-dialog';
+import { Button } from '@/ui/components/shadcn/button';
+import { Trash2Icon } from 'lucide-react';
+import { useTransition } from 'react';
+import { toast } from 'sonner';
+
+interface DeleteArticleButtonProps {
+ articleId: string;
+ articleTitle: string;
+}
+
+export const DeleteArticleButton = ({
+ articleId,
+ articleTitle,
+}: DeleteArticleButtonProps) => {
+ const [isPending, startTransition] = useTransition();
+
+ const handleDelete = () => {
+ startTransition(async () => {
+ const result = await deleteArticle(articleId);
+ if (!result.ok) {
+ toast.error('Failed to delete article', {
+ description: result.error.message,
+ position: 'bottom-right',
+ });
+ return;
+ }
+ toast.success('Article deleted', {
+ description: `"${articleTitle}" has been removed.`,
+ position: 'bottom-right',
+ });
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ Delete article?
+
+ This will permanently delete “{articleTitle}
+ ”. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/ui/components/internal/update-article-form.tsx b/src/ui/components/internal/update-article-form.tsx
new file mode 100644
index 0000000..9d8ae78
--- /dev/null
+++ b/src/ui/components/internal/update-article-form.tsx
@@ -0,0 +1,295 @@
+'use client';
+
+import { updateArticle } from '@/lib/feature/article/article.external';
+import { ArticleModel } from '@/lib/feature/article/article.model';
+import { uploadFile } from '@/lib/storage/storage.utils';
+import { FileUploadField } from '@/ui/components/internal/file-upload-field';
+import { Button } from '@/ui/components/shadcn/button';
+import {
+ Field,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+} from '@/ui/components/shadcn/field';
+import { Input } from '@/ui/components/shadcn/input';
+import {
+ InputGroup,
+ InputGroupTextarea,
+} from '@/ui/components/shadcn/input-group';
+import { zodResolver } from '@hookform/resolvers/zod';
+import Image from 'next/image';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Controller, useForm, useWatch } from 'react-hook-form';
+import slugify from 'slugify';
+import { toast } from 'sonner';
+import { z } from 'zod';
+import ImageLogo from '~/public/img/icons/cover-image.svg';
+import MarkdownLogo from '~/public/img/icons/markdown-content.svg';
+
+function isImageFile(file: File): boolean {
+ return file.type.startsWith('image/');
+}
+
+function isContentFile(file: File): boolean {
+ const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
+ return (
+ file.type === 'text/markdown' ||
+ file.type === 'text/plain' ||
+ extension === 'md' ||
+ extension === 'markdown' ||
+ extension === 'txt'
+ );
+}
+
+function validateImageFile(file: File): string | null {
+ return isImageFile(file)
+ ? null
+ : 'Only image files are allowed for cover image';
+}
+
+function validateContentFile(file: File): string | null {
+ return isContentFile(file)
+ ? null
+ : 'Only markdown or text files are allowed';
+}
+
+interface UpdateArticleFormProps {
+ article: ArticleModel;
+}
+
+export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => {
+ const [coverImageFile, setCoverImageFile] = useState(null);
+ const [coverImageUploading, setCoverImageUploading] = useState(false);
+ const [contentFile, setContentFile] = useState(null);
+ const coverImageUrlRef = useRef(null);
+
+ const formSchema = z.object({
+ title: z.string().min(3).max(255),
+ slug: z.string().min(3),
+ description: z.string().min(10),
+ coverImageUrl: z.url('Cover image URL must be a valid URL'),
+ content: z
+ .string()
+ .min(10, 'Article content must have at least 10 characters'),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ title: article.title,
+ slug: article.slug,
+ description: article.description,
+ coverImageUrl: article.coverImageUrl,
+ content: article.content,
+ },
+ });
+
+ const title = useWatch({ control: form.control, name: 'title' });
+ const coverImageUrl = useWatch({
+ control: form.control,
+ name: 'coverImageUrl',
+ });
+
+ useEffect(() => {
+ if (!title) return;
+ form.setValue('slug', slugify(title).toLowerCase());
+ }, [form, title]);
+
+ const handleFormSubmit = useCallback(
+ async (data: z.infer) => {
+ const result = await updateArticle(article.id, data);
+ if (!result.ok) {
+ toast.error('Failed to update article', {
+ description: result.error.message,
+ position: 'bottom-right',
+ });
+ return;
+ }
+ toast.success('Article updated', {
+ description: `"${result.value.title}" has been saved.`,
+ position: 'bottom-right',
+ });
+ },
+ [article.id]
+ );
+
+ const handleCoverImageFileChange = useCallback(
+ async (file: File | null) => {
+ if (coverImageUrlRef.current) {
+ URL.revokeObjectURL(coverImageUrlRef.current);
+ coverImageUrlRef.current = null;
+ }
+ setCoverImageFile(file);
+ if (!file) {
+ setCoverImageUploading(false);
+ form.setValue('coverImageUrl', article.coverImageUrl);
+ return;
+ }
+ setCoverImageUploading(true);
+ const fileMetadataResult = await uploadFile(file);
+ setCoverImageUploading(false);
+ if (!fileMetadataResult.ok) {
+ setCoverImageFile(null);
+ form.setValue('coverImageUrl', article.coverImageUrl);
+ toast((fileMetadataResult.error as Error).message);
+ return;
+ }
+ const fileMetadata = fileMetadataResult.value;
+ coverImageUrlRef.current = fileMetadata.signedUrl;
+ form.setValue('coverImageUrl', fileMetadata.signedUrl);
+ },
+ [form, article.coverImageUrl]
+ );
+
+ const handleContentFileChange = useCallback(
+ async (file: File | null) => {
+ setContentFile(file);
+ if (file) {
+ const content = await file.text();
+ form.setValue('content', content);
+ } else {
+ form.setValue('content', article.content);
+ }
+ },
+ [form, article.content]
+ );
+
+ const handleCoverImageReject = useCallback(
+ (_file: File, message: string) => {
+ toast.error(`Cover image rejected: ${message}`);
+ },
+ []
+ );
+
+ const handleContentFileReject = useCallback(
+ (_file: File, message: string) => {
+ toast.error(`Content file rejected: ${message}`);
+ },
+ []
+ );
+
+ return (
+
+ );
+};
+
+export default UpdateArticleForm;
diff --git a/src/ui/components/shadcn/alert-dialog.tsx b/src/ui/components/shadcn/alert-dialog.tsx
new file mode 100644
index 0000000..6ca0bbb
--- /dev/null
+++ b/src/ui/components/shadcn/alert-dialog.tsx
@@ -0,0 +1,163 @@
+'use client';
+
+import { cn } from '@/ui/components/shadcn/lib/utils';
+import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
+import * as React from 'react';
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogTrigger,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/src/ui/components/shadcn/table.tsx b/src/ui/components/shadcn/table.tsx
new file mode 100644
index 0000000..2056a71
--- /dev/null
+++ b/src/ui/components/shadcn/table.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { cn } from '@/ui/components/shadcn/lib/utils';
+import * as React from 'react';
+
+function Table({ className, ...props }: React.ComponentProps<'table'>) {
+ return (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
+ return (
+
+ );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
+ return (
+
+ );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
+ return (
+ tr]:last:border-b-0',
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
+ return (
+ |
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
+ return (
+ |
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<'caption'>) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};