feat: add table components and admin article list with skeleton loading
Some checks failed
Build and Test / run-test (20.x) (push) Failing after 1m30s
Some checks failed
Build and Test / run-test (20.x) (push) Failing after 1m30s
This commit is contained in:
40
src/app/(pages)/admin/article/[externalId]/page.tsx
Normal file
40
src/app/(pages)/admin/article/[externalId]/page.tsx
Normal 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;
|
||||||
14
src/app/(pages)/admin/article/create/page.tsx
Normal file
14
src/app/(pages)/admin/article/create/page.tsx
Normal 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;
|
||||||
@@ -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 (
|
return (
|
||||||
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
||||||
<div className='rounded-lg border border-border p-6'>
|
<div className='mb-8 flex items-center justify-between border-b border-border pb-6'>
|
||||||
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
|
<h1 className='text-2xl font-bold'>Articles</h1>
|
||||||
<CreateArticleForm />
|
<Button asChild>
|
||||||
|
<Link href='/admin/article/create'>
|
||||||
|
<PlusIcon className='size-4' />
|
||||||
|
Create Article
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Suspense
|
||||||
|
fallback={<AdminArticleListSkeleton skeletonSize={PAGE_SIZE} />}
|
||||||
|
>
|
||||||
|
<AdminArticleList
|
||||||
|
searchParams={searchParams}
|
||||||
|
defaultPageSize={PAGE_SIZE}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,6 @@ import { ArticleList } from '@/ui/components/internal/article/article-list';
|
|||||||
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
|
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const PAGE_SIZE = 4;
|
const PAGE_SIZE = 4;
|
||||||
|
|
||||||
type HomeProps = {
|
type HomeProps = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as service from '@/lib/feature/article/article.service';
|
|||||||
import { getSessionData } from '@/lib/session/session-storage';
|
import { getSessionData } from '@/lib/session/session-storage';
|
||||||
import { TypedResult, wrap } from '@/utils/types/results';
|
import { TypedResult, wrap } from '@/utils/types/results';
|
||||||
import { UUIDv4 } from '@/utils/types/uuid';
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
export const getArticleByExternalId: (
|
export const getArticleByExternalId: (
|
||||||
externalId: UUIDv4
|
externalId: UUIDv4
|
||||||
@@ -82,6 +83,7 @@ export const updateArticle: (
|
|||||||
|
|
||||||
const result = await service.updateArticle(articleId, article);
|
const result = await service.updateArticle(articleId, article);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
|
revalidatePath('/admin');
|
||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -97,4 +99,5 @@ export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> =
|
|||||||
|
|
||||||
const result = await service.deleteArticle(articleId);
|
const result = await service.deleteArticle(articleId);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
|
revalidatePath('/admin');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { getSessionData } from '@/lib/session/session-storage';
|
|||||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const isPublic = createRouteMatcher([
|
const isPublic = createRouteMatcher([
|
||||||
'/home(.*)?',
|
'/home(.*)?',
|
||||||
'/about(.*)?',
|
'/about(.*)?',
|
||||||
|
|||||||
72
src/ui/components/internal/article/admin-article-card.tsx
Normal file
72
src/ui/components/internal/article/admin-article-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
172
src/ui/components/internal/article/admin-article-list.tsx
Normal file
172
src/ui/components/internal/article/admin-article-list.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,20 +4,6 @@ import { ArticleCard } from '@/ui/components/internal/article-card';
|
|||||||
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type ArticleListProps = {
|
type ArticleListProps = {
|
||||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||||||
defaultPageSize: number;
|
defaultPageSize: number;
|
||||||
|
|||||||
84
src/ui/components/internal/article/delete-article-button.tsx
Normal file
84
src/ui/components/internal/article/delete-article-button.tsx
Normal 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 “{articleTitle}
|
||||||
|
”. 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
295
src/ui/components/internal/update-article-form.tsx
Normal file
295
src/ui/components/internal/update-article-form.tsx
Normal 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;
|
||||||
163
src/ui/components/shadcn/alert-dialog.tsx
Normal file
163
src/ui/components/shadcn/alert-dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
115
src/ui/components/shadcn/table.tsx
Normal file
115
src/ui/components/shadcn/table.tsx
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user