Compare commits

..

3 Commits

18 changed files with 1176 additions and 105 deletions

View File

@@ -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 (
<div className='container mx-auto px-4 py-10 min-h-3/4'>
<div className='mb-6'>
<Link
href='/admin'
className='inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors'
>
<ArrowLeftIcon className='size-4' />
Back to articles
</Link>
</div>
<div className='rounded-lg border border-border p-6'>
<h2 className='mb-6 text-2xl font-bold'>Edit Article</h2>
<UpdateArticleForm article={article} />
</div>
</div>
);
};
export default UpdateArticlePage;

View File

@@ -0,0 +1,14 @@
import { CreateArticleForm } from '@/ui/components/internal/create-article-form';
const CreateArticlePage = () => {
return (
<div className='container mx-auto px-4 py-10 min-h-3/4'>
<div className='rounded-lg border border-border p-6'>
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
<CreateArticleForm />
</div>
</div>
);
};
export default CreateArticlePage;

View File

@@ -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 (
<div className='container mx-auto px-4 py-10 min-h-3/4'>
<div className='rounded-lg border border-border p-6'>
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
<CreateArticleForm />
<div className='mb-8 flex items-center justify-between border-b border-border pb-6'>
<h1 className='text-2xl font-bold'>Articles</h1>
<Button asChild>
<Link href='/admin/article/create'>
<PlusIcon className='size-4' />
Create Article
</Link>
</Button>
</div>
<Suspense
fallback={<AdminArticleListSkeleton skeletonSize={PAGE_SIZE} />}
>
<AdminArticleList
searchParams={searchParams}
defaultPageSize={PAGE_SIZE}
/>
</Suspense>
</div>
);
};

View File

@@ -1,94 +1,8 @@
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 = () => (
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card'>
<div className='aspect-video w-full animate-pulse bg-muted' />
<div className='flex flex-1 flex-col gap-3 p-5'>
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
<div className='space-y-1.5'>
<div className='h-4 w-full animate-pulse rounded bg-muted' />
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
</div>
<div className='flex-1 space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-muted' />
<div className='h-3 w-full animate-pulse rounded bg-muted' />
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
</div>
<div className='mt-1 h-3 w-16 animate-pulse rounded bg-muted' />
</div>
</div>
);
const ArticleListSkeleton = () => (
<>
<div className='mb-10 h-4 w-32 animate-pulse rounded bg-muted' />
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<ArticleCardSkeleton key={i} />
))}
</div>
</>
);
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 (
<>
<p className='mb-10 text-muted-foreground'>
{total === 0
? 'No articles published yet.'
: `${total} article${total === 1 ? '' : 's'} published`}
</p>
{articles.length === 0 ? (
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
<FileTextIcon className='size-10 text-muted-foreground/40' />
<p className='text-muted-foreground'>
No articles yet. Check back soon!
</p>
</div>
) : (
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
{articles.map((article) => (
<ArticleCard
key={article.externalId}
article={article}
/>
))}
</div>
)}
{totalPages > 1 && (
<div className='mt-12 flex items-center justify-between gap-4'>
<p className='text-sm text-muted-foreground'>
Page {page} of {totalPages}
</p>
<ArticleListPagination
currentPage={page}
totalPages={totalPages}
/>
</div>
)}
</>
);
};
const PAGE_SIZE = 4;
type HomeProps = {
searchParams: Promise<{ page?: string; pageSize?: string }>;
@@ -105,8 +19,13 @@ const Home = async ({ searchParams }: HomeProps) => {
Latest Articles
</h1>
</div>
<Suspense fallback={<ArticleListSkeleton />}>
<ArticleList searchParams={searchParams} />
<Suspense
fallback={<ArticleListSkeleton skeletonSize={PAGE_SIZE} />}
>
<ArticleList
searchParams={searchParams}
defaultPageSize={PAGE_SIZE}
/>
</Suspense>
</div>
);

View File

@@ -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
@@ -39,6 +40,8 @@ export const getArticlesPaginated: (
page: number = 1,
pageSize: number = 10
): Promise<PaginatedArticlesResult> => {
// await new Promise((r) => setTimeout(r, 1000));
const result = await service.getArticlesPaginated(page, pageSize);
if (!result.ok) throw result.error;
return result.value;
@@ -80,6 +83,7 @@ export const updateArticle: (
const result = await service.updateArticle(articleId, article);
if (!result.ok) throw result.error;
revalidatePath('/admin');
return result.value;
}
);
@@ -95,4 +99,5 @@ export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> =
const result = await service.deleteArticle(articleId);
if (!result.ok) throw result.error;
revalidatePath('/admin');
});

