From 56a5d77c6ccdd1b808ea41852b268072a289a823 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 11 Apr 2026 20:52:35 -0300 Subject: [PATCH 1/9] refactor: rename getSignedUrl to getPublicUrl for clarity in storage functions --- src/lib/storage/storage.adapter.ts | 1 - src/lib/storage/storage.external.ts | 2 +- src/lib/storage/storage.utils.ts | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts index 02626ae..5ddc916 100644 --- a/src/lib/storage/storage.adapter.ts +++ b/src/lib/storage/storage.adapter.ts @@ -74,7 +74,6 @@ export class S3StorageAdapter implements StorageProvider { Bucket: this.bucketName, Key: key, ContentType: contentType, - ACL: ObjectCannedACL.public_read, }); return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 }); diff --git a/src/lib/storage/storage.external.ts b/src/lib/storage/storage.external.ts index a97ebeb..2454490 100644 --- a/src/lib/storage/storage.external.ts +++ b/src/lib/storage/storage.external.ts @@ -7,7 +7,7 @@ import { TypedResult } from '@/utils/types/results'; const storage: StorageProvider = createStorageProvider(); -export const getSignedUrl = async ( +export const getPublicUrl = async ( key: string, storageProvider?: StorageProvider ): Promise> => { diff --git a/src/lib/storage/storage.utils.ts b/src/lib/storage/storage.utils.ts index 17461f8..cfc5dcf 100644 --- a/src/lib/storage/storage.utils.ts +++ b/src/lib/storage/storage.utils.ts @@ -26,7 +26,7 @@ export const uploadFile = wrap(async (file: File) => { const existsResult = await storage.checkExists(fileKey); if (existsResult) { - const presignedUrl = await storage.getSignedUrl(fileKey); + const presignedUrl = await storage.getPublicUrl(fileKey); if (!presignedUrl.ok) { throw new Error('Failed to retrieve file URL'); } @@ -48,7 +48,7 @@ export const uploadFile = wrap(async (file: File) => { throw new Error('Failed to upload file'); } - const presignedUrl = await storage.getSignedUrl(fileKey); + const presignedUrl = await storage.getPublicUrl(fileKey); if (!presignedUrl.ok) { throw new Error('Failed to retrieve file URL'); } From 79e6fae0f9b2aa183c0662beb5ca194b7ab95330 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 11 Apr 2026 21:37:39 -0300 Subject: [PATCH 2/9] feat: implement ArticleList component with pagination and loading skeleton --- src/app/(pages)/home/page.tsx | 95 +++---------------- src/lib/feature/article/article.external.ts | 2 + src/lib/storage/storage.adapter.ts | 1 - src/lib/storage/storage.external.ts | 4 - src/proxy.ts | 6 ++ .../article/article-list-skeleton.tsx | 33 +++++++ .../internal/article/article-list.tsx | 87 +++++++++++++++++ 7 files changed, 140 insertions(+), 88 deletions(-) create mode 100644 src/ui/components/internal/article/article-list-skeleton.tsx create mode 100644 src/ui/components/internal/article/article-list.tsx diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx index 39556c8..def28f9 100644 --- a/src/app/(pages)/home/page.tsx +++ b/src/app/(pages)/home/page.tsx @@ -1,94 +1,18 @@ -import { getArticlesPaginated } from '@/lib/feature/article/article.external'; -import { ArticleCard } from '@/ui/components/internal/article-card'; -import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination'; -import { FileTextIcon } from 'lucide-react'; +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 = 9; -const ArticleCardSkeleton = () => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-); -const ArticleListSkeleton = () => ( - <> -
-
- {Array.from({ length: PAGE_SIZE }).map((_, i) => ( - - ))} -
- -); -type ArticleListProps = { - searchParams: Promise<{ page?: string; pageSize?: string }>; -}; -const ArticleList = async ({ searchParams }: ArticleListProps) => { - const { page: pageParam, pageSize: pageSizeParam } = await searchParams; - const page = Math.max(1, Number(pageParam) || 1); - const pageSize = Number(pageSizeParam) || PAGE_SIZE; - const paginationResult = await getArticlesPaginated(page, pageSize); - if (!paginationResult.ok) throw paginationResult.error; - const { data: articles, totalPages, total } = paginationResult.value; - return ( - <> -

- {total === 0 - ? 'No articles published yet.' - : `${total} article${total === 1 ? '' : 's'} published`} -

- {articles.length === 0 ? ( -
- -

- No articles yet. Check back soon! -

-
- ) : ( -
- {articles.map((article) => ( - - ))} -
- )} - {totalPages > 1 && ( -
-

- Page {page} of {totalPages} -

- -
- )} - - ); -}; + + +const PAGE_SIZE = 4; type HomeProps = { searchParams: Promise<{ page?: string; pageSize?: string }>; @@ -105,8 +29,13 @@ const Home = async ({ searchParams }: HomeProps) => { Latest Articles
- }> - + } + > +
); diff --git a/src/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts index fb377f6..83d6c57 100644 --- a/src/lib/feature/article/article.external.ts +++ b/src/lib/feature/article/article.external.ts @@ -39,6 +39,8 @@ export const getArticlesPaginated: ( page: number = 1, pageSize: number = 10 ): Promise => { + // await new Promise((r) => setTimeout(r, 1000)); + const result = await service.getArticlesPaginated(page, pageSize); if (!result.ok) throw result.error; return result.value; diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts index 5ddc916..d4cca79 100644 --- a/src/lib/storage/storage.adapter.ts +++ b/src/lib/storage/storage.adapter.ts @@ -3,7 +3,6 @@ import { TypedResult, wrap } from '@/utils/types/results'; import { DeleteObjectCommand, HeadObjectCommand, - ObjectCannedACL, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; diff --git a/src/lib/storage/storage.external.ts b/src/lib/storage/storage.external.ts index 2454490..81d4638 100644 --- a/src/lib/storage/storage.external.ts +++ b/src/lib/storage/storage.external.ts @@ -14,10 +14,6 @@ export const getPublicUrl = async ( if (!storageProvider) { storageProvider = storage; } - const session = await getSessionData(); - if (!session || !session?.user || session?.user.role !== 'admin') { - throw new Error('Unauthorized: Only admin users can delete articles.'); - } return await storageProvider.get(key); }; diff --git a/src/proxy.ts b/src/proxy.ts index daeadd5..426efa0 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,9 +2,15 @@ import { getSessionData } from '@/lib/session/session-storage'; import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; + + + + + const isPublic = createRouteMatcher([ '/home(.*)?', '/about(.*)?', + '/article(.*)?', '/api/user(.*)?', ]); diff --git a/src/ui/components/internal/article/article-list-skeleton.tsx b/src/ui/components/internal/article/article-list-skeleton.tsx new file mode 100644 index 0000000..e9a74d0 --- /dev/null +++ b/src/ui/components/internal/article/article-list-skeleton.tsx @@ -0,0 +1,33 @@ +export const ArticleCardSkeleton = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +export const ArticleListSkeleton = ({ + skeletonSize, +}: { + skeletonSize: number; +}) => ( + <> +
+
+ {Array.from({ length: skeletonSize }).map((_, i) => ( + + ))} +
+ +); diff --git a/src/ui/components/internal/article/article-list.tsx b/src/ui/components/internal/article/article-list.tsx new file mode 100644 index 0000000..1ede796 --- /dev/null +++ b/src/ui/components/internal/article/article-list.tsx @@ -0,0 +1,87 @@ +// 'use client'; +import { getArticlesPaginated } from '@/lib/feature/article/article.external'; +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; +}; + +export const ArticleList = async ({ + searchParams, + defaultPageSize, +}: ArticleListProps) => { + 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 } = paginationResult.value; + + return ( + <> +

+ {total === 0 + ? 'No articles published yet.' + : `${total} article${total === 1 ? '' : 's'} published`} +

+ + {articles.length === 0 ? ( +
+ +

+ No articles yet. Check back soon! +

+
+ ) : ( +
+ {articles.map((article) => ( + + ))} +
+ )} + + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+ +
+ )} + + ); +}; From c938974d2bde1b60672137fbd7ad6be2e823a96d Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Mon, 13 Apr 2026 20:51:20 -0300 Subject: [PATCH 3/9] feat: add table components and admin article list with skeleton loading --- .../admin/article/[externalId]/page.tsx | 40 +++ src/app/(pages)/admin/article/create/page.tsx | 14 + src/app/(pages)/admin/page.tsx | 34 +- src/app/(pages)/home/page.tsx | 10 - src/lib/feature/article/article.external.ts | 3 + src/proxy.ts | 5 - .../internal/article/admin-article-card.tsx | 72 +++++ .../article/admin-article-list-skeleton.tsx | 67 ++++ .../internal/article/admin-article-list.tsx | 172 ++++++++++ .../internal/article/article-list.tsx | 14 - .../article/delete-article-button.tsx | 84 +++++ .../internal/update-article-form.tsx | 295 ++++++++++++++++++ src/ui/components/shadcn/alert-dialog.tsx | 163 ++++++++++ src/ui/components/shadcn/table.tsx | 115 +++++++ 14 files changed, 1054 insertions(+), 34 deletions(-) create mode 100644 src/app/(pages)/admin/article/[externalId]/page.tsx create mode 100644 src/app/(pages)/admin/article/create/page.tsx create mode 100644 src/ui/components/internal/article/admin-article-card.tsx create mode 100644 src/ui/components/internal/article/admin-article-list-skeleton.tsx create mode 100644 src/ui/components/internal/article/admin-article-list.tsx create mode 100644 src/ui/components/internal/article/delete-article-button.tsx create mode 100644 src/ui/components/internal/update-article-form.tsx create mode 100644 src/ui/components/shadcn/alert-dialog.tsx create mode 100644 src/ui/components/shadcn/table.tsx 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, +}; From 4e61e4bff5c46fdd9f097e9678b56b6a28955a15 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 16 Apr 2026 19:26:23 -0300 Subject: [PATCH 4/9] refactor: restructure UpdateArticlePage to use Suspense for loading state --- .../(pages)/admin/article/[externalId]/page.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app/(pages)/admin/article/[externalId]/page.tsx b/src/app/(pages)/admin/article/[externalId]/page.tsx index 2d32d0e..f88eff4 100644 --- a/src/app/(pages)/admin/article/[externalId]/page.tsx +++ b/src/app/(pages)/admin/article/[externalId]/page.tsx @@ -4,20 +4,28 @@ import { UUIDv4 } from '@/utils/types/uuid'; import { ArrowLeftIcon } from 'lucide-react'; import Link from 'next/link'; import { notFound } from 'next/navigation'; +import { Suspense } from 'react'; interface UpdateArticlePageProps { params: Promise<{ externalId: string }>; } -const UpdateArticlePage = async ({ params }: UpdateArticlePageProps) => { +const ArticleFormContent = async ({ + params, +}: { + params: Promise<{ externalId: string }>; +}) => { 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 ; +}; + +const UpdateArticlePage = ({ params }: UpdateArticlePageProps) => { return (
@@ -31,7 +39,9 @@ const UpdateArticlePage = async ({ params }: UpdateArticlePageProps) => {

Edit Article

- + Loading...
}> + +
); From 873e372badd53be55a4c6c07dc3dce39ae797d31 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 16 Apr 2026 20:21:22 -0300 Subject: [PATCH 5/9] refactor: update S3StorageAdapter to use publicUrl for object retrieval --- src/lib/storage/storage.adapter.ts | 39 +++++++++++++++++++++++------- src/lib/storage/storage.utils.ts | 1 + 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts index d4cca79..26a93ce 100644 --- a/src/lib/storage/storage.adapter.ts +++ b/src/lib/storage/storage.adapter.ts @@ -1,14 +1,30 @@ import { StorageProvider } from '@/lib/storage/storage.interface'; import { TypedResult, wrap } from '@/utils/types/results'; -import { - DeleteObjectCommand, - HeadObjectCommand, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { z } from 'zod'; + + + + + + + + + + + + + + + + + + + + + /** * Configuration for S3 storage adapter */ @@ -18,6 +34,7 @@ export const S3StorageConfig = z.object({ region: z.string(), accessKey: z.string(), secretKey: z.string(), + publicUrl: z.string().optional(), }); export type S3StorageConfig = z.infer; @@ -27,7 +44,7 @@ export type S3StorageConfig = z.infer; */ export class S3StorageAdapter implements StorageProvider { private readonly s3Client: S3Client; - private readonly endpoint: string; + private readonly publicUrl: string; private readonly bucketName: string; readonly get: ( @@ -44,8 +61,9 @@ export class S3StorageAdapter implements StorageProvider { ) => Promise>; constructor(config: S3StorageConfig, s3Client?: S3Client) { - this.endpoint = config.endpoint; this.bucketName = config.bucket; + this.publicUrl = config.publicUrl || config.endpoint; + this.s3Client = s3Client || new S3Client({ @@ -56,6 +74,8 @@ export class S3StorageAdapter implements StorageProvider { accessKeyId: config.accessKey, secretAccessKey: config.secretKey, }, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', }); this.get = wrap(this._get.bind(this)); @@ -65,7 +85,7 @@ export class S3StorageAdapter implements StorageProvider { } private async _get(key: string): Promise { - return `${this.endpoint}/${this.bucketName}/${key}`; + return `${this.publicUrl}/${key}`; } private async _put(key: string, contentType: string): Promise { @@ -109,6 +129,7 @@ export const new_s3_storage_adapter = (): S3StorageAdapter => { region: process.env.S3_REGION, accessKey: process.env.S3_ACCESS_KEY, secretKey: process.env.S3_SECRET_KEY, + publicUrl: process.env.S3_PUBLIC_URL, }); return new S3StorageAdapter(config); }; diff --git a/src/lib/storage/storage.utils.ts b/src/lib/storage/storage.utils.ts index cfc5dcf..ec88f6f 100644 --- a/src/lib/storage/storage.utils.ts +++ b/src/lib/storage/storage.utils.ts @@ -37,6 +37,7 @@ export const uploadFile = wrap(async (file: File) => { if (!result.ok) { throw new Error('File upload failed'); } + console.log(result.value); const response = await fetch(result.value, { method: 'PUT', From b9e34e590d7b888ba5744d804433ed299dc42524 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Fri, 17 Apr 2026 00:12:44 -0300 Subject: [PATCH 6/9] refactor: update article deletion and retrieval methods to use external ID --- src/app/(pages)/home/page.tsx | 18 ++---- src/lib/feature/article/article.external.ts | 57 +++++++++++++----- src/lib/feature/article/article.service.ts | 13 +++-- src/lib/storage/storage.adapter.ts | 28 ++------- .../internal/article/admin-article-card.tsx | 4 +- .../internal/article/admin-article-list.tsx | 4 +- .../internal/article/article-list.tsx | 16 ++--- .../article/delete-article-button.tsx | 17 +++--- src/utils/types/results.ts | 58 ++++++++++++++----- src/utils/types/uuid.ts | 24 +------- .../feature/article/article.service.test.ts | 10 ++-- 11 files changed, 134 insertions(+), 115 deletions(-) diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx index bb16a9a..e0d6fb3 100644 --- a/src/app/(pages)/home/page.tsx +++ b/src/app/(pages)/home/page.tsx @@ -1,14 +1,15 @@ 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; +const DEFAULT_PAGE_SIZE = 4; type HomeProps = { - searchParams: Promise<{ page?: string; pageSize?: string }>; + searchParams?: { page?: string; pageSize?: string }; }; const Home = async ({ searchParams }: HomeProps) => { + const page = Number(searchParams?.page) || 0; + const pageSize = Number(searchParams?.pageSize) || DEFAULT_PAGE_SIZE; + return (
@@ -19,14 +20,7 @@ const Home = async ({ searchParams }: HomeProps) => { Latest Articles
- } - > - - +
); }; diff --git a/src/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts index df536d5..c6c98b0 100644 --- a/src/lib/feature/article/article.external.ts +++ b/src/lib/feature/article/article.external.ts @@ -8,43 +8,56 @@ import { } from '@/lib/feature/article/article.model'; import * as service from '@/lib/feature/article/article.service'; import { getSessionData } from '@/lib/session/session-storage'; -import { TypedResult, wrap } from '@/utils/types/results'; +import { TypedResult, wrap, wrapCached } from '@/utils/types/results'; import { UUIDv4 } from '@/utils/types/uuid'; -import { revalidatePath } from 'next/cache'; +import { revalidateTag } from 'next/cache'; export const getArticleByExternalId: ( externalId: UUIDv4 -) => Promise> = wrap( +) => Promise> = wrapCached( async (externalId: UUIDv4): Promise => { const result = await service.getArticleByExternalId(externalId); if (!result.ok) throw result.error; return result.value; + }, + { + key: (id) => [`article:${id}`], + tags: (id) => ['articles', `article:${id}`], } ); export const getArticleBySlug: ( slug: string -) => Promise> = wrap( +) => Promise> = wrapCached( async (slug: string): Promise => { const result = await service.getArticleBySlug(slug); if (!result.ok) throw result.error; return result.value; + }, + { + key: (slug) => [`article:slug:${slug}`], + tags: (slug) => ['articles', `article:slug:${slug}`], } ); export const getArticlesPaginated: ( page?: number, pageSize?: number -) => Promise> = wrap( +) => Promise> = wrapCached( async ( page: number = 1, pageSize: number = 10 ): Promise => { - // await new Promise((r) => setTimeout(r, 1000)); - const result = await service.getArticlesPaginated(page, pageSize); if (!result.ok) throw result.error; return result.value; + }, + { + key: (page, pageSize) => [`articles:page:${page}:${pageSize}`], + tags: (page, pageSize) => [ + 'articles', + `articles:page:${page}-${pageSize}`, + ], } ); @@ -62,6 +75,8 @@ export const saveArticle: ( const result = await service.saveArticle(article); if (!result.ok) throw result.error; + + revalidateTag('articles', 'max'); return result.value; } ); @@ -83,13 +98,19 @@ export const updateArticle: ( const result = await service.updateArticle(articleId, article); if (!result.ok) throw result.error; - revalidatePath('/admin'); + + revalidateTag('articles', 'max'); + revalidateTag(`article:${articleId}`, 'max'); + revalidateTag(`article:slug:${result.value.slug}`, 'max'); + return result.value; } ); -export const deleteArticle: (articleId: string) => Promise> = - wrap(async (articleId: string): Promise => { +export const deleteArticleByExternalId: ( + externalId: UUIDv4 +) => Promise> = wrap( + async (externalId: UUIDv4): Promise => { const session = await getSessionData(); if (!session || !session?.user || session?.user.role !== 'admin') { throw new Error( @@ -97,7 +118,17 @@ export const deleteArticle: (articleId: string) => Promise> = ); } - const result = await service.deleteArticle(articleId); + const getResult = await service.getArticleByExternalId(externalId); + if (!getResult.ok) throw getResult.error; + const article = getResult.value; + + if (!article) throw new Error('Article not found'); + + const result = await service.deleteArticleByExternalId(externalId); if (!result.ok) throw result.error; - revalidatePath('/admin'); - }); + + revalidateTag('articles', 'max'); + revalidateTag(`article:${externalId}`, 'max'); + revalidateTag(`article:slug:${article.slug}`, 'max'); + } +); diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts index 6eaa5ae..2121fce 100644 --- a/src/lib/feature/article/article.service.ts +++ b/src/lib/feature/article/article.service.ts @@ -155,16 +155,19 @@ export const updateArticle: ( ); /** Deletes an article from the database. */ -export const deleteArticle: (articleId: string) => Promise> = - wrap(async (articleId: string): Promise => { +export const deleteArticleByExternalId: ( + externalId: UUIDv4 +) => Promise> = wrap( + async (externalId: UUIDv4): Promise => { const articleRepository = await getRepository(ArticleEntity); const existingArticle = await articleRepository.findOneBy({ - id: articleId, + externalId: externalId, }); if (!existingArticle) { - throw new Error(`Article with ID ${articleId} not found`); + throw new Error(`Article with ExternalID ${externalId} not found`); } await articleRepository.remove(existingArticle); - }); + } +); diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts index 26a93ce..6a6ad85 100644 --- a/src/lib/storage/storage.adapter.ts +++ b/src/lib/storage/storage.adapter.ts @@ -1,30 +1,14 @@ import { StorageProvider } from '@/lib/storage/storage.interface'; import { TypedResult, wrap } from '@/utils/types/results'; -import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + DeleteObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { z } from 'zod'; - - - - - - - - - - - - - - - - - - - - - /** * Configuration for S3 storage adapter */ diff --git a/src/ui/components/internal/article/admin-article-card.tsx b/src/ui/components/internal/article/admin-article-card.tsx index 6497b75..52ab98e 100644 --- a/src/ui/components/internal/article/admin-article-card.tsx +++ b/src/ui/components/internal/article/admin-article-card.tsx @@ -62,8 +62,8 @@ export const AdminArticleCard = ({ article }: AdminArticleCardProps) => { diff --git a/src/ui/components/internal/article/admin-article-list.tsx b/src/ui/components/internal/article/admin-article-list.tsx index f7e6e52..4d0ba1b 100644 --- a/src/ui/components/internal/article/admin-article-list.tsx +++ b/src/ui/components/internal/article/admin-article-list.tsx @@ -144,8 +144,8 @@ export const AdminArticleList = async ({ diff --git a/src/ui/components/internal/article/article-list.tsx b/src/ui/components/internal/article/article-list.tsx index 248d596..303d25e 100644 --- a/src/ui/components/internal/article/article-list.tsx +++ b/src/ui/components/internal/article/article-list.tsx @@ -1,22 +1,18 @@ -// 'use client'; import { getArticlesPaginated } from '@/lib/feature/article/article.external'; import { ArticleCard } from '@/ui/components/internal/article-card'; import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination'; import { FileTextIcon } from 'lucide-react'; +import { cacheTag } from 'next/cache'; type ArticleListProps = { - searchParams: Promise<{ page?: string; pageSize?: string }>; - defaultPageSize: number; + page: number; + pageSize: number; }; -export const ArticleList = async ({ - searchParams, - defaultPageSize, -}: ArticleListProps) => { - const { page: pageParam, pageSize: pageSizeParam } = await searchParams; - const page = Math.max(1, Number(pageParam) || 1); - const pageSize = Number(pageSizeParam) || defaultPageSize; +export const ArticleList = async ({ page, pageSize }: ArticleListProps) => { + 'use cache'; + cacheTag('articles', `articles:page:${page}-${pageSize}`); const paginationResult = await getArticlesPaginated(page, pageSize); if (!paginationResult.ok) { diff --git a/src/ui/components/internal/article/delete-article-button.tsx b/src/ui/components/internal/article/delete-article-button.tsx index 1223808..cd74c32 100644 --- a/src/ui/components/internal/article/delete-article-button.tsx +++ b/src/ui/components/internal/article/delete-article-button.tsx @@ -1,6 +1,6 @@ 'use client'; -import { deleteArticle } from '@/lib/feature/article/article.external'; +import { deleteArticleByExternalId } from '@/lib/feature/article/article.external'; import { AlertDialog, AlertDialogAction, @@ -13,24 +13,25 @@ import { AlertDialogTrigger, } from '@/ui/components/shadcn/alert-dialog'; import { Button } from '@/ui/components/shadcn/button'; +import { UUIDv4 } from '@/utils/types/uuid'; import { Trash2Icon } from 'lucide-react'; import { useTransition } from 'react'; import { toast } from 'sonner'; interface DeleteArticleButtonProps { - articleId: string; - articleTitle: string; + externalID: UUIDv4; + title: string; } export const DeleteArticleButton = ({ - articleId, - articleTitle, + externalID, + title, }: DeleteArticleButtonProps) => { const [isPending, startTransition] = useTransition(); const handleDelete = () => { startTransition(async () => { - const result = await deleteArticle(articleId); + const result = await deleteArticleByExternalId(externalID); if (!result.ok) { toast.error('Failed to delete article', { description: result.error.message, @@ -39,7 +40,7 @@ export const DeleteArticleButton = ({ return; } toast.success('Article deleted', { - description: `"${articleTitle}" has been removed.`, + description: `"${title}" has been removed.`, position: 'bottom-right', }); }); @@ -57,7 +58,7 @@ export const DeleteArticleButton = ({ Delete article? - This will permanently delete “{articleTitle} + This will permanently delete “{title} ”. This action cannot be undone. diff --git a/src/utils/types/results.ts b/src/utils/types/results.ts index c93eca8..61150c0 100644 --- a/src/utils/types/results.ts +++ b/src/utils/types/results.ts @@ -1,22 +1,8 @@ +import { unstable_cache } from 'next/cache'; + export type Result = { ok: true; value: T } | { ok: false; error: E }; export type TypedResult = Result; -export function wrapBlocking< - F extends (...args: never[]) => unknown, - E = unknown, ->( - fn: F, - mapError: (e: unknown) => E = (e) => e as E -): (...args: Parameters) => Result, E> { - return (...args) => { - try { - return { ok: true, value: fn(...args) as ReturnType }; - } catch (e) { - return { ok: false, error: mapError(e) }; - } - }; -} - export function wrap< F extends (...args: never[]) => Promise, E = unknown, @@ -35,3 +21,43 @@ export function wrap< } }; } + +export function wrapCached< + F extends (...args: never[]) => Promise, + E = unknown, +>( + fn: F, + options: { + key: (...args: Parameters) => string[]; + tags?: (...args: Parameters) => string[]; + revalidate?: number; + }, + mapError: (e: unknown) => E = (e) => e as E +): (...args: Parameters) => Promise>, E>> { + return async (...args: Parameters) => { + try { + const cachedFn = unstable_cache( + async (...innerArgs: Parameters) => { + return await fn(...innerArgs); + }, + options.key(...args), + { + tags: options.tags?.(...args), + revalidate: options.revalidate, + } + ); + + const value = await cachedFn(...args); + + return { + ok: true, + value: value as Awaited>, + }; + } catch (e) { + return { + ok: false, + error: mapError(e), + }; + } + }; +} diff --git a/src/utils/types/uuid.ts b/src/utils/types/uuid.ts index bfd9b9f..5838c7a 100644 --- a/src/utils/types/uuid.ts +++ b/src/utils/types/uuid.ts @@ -1,22 +1,4 @@ -type HexChar = - | '0' - | '1' - | '2' - | '3' - | '4' - | '5' - | '6' - | '7' - | '8' - | '9' - | 'a' - | 'b' - | 'c' - | 'd' - | 'e' - | 'f'; -type UUIDv4Segment = - `${HexChar extends string ? HexChar : never}${string extends `${Length}` ? never : never}`; // Simplified for brevity +import { z } from 'zod'; -export type UUIDv4 = - `${UUIDv4Segment<8>}-${UUIDv4Segment<4>}-4${UUIDv4Segment<3>}-${'8' | '9' | 'a' | 'b'}${UUIDv4Segment<3>}-${UUIDv4Segment<12>}`; +export const UUIDv4 = z.uuid(); +export type UUIDv4 = z.infer; diff --git a/tests/lib/feature/article/article.service.test.ts b/tests/lib/feature/article/article.service.test.ts index 8d5ea16..11267cb 100644 --- a/tests/lib/feature/article/article.service.test.ts +++ b/tests/lib/feature/article/article.service.test.ts @@ -1,13 +1,13 @@ -import { getArticlesPaginated } from '@/lib/feature/article/article.external'; import { CreateArticleModel, UpdateArticleModel, } from '@/lib/feature/article/article.model'; import { - deleteArticle, + deleteArticleByExternalId, getArticleByExternalId, getArticleBySlug, getArticlesByAuthorId, + getArticlesPaginated, saveArticle, updateArticle, } from '@/lib/feature/article/article.service'; @@ -259,7 +259,9 @@ describe('ArticleService', () => { if (!saveResult.ok) return; expect(saveResult.value.id).toBeDefined(); - const deleteResult = await deleteArticle(saveResult.value.id); + const deleteResult = await deleteArticleByExternalId( + saveResult.value.externalId + ); expect(deleteResult.ok).toBe(true); const getResult = await getArticleBySlug('article-to-delete'); @@ -269,7 +271,7 @@ describe('ArticleService', () => { }); test('cannot delete non-existing article', async () => { - const result = await deleteArticle('9999'); + const result = await deleteArticleByExternalId('9999'); expect(result.ok).toBe(false); if (result.ok) return; From 26bdb65346d1a330c76fdde94396dad2bf89588e Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Fri, 17 Apr 2026 00:59:13 -0300 Subject: [PATCH 7/9] refactor: update S3StorageAdapter tests to use public URL for object retrieval --- tests/lib/storage/storage.s3.test.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/lib/storage/storage.s3.test.ts b/tests/lib/storage/storage.s3.test.ts index fbba29d..12fe89d 100644 --- a/tests/lib/storage/storage.s3.test.ts +++ b/tests/lib/storage/storage.s3.test.ts @@ -1,15 +1,20 @@ -import { - S3StorageAdapter, - S3StorageConfig, -} from '@/lib/storage/storage.adapter'; -import { - DeleteObjectCommand, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; +import { S3StorageAdapter, S3StorageConfig } from '@/lib/storage/storage.adapter'; +import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import * as presigner from '@aws-sdk/s3-request-presigner'; import { mockClient } from 'aws-sdk-client-mock'; + + + + + + + + + + + + jest.mock('@aws-sdk/s3-request-presigner'); describe('S3StorageAdapter', () => { @@ -23,6 +28,7 @@ describe('S3StorageAdapter', () => { region: 'us-east-1', accessKey: 'test-access-key', secretKey: 'test-secret-key', + publicUrl: 'http://test.com', }; beforeEach(() => { @@ -44,7 +50,7 @@ describe('S3StorageAdapter', () => { expect(result).toEqual({ ok: true, - value: `http://localhost:9000/test-bucket/${key}`, + value: `http://test.com/${key}`, }); }); @@ -55,7 +61,7 @@ describe('S3StorageAdapter', () => { expect(result).toEqual({ ok: true, - value: `http://localhost:9000/test-bucket/${key}`, + value: `http://test.com/${key}`, }); }); }); From 93d66315a132c081f3801a094ea483bc2e6b1582 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Fri, 17 Apr 2026 01:01:21 -0300 Subject: [PATCH 8/9] refactor: update Next.js and Clerk dependencies to latest versions --- package-lock.json | 132 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index b315651..2547b46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "iron-session": "^8.0.4", "jotai": "^2.19.0", "lucide-react": "^1.7.0", - "next": "16.2.1", + "next": "^16.2.4", "next-themes": "^0.4.6", "pg": "^8.20.0", "radix-ui": "^1.4.3", @@ -1585,12 +1585,12 @@ "license": "MIT" }, "node_modules/@clerk/backend": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.3.tgz", - "integrity": "sha512-I3YLnSioYFG+EVFBYm0ilN28+FC8H+hkqMgB5Pdl7AcotQOn3JhiZMqLel2H0P390p8FEJKQNnrvXk3BemeKKQ==", + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.12.tgz", + "integrity": "sha512-pTuD3+3IvLrjx9XMYdbqpttTltrWc7npxkOIDzhtID5/+IOomxZAzsnxU1G1hvOeBoR1Jl68ZgKQw66aZ+DRFQ==", "license": "MIT", "dependencies": { - "@clerk/shared": "^4.3.2", + "@clerk/shared": "^4.8.2", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" }, @@ -1599,14 +1599,14 @@ } }, "node_modules/@clerk/nextjs": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.0.7.tgz", - "integrity": "sha512-Iqg4q0ns1LZZrAdC66r/QUFMY+Rs3HAJcAb/IR0uFBj7ZAZusxdVKMmNkZP9UP6sk3OOorCsJTdE0rTMoXD2YQ==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.2.2.tgz", + "integrity": "sha512-kFQ+sXD5qAxL8C53hDgpKJT+KithlOFDlkMK5Bwv/YNFy/Sf5Tzkxdh4y/AfAgJlDU0ksvu0Hn7mtB2QT8t9bg==", "license": "MIT", "dependencies": { - "@clerk/backend": "^3.2.3", - "@clerk/react": "^6.1.3", - "@clerk/shared": "^4.3.2", + "@clerk/backend": "^3.2.12", + "@clerk/react": "^6.4.2", + "@clerk/shared": "^4.8.2", "server-only": "0.0.1", "tslib": "2.8.1" }, @@ -1620,12 +1620,12 @@ } }, "node_modules/@clerk/react": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.1.3.tgz", - "integrity": "sha512-9t5C8eM5cTmOmpBO5nb8FDA40biQqeQLUW+cVwAE0t5hnGRwiC6mSv83vqHg+9qQBqtliR013BGVjpCz53gVCA==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.4.2.tgz", + "integrity": "sha512-l43pon5wcM0n4e3gnRP7MWdvAXAa3M7BOF6aeNwEuITxgy6MU6ypej9tPeGmXxPicL/Hd6E/VRgiEipEKDqyCQ==", "license": "MIT", "dependencies": { - "@clerk/shared": "^4.3.2", + "@clerk/shared": "^4.8.2", "tslib": "2.8.1" }, "engines": { @@ -1637,9 +1637,9 @@ } }, "node_modules/@clerk/shared": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.3.2.tgz", - "integrity": "sha512-tYYzdY4Fxb02TO4RHoLRFzEjXJn0iFDfoKhWtGyqf2AaIgkprTksunQtX0hnVssHMr3XD/E2S00Vrb+PzX3jCQ==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.8.2.tgz", + "integrity": "sha512-kBFDNeLdiNZkOHavTkOB00NMuqsmOGgVtDlzCS0/yivxsCUZXk3AT6+UsTfeSBGV7QD80YxjeFMNSrpqnSv4Gg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2164,9 +2164,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -3518,9 +3518,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", - "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -3534,9 +3534,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz", - "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -3550,9 +3550,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz", - "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -3566,9 +3566,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz", - "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], @@ -3582,9 +3582,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz", - "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], @@ -3598,9 +3598,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz", - "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], @@ -3614,9 +3614,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz", - "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], @@ -3630,9 +3630,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz", - "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -3646,9 +3646,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz", - "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -11598,9 +11598,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -15239,12 +15239,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz", - "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.2.1", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -15258,14 +15258,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.1", - "@next/swc-darwin-x64": "16.2.1", - "@next/swc-linux-arm64-gnu": "16.2.1", - "@next/swc-linux-arm64-musl": "16.2.1", - "@next/swc-linux-x64-gnu": "16.2.1", - "@next/swc-linux-x64-musl": "16.2.1", - "@next/swc-win32-arm64-msvc": "16.2.1", - "@next/swc-win32-x64-msvc": "16.2.1", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { @@ -16513,9 +16513,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", diff --git a/package.json b/package.json index f33dccd..c99859c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "iron-session": "^8.0.4", "jotai": "^2.19.0", "lucide-react": "^1.7.0", - "next": "16.2.1", + "next": "^16.2.4", "next-themes": "^0.4.6", "pg": "^8.20.0", "radix-ui": "^1.4.3", From e2960027f2fc1ea0e4637dff9252ee9201a312b0 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Fri, 17 Apr 2026 01:43:31 -0300 Subject: [PATCH 9/9] refactor: update article service methods to use external ID and improve caching --- src/app/(pages)/article/[slug]/page.tsx | 21 ++++++----- src/app/(pages)/home/page.tsx | 24 ++++++++++--- src/lib/feature/article/article.external.ts | 36 +++++++------------ src/lib/feature/article/article.model.ts | 1 - src/lib/feature/article/article.service.ts | 11 +++--- .../internal/update-article-form.tsx | 9 +++-- .../feature/article/article.service.test.ts | 14 ++++---- tests/lib/storage/storage.s3.test.ts | 23 +++++------- 8 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/app/(pages)/article/[slug]/page.tsx b/src/app/(pages)/article/[slug]/page.tsx index d5867dd..773f7f6 100644 --- a/src/app/(pages)/article/[slug]/page.tsx +++ b/src/app/(pages)/article/[slug]/page.tsx @@ -1,5 +1,6 @@ import { getArticleBySlug } from '@/lib/feature/article/article.external'; import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react'; +import { cacheTag } from 'next/cache'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { Suspense } from 'react'; @@ -10,10 +11,6 @@ type ArticlePageProps = { params: Promise<{ slug: string }>; }; -type ArticleContentProps = { - params: Promise<{ slug: string }>; -}; - function readingTime(text: string): number { const words = text.trim().split(/\s+/).length; return Math.max(1, Math.ceil(words / 200)); @@ -68,8 +65,11 @@ const ArticleContentSkeleton = () => ( ); -const ArticleContent = async ({ params }: ArticleContentProps) => { - const { slug } = await params; +const ArticleContent = async ({ slug }: { slug: string }) => { + 'use cache'; + + cacheTag(`article:slug:${slug}`); + const articleResult = await getArticleBySlug(slug); if (!articleResult.ok) throw articleResult.error; const article = articleResult.value; @@ -144,10 +144,15 @@ const ArticleContent = async ({ params }: ArticleContentProps) => { ); }; -const ArticlePage = ({ params }: ArticlePageProps) => { +const ArticleContentWrapper = async ({ params }: ArticlePageProps) => { + const { slug } = await params; + return ; +}; + +const ArticlePage = (props: ArticlePageProps) => { return ( }> - + ); }; diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx index e0d6fb3..b101b94 100644 --- a/src/app/(pages)/home/page.tsx +++ b/src/app/(pages)/home/page.tsx @@ -1,15 +1,23 @@ import { ArticleList } from '@/ui/components/internal/article/article-list'; +import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton'; +import { Suspense } from 'react'; const DEFAULT_PAGE_SIZE = 4; type HomeProps = { - searchParams?: { page?: string; pageSize?: string }; + searchParams?: Promise<{ page?: string; pageSize?: string }>; }; -const Home = async ({ searchParams }: HomeProps) => { - const page = Number(searchParams?.page) || 0; - const pageSize = Number(searchParams?.pageSize) || DEFAULT_PAGE_SIZE; +const ArticleListWrapper = async ({ searchParams }: HomeProps) => { + const params = await searchParams; + const page = Number(params?.page) || 1; + const pageSize = Number(params?.pageSize) || DEFAULT_PAGE_SIZE; + + return ; +}; + +const Home = async (props: HomeProps) => { return (
@@ -20,7 +28,13 @@ const Home = async ({ searchParams }: HomeProps) => { Latest Articles
- + + } + > + +
); }; diff --git a/src/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts index c6c98b0..2e5cea2 100644 --- a/src/lib/feature/article/article.external.ts +++ b/src/lib/feature/article/article.external.ts @@ -8,42 +8,34 @@ import { } from '@/lib/feature/article/article.model'; import * as service from '@/lib/feature/article/article.service'; import { getSessionData } from '@/lib/session/session-storage'; -import { TypedResult, wrap, wrapCached } from '@/utils/types/results'; +import { TypedResult, wrap } from '@/utils/types/results'; import { UUIDv4 } from '@/utils/types/uuid'; import { revalidateTag } from 'next/cache'; export const getArticleByExternalId: ( externalId: UUIDv4 -) => Promise> = wrapCached( +) => Promise> = wrap( async (externalId: UUIDv4): Promise => { const result = await service.getArticleByExternalId(externalId); if (!result.ok) throw result.error; return result.value; - }, - { - key: (id) => [`article:${id}`], - tags: (id) => ['articles', `article:${id}`], } ); export const getArticleBySlug: ( slug: string -) => Promise> = wrapCached( +) => Promise> = wrap( async (slug: string): Promise => { const result = await service.getArticleBySlug(slug); if (!result.ok) throw result.error; return result.value; - }, - { - key: (slug) => [`article:slug:${slug}`], - tags: (slug) => ['articles', `article:slug:${slug}`], } ); export const getArticlesPaginated: ( page?: number, pageSize?: number -) => Promise> = wrapCached( +) => Promise> = wrap( async ( page: number = 1, pageSize: number = 10 @@ -51,13 +43,6 @@ export const getArticlesPaginated: ( const result = await service.getArticlesPaginated(page, pageSize); if (!result.ok) throw result.error; return result.value; - }, - { - key: (page, pageSize) => [`articles:page:${page}:${pageSize}`], - tags: (page, pageSize) => [ - 'articles', - `articles:page:${page}-${pageSize}`, - ], } ); @@ -81,12 +66,12 @@ export const saveArticle: ( } ); -export const updateArticle: ( - articleId: string, +export const updateArticleByExternalId: ( + externalId: UUIDv4, article: UpdateArticleModel ) => Promise> = wrap( async ( - articleId: string, + externalId: UUIDv4, article: UpdateArticleModel ): Promise => { const session = await getSessionData(); @@ -96,11 +81,14 @@ export const updateArticle: ( ); } - const result = await service.updateArticle(articleId, article); + const result = await service.updateArticleByExternalId( + externalId, + article + ); if (!result.ok) throw result.error; revalidateTag('articles', 'max'); - revalidateTag(`article:${articleId}`, 'max'); + revalidateTag(`article:${externalId}`, 'max'); revalidateTag(`article:slug:${result.value.slug}`, 'max'); return result.value; diff --git a/src/lib/feature/article/article.model.ts b/src/lib/feature/article/article.model.ts index 7851c97..5c5d1dc 100644 --- a/src/lib/feature/article/article.model.ts +++ b/src/lib/feature/article/article.model.ts @@ -21,7 +21,6 @@ export const UpdateArticleModel = z.object({ export type UpdateArticleModel = z.infer; export const ArticleModel = z.object({ - id: z.string(), title: z.string(), slug: z.string(), description: z.string(), diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts index 2121fce..9acc668 100644 --- a/src/lib/feature/article/article.service.ts +++ b/src/lib/feature/article/article.service.ts @@ -13,7 +13,6 @@ export const articleEntityToModel = ( articleEntity: ArticleEntity ): ArticleModel => { return { - id: articleEntity.id, title: articleEntity.title, slug: articleEntity.slug, description: articleEntity.description, @@ -123,21 +122,21 @@ export const saveArticle: ( ); /** Updates an existing article in the database. */ -export const updateArticle: ( - articleId: string, +export const updateArticleByExternalId: ( + externalId: string, article: UpdateArticleModel ) => Promise> = wrap( async ( - articleId: string, + externalId: string, article: UpdateArticleModel ): Promise => { const articleRepository = await getRepository(ArticleEntity); const existingArticle = await articleRepository.findOneBy({ - id: articleId, + externalId: externalId, }); if (!existingArticle) { - throw new Error(`Article with ID ${articleId} not found`); + throw new Error(`Article with ID ${externalId} not found`); } if (!!article.title) existingArticle.title = article.title; diff --git a/src/ui/components/internal/update-article-form.tsx b/src/ui/components/internal/update-article-form.tsx index 9d8ae78..33ff894 100644 --- a/src/ui/components/internal/update-article-form.tsx +++ b/src/ui/components/internal/update-article-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { updateArticle } from '@/lib/feature/article/article.external'; +import { updateArticleByExternalId } 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'; @@ -97,7 +97,10 @@ export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => { const handleFormSubmit = useCallback( async (data: z.infer) => { - const result = await updateArticle(article.id, data); + const result = await updateArticleByExternalId( + article.externalId, + data + ); if (!result.ok) { toast.error('Failed to update article', { description: result.error.message, @@ -110,7 +113,7 @@ export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => { position: 'bottom-right', }); }, - [article.id] + [article.externalId] ); const handleCoverImageFileChange = useCallback( diff --git a/tests/lib/feature/article/article.service.test.ts b/tests/lib/feature/article/article.service.test.ts index 11267cb..13aceb4 100644 --- a/tests/lib/feature/article/article.service.test.ts +++ b/tests/lib/feature/article/article.service.test.ts @@ -9,7 +9,7 @@ import { getArticlesByAuthorId, getArticlesPaginated, saveArticle, - updateArticle, + updateArticleByExternalId, } from '@/lib/feature/article/article.service'; import { CreateUserModel } from '@/lib/feature/user/user.model'; import { saveUser } from '@/lib/feature/user/user.service'; @@ -56,7 +56,6 @@ describe('ArticleService', () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.id).toBeDefined(); expect(result.value.title).toBe(articleToSave.title); expect(result.value.slug).toBe(articleToSave.slug); expect(result.value.description).toBe(articleToSave.description); @@ -136,7 +135,6 @@ describe('ArticleService', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value).toBeDefined(); - expect(result.value?.id).toBe(article!.id); expect(result.value?.title).toBe(article!.title); expect(result.value?.slug).toBe(article!.slug); expect(result.value?.externalId).toBe(article!.externalId); @@ -219,12 +217,14 @@ describe('ArticleService', () => { const article = slugResult.value; expect(article).toBeDefined(); - const result = await updateArticle(article!.id, dataToUpdate); + const result = await updateArticleByExternalId( + article!.externalId, + dataToUpdate + ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value).toBeDefined(); - expect(result.value.id).toBe(article!.id); expect(result.value.title).toBe(dataToUpdate.title); expect(result.value.description).toBe(dataToUpdate.description); expect(result.value.slug).toBe(article!.slug); @@ -237,7 +237,7 @@ describe('ArticleService', () => { title: 'Updated Article Title', }; - const result = await updateArticle('9999', dataToUpdate); + const result = await updateArticleByExternalId('9999', dataToUpdate); expect(result.ok).toBe(false); if (result.ok) return; @@ -257,7 +257,7 @@ describe('ArticleService', () => { const saveResult = await saveArticle(articleToSave); expect(saveResult.ok).toBe(true); if (!saveResult.ok) return; - expect(saveResult.value.id).toBeDefined(); + expect(saveResult.value.externalId).toBeDefined(); const deleteResult = await deleteArticleByExternalId( saveResult.value.externalId diff --git a/tests/lib/storage/storage.s3.test.ts b/tests/lib/storage/storage.s3.test.ts index 12fe89d..08819d8 100644 --- a/tests/lib/storage/storage.s3.test.ts +++ b/tests/lib/storage/storage.s3.test.ts @@ -1,20 +1,15 @@ -import { S3StorageAdapter, S3StorageConfig } from '@/lib/storage/storage.adapter'; -import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + S3StorageAdapter, + S3StorageConfig, +} from '@/lib/storage/storage.adapter'; +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import * as presigner from '@aws-sdk/s3-request-presigner'; import { mockClient } from 'aws-sdk-client-mock'; - - - - - - - - - - - - jest.mock('@aws-sdk/s3-request-presigner'); describe('S3StorageAdapter', () => {