292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Plus, Trash2, Pencil, Eye } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { fetchWorkspaces, deleteWorkspace, type Workspace } from '../api/workspaces';
|
|
import { useTranslation } from '../hooks/useTranslation';
|
|
import {
|
|
WORKSPACE_DELETE,
|
|
WORKSPACE_EDIT,
|
|
canWorkspace,
|
|
type WorkspaceRole,
|
|
} from '../lib/permissions';
|
|
import FilterBar from '../components/FilterBar';
|
|
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';
|
|
import { Pagination } from '../components/Pagination';
|
|
import { Modal } from '../components/Modal';
|
|
|
|
const RoleBadge = ({ role }: { role?: WorkspaceRole }) => {
|
|
const { t } = useTranslation();
|
|
if (!role) return null;
|
|
|
|
const styles: Record<string, string> = {
|
|
owner: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400',
|
|
admin: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
|
|
member: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400',
|
|
guest: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400',
|
|
};
|
|
|
|
return (
|
|
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${styles[role] || styles.guest}`}>
|
|
{role ? t.workspace?.roles[role] : "-"}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export default function Workspaces() {
|
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [ordering, setOrdering] = useState('-created_at');
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const [limit, setLimit] = useState(10);
|
|
|
|
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
|
|
const [deleteInput, setDeleteInput] = useState('');
|
|
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation();
|
|
|
|
const orderingOptions = [
|
|
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
|
|
{ value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' },
|
|
{ value: 'name', label: t.ordering?.name || 'Name (A-Z)' },
|
|
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
|
|
{ value: '-updated_at', label: t.ordering?.updatedAtDesc || 'Recently Updated' },
|
|
];
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchQuery, ordering]);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
loadWorkspaces();
|
|
}, 400);
|
|
return () => clearTimeout(timer);
|
|
}, [searchQuery, ordering, currentPage, limit]);
|
|
|
|
const loadWorkspaces = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const params: Record<string, string | number> = {
|
|
limit: limit,
|
|
offset: (currentPage - 1) * limit,
|
|
};
|
|
|
|
if (searchQuery) params.search = searchQuery;
|
|
if (ordering) params.ordering = ordering;
|
|
|
|
const data = await fetchWorkspaces(params as any);
|
|
|
|
const items = Array.isArray(data) ? data : (data?.results || []);
|
|
const count = !Array.isArray(data) && data?.count !== undefined ? data.count : items.length;
|
|
|
|
setWorkspaces(items);
|
|
setTotalItems(count);
|
|
} catch (error) {
|
|
toast.error(t.workspace?.fetchError || 'Error fetching workspaces');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!deleteModal.workspace) return;
|
|
try {
|
|
const deletedId = deleteModal.workspace.id;
|
|
await deleteWorkspace(deletedId);
|
|
|
|
loadWorkspaces();
|
|
|
|
window.dispatchEvent(new CustomEvent('workspace_deleted', {
|
|
detail: { id: deletedId }
|
|
}));
|
|
|
|
toast.success(t.workspace?.deleteSuccess || 'Workspace deleted successfully');
|
|
setDeleteModal({ isOpen: false, workspace: null });
|
|
setDeleteInput('');
|
|
} catch (error) {
|
|
toast.error(t.workspace?.deleteError || 'Failed to delete workspace');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto flex min-h-full max-w-7xl flex-col p-4 md:p-6">
|
|
<div className="flex flex-1 flex-col gap-5">
|
|
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
|
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => navigate('/workspaces/create')}
|
|
size="icon"
|
|
className="shrink-0 shadow-sm"
|
|
title={t.workspace?.createNew || 'Create New'}
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
|
<FilterBar
|
|
searchQuery={searchQuery}
|
|
setSearchQuery={setSearchQuery}
|
|
ordering={ordering}
|
|
setOrdering={setOrdering}
|
|
orderingOptions={orderingOptions}
|
|
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
|
|
/>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<ListPageSkeleton variant="list" />
|
|
) : (
|
|
<div className="flex flex-1 flex-col gap-6">
|
|
<div className="flex flex-1 flex-col gap-4">
|
|
{workspaces.map((workspace) => {
|
|
const canDeleteWorkspace = canWorkspace(workspace.my_role, WORKSPACE_DELETE);
|
|
const canEditWorkspace = canWorkspace(workspace.my_role, WORKSPACE_EDIT);
|
|
|
|
return (
|
|
<Card key={workspace.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
|
|
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between py-4 px-6 gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
{workspace.thumbnail ? (
|
|
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-lg flex items-center justify-center text-sm font-semibold text-slate-700 dark:text-slate-200">
|
|
<img src={workspace.thumbnail} alt={workspace.name} className="h-full w-full object-cover" />
|
|
</div>
|
|
) : (
|
|
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-600 flex items-center justify-center text-sm font-semibold text-slate-700 dark:text-slate-200">
|
|
{workspace.name.trim().charAt(0).toUpperCase() || "W"}
|
|
</div>
|
|
)}
|
|
<CardTitle className="text-lg line-clamp-1">
|
|
{workspace.name}
|
|
</CardTitle>
|
|
<RoleBadge role={workspace.my_role} />
|
|
</div>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-1">
|
|
{workspace.description || t.workspace?.noDescription || 'No description'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{canDeleteWorkspace && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setDeleteModal({ isOpen: true, workspace })}
|
|
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
|
title={t.actions?.delete || 'Delete'}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{canEditWorkspace && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
|
|
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
|
|
title={t.actions?.edit || 'Edit'}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
size="icon"
|
|
onClick={() => navigate(`/workspaces/${workspace.id}`)}
|
|
className="h-8 w-8 hover:bg-blue-700 dark:hover:bg-blue-500 border-none shadow-sm transition-all"
|
|
title={t.actions?.view || 'View'}
|
|
>
|
|
<Eye className="h-4 w-4 rtl:-scale-x-100" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
|
|
{workspaces.length === 0 && (
|
|
<div className="flex flex-1 rounded-3xl border-2 border-dashed border-slate-200 bg-white py-16 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
<div className="flex flex-col items-center justify-center">
|
|
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.workspace?.emptyState || 'No workspaces found'}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalCount={totalItems}
|
|
limit={limit}
|
|
onPageChange={setCurrentPage}
|
|
onLimitChange={setLimit}
|
|
pageSizeOptions={[10, 20, 50]}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{deleteModal.workspace && (
|
|
<Modal
|
|
isOpen={deleteModal.isOpen}
|
|
onClose={() => {
|
|
setDeleteModal({ isOpen: false, workspace: null });
|
|
setDeleteInput('');
|
|
}}
|
|
title={t.workspace?.deleteTitle || 'Delete Workspace'}
|
|
maxWidth="max-w-md"
|
|
footer={
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setDeleteModal({ isOpen: false, workspace: null });
|
|
setDeleteInput('');
|
|
}}
|
|
className="rounded-xl font-semibold"
|
|
>
|
|
{t.actions?.cancel || 'Cancel'}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={deleteInput !== deleteModal.workspace.name}
|
|
onClick={confirmDelete}
|
|
className="rounded-xl font-semibold"
|
|
>
|
|
{t.actions?.delete || 'Delete'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
|
|
{t.workspace?.deleteWarning || 'To confirm deletion, please type the workspace name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.workspace.name}</strong>
|
|
</p>
|
|
|
|
<Input
|
|
type="text"
|
|
value={deleteInput}
|
|
onChange={(e) => setDeleteInput(e.target.value)}
|
|
placeholder={deleteModal.workspace.name}
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|