View File

@@ -3,7 +3,6 @@ import { TypedResult, wrap } from '@/utils/types/results';
import {
DeleteObjectCommand,
HeadObjectCommand,
ObjectCannedACL,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
@@ -74,7 +73,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 });

View File

@@ -7,17 +7,13 @@ import { TypedResult } from '@/utils/types/results';
const storage: StorageProvider = createStorageProvider();
export const getSignedUrl = async (
export const getPublicUrl = async (
key: string,
storageProvider?: StorageProvider
): Promise<TypedResult<string>> => {
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);
};

View File

@@ -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');
}

View File

@@ -5,6 +5,7 @@ import { NextResponse } from 'next/server';
const isPublic = createRouteMatcher([
'/home(.*)?',
'/about(.*)?',
'/article(.*)?',
'/api/user(.*)?',
]);

View File

@@ -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 (
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground'>
<div className='relative aspect-video w-full overflow-hidden bg-muted'>
{article.coverImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.coverImageUrl}
alt={article.title}
className='h-full w-full object-cover'
/>
) : (
<div className='flex h-full items-center justify-center bg-gradient-to-br from-muted to-muted/60'>
<span className='font-mono text-5xl font-bold text-muted-foreground/20'>
{'{ }'}
</span>
</div>
)}
</div>
<div className='flex flex-1 flex-col gap-3 p-5'>
<div className='flex items-center gap-1.5 text-xs text-muted-foreground'>
<CalendarIcon className='size-3 shrink-0' />
<time dateTime={article.createdAt.toISOString()}>
{formatDate(article.createdAt)}
</time>
</div>
<Link
href={`/article/${article.slug}`}
className='line-clamp-2 text-base font-bold leading-snug tracking-tight hover:text-primary hover:underline underline-offset-4'
>
{article.title}
</Link>
<p className='line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground'>
{article.description}
</p>
<div className='mt-2 flex items-center gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href={`/admin/article/${article.externalId}`}>
<PencilIcon className='size-4' />
Edit
</Link>
</Button>
<DeleteArticleButton
articleId={article.id}
articleTitle={article.title}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,67 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/ui/components/shadcn/table';
const AdminArticleRowSkeleton = () => (
<TableRow>
<TableCell>
<div className='size-12 animate-pulse rounded-md bg-muted' />
</TableCell>
<TableCell>
<div className='space-y-1.5'>
<div className='h-4 w-40 animate-pulse rounded bg-muted' />
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
</div>
</TableCell>
<TableCell>
<div className='space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-muted' />
<div className='h-3 w-3/4 animate-pulse rounded bg-muted' />
</div>
</TableCell>
<TableCell>
<div className='h-3 w-20 animate-pulse rounded bg-muted' />
</TableCell>
<TableCell>
<div className='flex justify-end gap-2'>
<div className='h-7 w-14 animate-pulse rounded-lg bg-muted' />
<div className='h-7 w-16 animate-pulse rounded-lg bg-muted' />
</div>
</TableCell>
</TableRow>
);
export const AdminArticleListSkeleton = ({
skeletonSize,
}: {
skeletonSize: number;
}) => (
<>
<div className='mb-4 h-4 w-32 animate-pulse rounded bg-muted' />
<div className='rounded-lg border border-border'>
<Table>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-16'>Cover</TableHead>
<TableHead className='w-56'>Title</TableHead>
<TableHead>Description</TableHead>
<TableHead className='w-32'>Published</TableHead>
<TableHead className='w-32 text-right'>
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: skeletonSize }).map((_, i) => (
<AdminArticleRowSkeleton key={i} />
))}
</TableBody>
</Table>
</div>
</>
);

