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