refactor(frontend): share list empty state card

This commit is contained in:
2026-04-29 12:16:59 +03:30
parent 013c78a46d
commit a2bc1aa91f
5 changed files with 120 additions and 92 deletions

View 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>
);
}

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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

View File

@@ -10,8 +10,9 @@ import {
canWorkspace, canWorkspace,
type WorkspaceRole, type WorkspaceRole,
} from '../lib/permissions'; } from '../lib/permissions';
import FilterBar from '../components/FilterBar'; import FilterBar from '../components/FilterBar';
import { ListPageSkeleton } from '../components/ListPageSkeleton'; import EmptyStateCard from '../components/EmptyStateCard';
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';
import { Card, CardContent, CardTitle } from '../components/ui/card'; import { Card, CardContent, CardTitle } from '../components/ui/card';
@@ -229,15 +230,13 @@ 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>
<Pagination <Pagination