refactor(frontend): share list empty state card
This commit is contained in:
25
src/components/EmptyStateCard.tsx
Normal file
25
src/components/EmptyStateCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface EmptyStateCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EmptyStateCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
className = "",
|
||||
}: EmptyStateCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-1 flex-col justify-center rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900 ${className}`}
|
||||
>
|
||||
<Icon className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{title}</h3>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { getClients } from "../api/clients"
|
||||
import CreateClientModal from "../components/CreateClientModal"
|
||||
import EditClientModal from "../components/EditClientModal"
|
||||
import DeleteClientModal from "../components/DeleteClientModal"
|
||||
import EmptyStateCard from "../components/EmptyStateCard"
|
||||
import FilterBar from "../components/FilterBar"
|
||||
import { ListPageSkeleton } from "../components/ListPageSkeleton"
|
||||
import { Button } from "../components/ui/button"
|
||||
@@ -169,13 +170,11 @@ export default function Clients() {
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{clients.length === 0 ? (
|
||||
<div className="flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.clients.noClients}</h3>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
{searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyStateCard
|
||||
icon={Building2}
|
||||
title={t.clients.noClients}
|
||||
description={searchQuery ? t.clients.noClientsSearch : t.clients.noClientsAdd}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{clients.map((client) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProjectEditModal } from "../components/projects/ProjectEditModal";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
import { Plus, Archive, Building2, Pencil, Trash2, X } from "lucide-react";
|
||||
|
||||
import EmptyStateCard from "../components/EmptyStateCard";
|
||||
import FilterBar from "../components/FilterBar";
|
||||
import { ListPageSkeleton } from "../components/ListPageSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -329,11 +330,11 @@ export const Projects: React.FC = () => {
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<Building2 className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.projects?.emptyState || 'No projects found'}</h3>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">{t.projects?.noProjectsSearch}</p>
|
||||
</div>
|
||||
<EmptyStateCard
|
||||
icon={Building2}
|
||||
title={t.projects?.emptyState || "No projects found"}
|
||||
description={t.projects?.noProjectsSearch || t.projects?.emptyState || "No projects found"}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{projects.map((project) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Edit2, Plus, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createTag, deleteTag, getTags, type Tag, updateTag } from "../api/tags";
|
||||
import EmptyStateCard from "../components/EmptyStateCard";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
@@ -193,82 +194,85 @@ export default function Tags() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<ListPageSkeleton variant="dense-grid" />
|
||||
<ListPageSkeleton variant="list" />
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{tags.map((tag) => {
|
||||
const canDeleteTag = canDeleteWorkspaceResource({
|
||||
workspaceRole,
|
||||
currentUserId: user?.id,
|
||||
createdById: tag.created_by?.id,
|
||||
});
|
||||
return (
|
||||
<Card key={tag.id} className="overflow-hidden shadow-sm dark:border-slate-700 dark:bg-slate-800">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="h-9 w-9 shrink-0 rounded-xl border border-slate-200 dark:border-slate-700"
|
||||
style={{ backgroundColor: tag.color || DEFAULT_COLOR }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base text-slate-900 dark:text-white">{tag.name}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
{tags.length === 0 ? (
|
||||
<EmptyStateCard
|
||||
icon={TagIcon}
|
||||
title={t.tags?.emptyState || "No tags found"}
|
||||
description={searchQuery ? t.tags?.noTagsSearch : t.tags?.emptyState || "No tags found"}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{tags.map((tag) => {
|
||||
const canDeleteTag = canDeleteWorkspaceResource({
|
||||
workspaceRole,
|
||||
currentUserId: user?.id,
|
||||
createdById: tag.created_by?.id,
|
||||
});
|
||||
|
||||
{(canEditTag || canDeleteTag) && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canEditTag && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditModal(tag)}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
title={t.actions?.edit || "Edit"}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDeleteTag && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteModal({ isOpen: true, tag })}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title={t.actions?.delete || "Delete"}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Card
|
||||
key={tag.id}
|
||||
className="flex flex-col text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<CardContent className="flex h-full flex-col justify-between gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-9 w-9 shrink-0 rounded-lg border border-slate-200 dark:border-slate-700"
|
||||
style={{ backgroundColor: tag.color || DEFAULT_COLOR }}
|
||||
/>
|
||||
<CardTitle className="text-lg line-clamp-1">{tag.name}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 && (
|
||||
<div className="col-span-full flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<TagIcon className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.tags?.emptyState || "No tags found"}</h3>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
{searchQuery ? t.tags?.noTagsSearch : t.tags?.emptyState}
|
||||
</p>
|
||||
{(canEditTag || canDeleteTag) && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canDeleteTag && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteModal({ isOpen: true, tag })}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title={t.actions?.delete || "Delete"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canEditTag && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditModal(tag)}
|
||||
className="h-8 w-8 text-slate-400 hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
title={t.actions?.edit || "Edit"}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={(page) => updateListParams({ page })}
|
||||
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={(page) => updateListParams({ page })}
|
||||
onLimitChange={(pageLimit) => updateListParams({ limit: pageLimit, page: 1 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
canWorkspace,
|
||||
type WorkspaceRole,
|
||||
} from '../lib/permissions';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import { ListPageSkeleton } from '../components/ListPageSkeleton';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import EmptyStateCard from '../components/EmptyStateCard';
|
||||
import { ListPageSkeleton } from '../components/ListPageSkeleton';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Card, CardContent, CardTitle } from '../components/ui/card';
|
||||
@@ -229,15 +230,13 @@ export default function Workspaces() {
|
||||
);
|
||||
})}
|
||||
|
||||
{workspaces.length === 0 && (
|
||||
<div className="flex flex-col flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white p-12 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<LayoutDashboard className="mx-auto mb-3 h-12 w-12 text-slate-300 dark:text-slate-700" />
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">{t.workspace.noWorkspace}</h3>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
{searchQuery ? t.workspace.noWorkspaceSearch : t.workspace?.emptyState}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{workspaces.length === 0 && (
|
||||
<EmptyStateCard
|
||||
icon={LayoutDashboard}
|
||||
title={t.workspace.noWorkspace}
|
||||
description={searchQuery ? t.workspace.noWorkspaceSearch : t.workspace?.emptyState || t.workspace.noWorkspace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
|
||||
Reference in New Issue
Block a user