View File

@@ -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 (
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
<FileTextIcon className='size-10 text-muted-foreground/40' />
<p className='text-muted-foreground'>
Failed to load articles. Please try again later.
</p>
</div>
);
}
const {
data: articles,
totalPages,
total,
page: currentPage,
} = paginationResult.value;
if (articles.length === 0) {
return (
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
<FileTextIcon className='size-10 text-muted-foreground/40' />
<p className='text-muted-foreground'>No articles yet.</p>
</div>
);
}
return (
<>
<p className='mb-4 text-sm text-muted-foreground'>
{total} article{total === 1 ? '' : 's'} published
</p>
<div className='rounded-lg border border-border'>
<Table>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-16'>Cover</TableHead>
<TableHead className='w-56'>Title</TableHead>
<TableHead>Description</TableHead>
<TableHead className='w-32'>Published</TableHead>
<TableHead className='w-32 text-right'>
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{articles.map((article) => (
<TableRow key={article.externalId}>
<TableCell>
<div className='size-12 shrink-0 overflow-hidden rounded-md bg-muted'>
{article.coverImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.coverImageUrl}
alt=''
className='h-full w-full object-cover'
/>
) : (
<div className='flex h-full items-center justify-center'>
<span className='font-mono text-xs font-bold text-muted-foreground/30'>
{'{}'}
</span>
</div>
)}
</div>
</TableCell>
<TableCell className='whitespace-normal'>
<Link
href={`/article/${article.slug}`}
className='font-medium leading-snug hover:text-primary hover:underline underline-offset-4'
>
{article.title}
</Link>
<p className='mt-0.5 text-xs text-muted-foreground'>
/{article.slug}
</p>
</TableCell>
<TableCell className='whitespace-normal'>
<p className='line-clamp-2 text-sm text-muted-foreground'>
{article.description}
</p>
</TableCell>
<TableCell className='text-sm text-muted-foreground'>
<time
dateTime={article.createdAt.toISOString()}
>
{formatDate(article.createdAt)}
</time>
</TableCell>
<TableCell>
<div className='flex items-center justify-end gap-2'>
<Button
asChild
variant='outline'
size='sm'
>
<Link
href={`/admin/article/${article.externalId}`}
>
<PencilIcon className='size-3.5' />
Edit
</Link>
</Button>
<DeleteArticleButton
articleId={article.id}
articleTitle={article.title}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className='mt-6 flex items-center justify-between gap-4'>
<p className='text-sm text-muted-foreground'>
Page {currentPage} of {totalPages}
</p>
<ArticleListPagination
currentPage={currentPage}
totalPages={totalPages}
baseUrl='/admin'
/>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,33 @@
export const ArticleCardSkeleton = () => (
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card'>
<div className='aspect-video w-full animate-pulse bg-muted' />
<div className='flex flex-1 flex-col gap-3 p-5'>
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
<div className='space-y-1.5'>
<div className='h-4 w-full animate-pulse rounded bg-muted' />
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
</div>
<div className='flex-1 space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-muted' />
<div className='h-3 w-full animate-pulse rounded bg-muted' />
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
</div>
<div className='mt-1 h-3 w-16 animate-pulse rounded bg-muted' />
</div>
</div>
);
export const ArticleListSkeleton = ({
skeletonSize,
}: {
skeletonSize: number;
}) => (
<>
<div className='mb-10 h-4 w-32 animate-pulse rounded bg-muted' />
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: skeletonSize }).map((_, i) => (
<ArticleCardSkeleton key={i} />
))}
</div>
</>
);

View File

