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

Edit Article

+ +
+
+ ); +}; + +export default UpdateArticlePage; diff --git a/src/app/(pages)/admin/article/create/page.tsx b/src/app/(pages)/admin/article/create/page.tsx new file mode 100644 index 0000000..ca05024 --- /dev/null +++ b/src/app/(pages)/admin/article/create/page.tsx @@ -0,0 +1,14 @@ +import { CreateArticleForm } from '@/ui/components/internal/create-article-form'; + +const CreateArticlePage = () => { + return ( +
+
+

Create New Article

+ +
+
+ ); +}; + +export default CreateArticlePage; diff --git a/src/app/(pages)/admin/page.tsx b/src/app/(pages)/admin/page.tsx index e517a7e..2d4d295 100644 --- a/src/app/(pages)/admin/page.tsx +++ b/src/app/(pages)/admin/page.tsx @@ -1,12 +1,36 @@ -import CreateArticleForm from '@/ui/components/internal/create-article-form'; +import { AdminArticleList } from '@/ui/components/internal/article/admin-article-list'; +import { AdminArticleListSkeleton } from '@/ui/components/internal/article/admin-article-list-skeleton'; +import { Button } from '@/ui/components/shadcn/button'; +import { PlusIcon } from 'lucide-react'; +import Link from 'next/link'; +import { Suspense } from 'react'; -const AdminPage = async () => { +const PAGE_SIZE = 6; + +interface AdminPageProps { + searchParams: Promise<{ page?: string; pageSize?: string }>; +} + +const AdminPage = async ({ searchParams }: AdminPageProps) => { 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.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 ( +
+ +

No articles yet.

+
+ ); + } + + 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 ( +
+ + ( + + + Title + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Slug + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Description + + + + + {fieldState.invalid && ( + + )} + + )} + /> +
+
+
+
+ +
+
+ ); +}; + +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, +};