@@ -0,0 +1,73 @@
// '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 (
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
<FileTextIcon className='size-10 text-muted-foreground/40' />
<p className='text-muted-foreground'>
Failed to load articles. Please try again later.
</p>
</div>
);
}
const { data: articles, totalPages, total } = paginationResult.value;
return (
<>
<p className='mb-10 text-muted-foreground'>
{total === 0
? 'No articles published yet.'
: `${total} article${total === 1 ? '' : 's'} published`}
</p>
{articles.length === 0 ? (
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
<FileTextIcon className='size-10 text-muted-foreground/40' />
<p className='text-muted-foreground'>
No articles yet. Check back soon!
</p>
</div>
) : (
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
{articles.map((article) => (
<ArticleCard
key={article.externalId}
article={article}
/>
))}
</div>
)}
{totalPages > 1 && (
<div className='mt-12 flex items-center justify-between gap-4'>
<p className='text-sm text-muted-foreground'>
Page {page} of {totalPages}
</p>
<ArticleListPagination
currentPage={page}
totalPages={totalPages}
/>
</div>
)}
</>
);
};

View File

@@ -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 (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant='destructive' size='sm' disabled={isPending}>
<Trash2Icon className='size-4' />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete article?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &ldquo;{articleTitle}
&rdquo;. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant='outline' size='sm'>
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant='destructive'
size='sm'
disabled={isPending}
onClick={handleDelete}
>
{isPending ? 'Deleting…' : 'Delete'}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -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<File | null>(null);
const [coverImageUploading, setCoverImageUploading] = useState(false);
const [contentFile, setContentFile] = useState<File | null>(null);
const coverImageUrlRef = useRef<string | null>(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<z.infer<typeof formSchema>>({
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<typeof formSchema>) => {
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 (
<form
id='form-update-article'
onSubmit={form.handleSubmit(handleFormSubmit)}
>
<FieldGroup className='gap-7'>
<Controller
name='title'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-update-article-title'>
Title
</FieldLabel>
<Input
{...field}
id='form-update-article-title'
aria-invalid={fieldState.invalid}
placeholder='Article title'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='slug'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-update-article-slug'>
Slug
</FieldLabel>
<Input
{...field}
id='form-update-article-slug'
aria-invalid={fieldState.invalid}
placeholder='article-slug'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='description'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-update-article-description'>
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id='form-update-article-description'
placeholder='A simple but nice description of the article here.'
rows={3}
className='min-h-24 resize-none'
aria-invalid={fieldState.invalid}
/>
</InputGroup>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FileUploadField
file={coverImageFile}
onFileChange={handleCoverImageFileChange}
accept='image/*'
validate={validateImageFile}
onFileReject={handleCoverImageReject}
label='Cover image'
description='PNG, JPG, GIF, WebP accepted'
error={form.formState.errors.coverImageUrl?.message}
previewUrl={
coverImageUrl || article.coverImageUrl || undefined
}
isUploading={coverImageUploading}
icon={
<Image
src={ImageLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
<FileUploadField
file={contentFile}
onFileChange={handleContentFileChange}
accept='.md,.markdown,.txt'
validate={validateContentFile}
onFileReject={handleContentFileReject}
label='Markdown content'
description='.md / .markdown / .txt accepted'
error={form.formState.errors.content?.message}
icon={
<Image
src={MarkdownLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
</div>
</FieldGroup>
<div className='flex w-full justify-end'>
<Button type='submit' className='mt-6'>
Update Article
</Button>
</div>
</form>
);
};
export default UpdateArticleForm;

View File

@@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger
data-slot='alert-dialog-trigger'
{...props}
/>
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal
data-slot='alert-dialog-portal'
{...props}
/>
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot='alert-dialog-overlay'
className={cn(
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
children,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot='alert-dialog-content'
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
>
{children}
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-footer'
className={cn('flex justify-end gap-2 pt-4', className)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-base font-semibold', className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
data-slot='alert-dialog-action'
className={cn(className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
data-slot='alert-dialog-cancel'
className={cn(className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -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 (
<div
data-slot='table-container'
className='relative w-full overflow-x-auto'
>
<table
data-slot='table'
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot='table-header'
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot='table-body'
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot='table-footer'
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot='table-row'
className={cn(
'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot='table-head'
className={cn(
'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot='table-cell'
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot='table-caption'
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};