feat(improvement): add pagination to endpoints and pages + sync navbar when data changes
This commit is contained in:
@@ -1,30 +1,49 @@
|
||||
import { API_BASE_URL } from "../config/constants";
|
||||
import { API_BASE_URL } from "../config/constants"
|
||||
|
||||
export const authFetch = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const token = localStorage.getItem("accessToken");
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const token = localStorage.getItem("accessToken")
|
||||
const isFormData = options.body instanceof FormData
|
||||
|
||||
const headers: HeadersInit = {
|
||||
...(!isFormData && { "Content-Type": "application/json" }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Safely join URLs preventing double slashes (e.g., "http://api.com//api/..." -> "http://api.com/api/...")
|
||||
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "");
|
||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
const url = `${cleanBaseUrl}${cleanEndpoint}`;
|
||||
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
||||
const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||
const url = `${cleanBaseUrl}${cleanEndpoint}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
window.location.href = "/auth";
|
||||
localStorage.removeItem("accessToken")
|
||||
localStorage.removeItem("refreshToken")
|
||||
window.location.href = "/auth"
|
||||
return response
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
const originalJson = response.json.bind(response)
|
||||
response.json = async () => {
|
||||
const data = await originalJson()
|
||||
|
||||
if (data && typeof data === "object" && "items" in data && "pages_count" in data) {
|
||||
return {
|
||||
count: data.total_items || 0,
|
||||
results: data.items || [],
|
||||
_meta: {
|
||||
pages_count: data.pages_count,
|
||||
items_per_page: data.items_per_page,
|
||||
current_page: data.current_page
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { authFetch } from "./client";
|
||||
import { type PaginatedClientList } from "../types/client";
|
||||
|
||||
|
||||
export const getClients = async (workspaceId: string, search: string = "", ordering: string = "") => {
|
||||
const queryParams = new URLSearchParams({ workspace: workspaceId });
|
||||
export const getClients = async (
|
||||
workspaceId: string,
|
||||
search: string = "",
|
||||
ordering: string = "",
|
||||
limit: number = 10,
|
||||
offset: number = 0
|
||||
) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
workspace: workspaceId,
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString()
|
||||
});
|
||||
|
||||
if (search) queryParams.append("search", search);
|
||||
if (ordering) queryParams.append("ordering", ordering);
|
||||
@@ -54,7 +62,6 @@ export const deleteClient = async (id: string) => {
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to delete client");
|
||||
}
|
||||
|
||||
// DELETE requests often return 204 No Content, which throws an error on .json()
|
||||
if (response.status === 204) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/api/workspaces.ts
|
||||
import { authFetch } from "./client";
|
||||
|
||||
export interface Workspace {
|
||||
@@ -10,7 +9,31 @@ export interface Workspace {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const fetchWorkspaces = async (params?: Record<string, string>): Promise<Workspace[]> => {
|
||||
export interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
export interface WorkspaceMembership {
|
||||
id: string;
|
||||
workspace: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
role: 'owner' | 'admin' | 'member' | 'guest';
|
||||
is_active: boolean;
|
||||
joined_at?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
export const fetchWorkspaces = async (params?: Record<string, string>): Promise<PaginatedResponse<Workspace>> => {
|
||||
const query = params ? new URLSearchParams(params).toString() : '';
|
||||
const url = `/api/workspaces/${query ? `?${query}` : ''}`;
|
||||
const response = await authFetch(url);
|
||||
@@ -20,7 +43,17 @@ export const fetchWorkspaces = async (params?: Record<string, string>): Promise<
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.results || data;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return { count: data.length, next: null, previous: null, results: data };
|
||||
}
|
||||
|
||||
return {
|
||||
count: data.count || data.results?.length || 0,
|
||||
next: data.next || null,
|
||||
previous: data.previous || null,
|
||||
results: data.results || data
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspace = async (id: string): Promise<Workspace> => {
|
||||
@@ -65,12 +98,24 @@ export const deleteWorkspace = async (id: string): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchWorkspaceMemberships = async (workspaceId: string) => {
|
||||
const response = await authFetch(`/api/workspace-memberships/?workspace=${workspaceId}`);
|
||||
export const fetchWorkspaceMemberships = async (params?: Record<string, string>): Promise<PaginatedResponse<WorkspaceMembership>> => {
|
||||
const queryParams = new URLSearchParams((params || {}));
|
||||
const response = await authFetch(`/api/workspace-memberships/?${queryParams.toString()}`);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to fetch workspace memberships");
|
||||
|
||||
const data = await response.json();
|
||||
return data.results || data;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return { count: data.length, next: null, previous: null, results: data };
|
||||
}
|
||||
|
||||
return {
|
||||
count: data.count || data.results?.length || 0,
|
||||
next: data.next || null,
|
||||
previous: data.previous || null,
|
||||
results: data.results || data
|
||||
};
|
||||
};
|
||||
|
||||
export const addWorkspaceMembership = async (data: { workspace: string; user: string; role: string }) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { WorkspaceSelector } from "./WorkspaceSelector"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function Navbar() {
|
||||
const { t, lang, setLang } = useTranslation()
|
||||
const { t, lang, setLanguage } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false)
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
@@ -23,6 +23,17 @@ export function Navbar() {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleProfileUpdated = ((e: CustomEvent) => {
|
||||
if (e.detail) {
|
||||
setUser((prev: any) => prev ? { ...prev, ...e.detail } : e.detail);
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('profile_updated', handleProfileUpdated);
|
||||
return () => window.removeEventListener('profile_updated', handleProfileUpdated);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
@@ -83,8 +94,8 @@ export function Navbar() {
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = isFa ? 'en' : 'fa'
|
||||
if (setLang) {
|
||||
setLang(newLang)
|
||||
if (setLanguage) {
|
||||
setLanguage(newLang)
|
||||
} else {
|
||||
localStorage.setItem('language', newLang)
|
||||
window.location.reload()
|
||||
@@ -126,7 +137,14 @@ export function Navbar() {
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className={`absolute ${isFa ? 'left-0' : 'right-0'} mt-2 w-56 rounded-lg bg-white dark:bg-slate-900 shadow-lg ring-1 ring-black ring-opacity-5 border border-slate-200 dark:border-slate-800 z-50 py-2 overflow-hidden`}>
|
||||
<div dir='rtl' className={`absolute ${isFa ? 'left-0' : 'right-0'} mt-2 w-56 rounded-lg bg-white dark:bg-slate-900 shadow-lg ring-1 ring-black ring-opacity-5 border border-slate-200 dark:border-slate-800 z-50 py-2 overflow-hidden`}>
|
||||
<div className="px-4 py-2 mb-2 border-b border-slate-100 dark:border-slate-800">
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-slate-400 truncate">
|
||||
{user.first_name || user.last_name
|
||||
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
|
||||
: user.email}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { navigate("/profile"); setIsDropdownOpen(false); }}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
|
||||
@@ -1,17 +1,84 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { useTranslation } from "../hooks/useTranslation";
|
||||
import { Check, ChevronDown, Plus, Briefcase, Settings } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchWorkspaces, type Workspace } from "../api/workspaces";
|
||||
import { InfiniteScroll } from "./InfiniteScroll";
|
||||
|
||||
export const WorkspaceSelector: React.FC = () => {
|
||||
const { workspaces, activeWorkspace, setActiveWorkspace } = useWorkspace();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
const { t, lang } = useTranslation();
|
||||
const isFa = lang === "fa";
|
||||
|
||||
const [localWorkspaces, setLocalWorkspaces] = useState<Workspace[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const LIMIT = 10;
|
||||
|
||||
const refreshWorkspacesList = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchWorkspaces({ offset: 0, limit: LIMIT });
|
||||
const items = Array.isArray(res) ? res : (res?.results || []);
|
||||
setLocalWorkspaces(items);
|
||||
setOffset(items.length);
|
||||
setHasMore(!Array.isArray(res) ? !!res?.next : items.length >= LIMIT);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWorkspaceDeleted = ((e: CustomEvent) => {
|
||||
if (activeWorkspace?.id === e.detail?.id) {
|
||||
setActiveWorkspace(null);
|
||||
}
|
||||
refreshWorkspacesList();
|
||||
}) as EventListener;
|
||||
|
||||
const handleWorkspaceCreated = ((e: CustomEvent) => {
|
||||
if (e.detail) {
|
||||
setActiveWorkspace(e.detail);
|
||||
}
|
||||
refreshWorkspacesList();
|
||||
}) as EventListener;
|
||||
|
||||
const handleWorkspaceEdited = ((e: CustomEvent) => {
|
||||
// آپدیت نام کارتابل در نوبار در صورتی که کارتابل فعال ویرایش شده باشد
|
||||
if (activeWorkspace?.id === e.detail?.id) {
|
||||
setActiveWorkspace({
|
||||
...activeWorkspace,
|
||||
name: e.detail.name,
|
||||
description: e.detail.description
|
||||
});
|
||||
}
|
||||
refreshWorkspacesList();
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener("workspace_deleted", handleWorkspaceDeleted);
|
||||
window.addEventListener("workspace_created", handleWorkspaceCreated);
|
||||
window.addEventListener("workspace_edited", handleWorkspaceEdited);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("workspace_deleted", handleWorkspaceDeleted);
|
||||
window.removeEventListener("workspace_created", handleWorkspaceCreated);
|
||||
window.removeEventListener("workspace_edited", handleWorkspaceEdited);
|
||||
};
|
||||
}, [activeWorkspace, setActiveWorkspace, refreshWorkspacesList]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctxList = Array.isArray(workspaces) ? workspaces : (workspaces as any)?.results || [];
|
||||
setLocalWorkspaces(ctxList);
|
||||
setOffset(ctxList.length);
|
||||
if (ctxList.length > 0 && ctxList.length < LIMIT) {
|
||||
setHasMore(false);
|
||||
}
|
||||
}, [workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
@@ -22,9 +89,35 @@ export const WorkspaceSelector: React.FC = () => {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const loadMoreWorkspaces = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
setIsLoadingMore(true);
|
||||
|
||||
try {
|
||||
const res = await fetchWorkspaces({ offset, limit: LIMIT });
|
||||
const newItems = Array.isArray(res) ? res : (res?.results || []);
|
||||
const nextUrl = !Array.isArray(res) ? res?.next : null;
|
||||
|
||||
setLocalWorkspaces((prev) => {
|
||||
const existingIds = new Set(prev.map(w => w.id));
|
||||
const uniqueNewItems = newItems.filter((w: Workspace) => !existingIds.has(w.id));
|
||||
return [...prev, ...uniqueNewItems];
|
||||
});
|
||||
|
||||
setOffset((prev) => prev + LIMIT);
|
||||
|
||||
if (!nextUrl && newItems.length < LIMIT) {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [offset, hasMore, isLoadingMore]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Selector Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
@@ -39,7 +132,6 @@ export const WorkspaceSelector: React.FC = () => {
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute top-full mt-2 w-64 bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-800 py-2 z-40 ${
|
||||
@@ -50,8 +142,13 @@ export const WorkspaceSelector: React.FC = () => {
|
||||
{t.workspace?.title || "Workspaces"}
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{workspaces.map((ws) => (
|
||||
<div className="max-h-60 overflow-y-auto" id="workspace-scroll-container">
|
||||
<InfiniteScroll
|
||||
onLoadMore={loadMoreWorkspaces}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMore}
|
||||
>
|
||||
{localWorkspaces.map((ws: Workspace) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
type="button"
|
||||
@@ -72,6 +169,7 @@ export const WorkspaceSelector: React.FC = () => {
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-slate-200 dark:bg-slate-800 my-2" />
|
||||
@@ -96,11 +194,10 @@ export const WorkspaceSelector: React.FC = () => {
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{ t.workspace?.manage || "Manage Workspaces" }
|
||||
{t.workspace?.manage || "Manage Workspaces"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { api } from '../api';
|
||||
import { getUserProfile } from '../api/users';
|
||||
import { fetchWorkspaces } from '../api/workspaces';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -30,23 +31,26 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const [userRes, wsRes] = await Promise.all([
|
||||
api.get('/api/users/me/'),
|
||||
api.get('/api/workspaces/')
|
||||
const [userData, wsData] = await Promise.all([
|
||||
getUserProfile(),
|
||||
fetchWorkspaces() // fetchWorkspaces({ limit: 50 })
|
||||
]);
|
||||
|
||||
setUser(userRes.data);
|
||||
setWorkspaces(wsRes.data.results || wsRes.data);
|
||||
setUser(userData);
|
||||
|
||||
const workspacesList = Array.isArray(wsData.data) ? wsData.data : (wsData?.data?.results || []);
|
||||
|
||||
setWorkspaces(workspacesList);
|
||||
|
||||
const savedWsId = localStorage.getItem('active_workspace');
|
||||
const targetWs = wsRes.data.find((w: Workspace) => w.id === savedWsId) || wsRes.data[0];
|
||||
const targetWs = workspacesList.find((w: Workspace) => w.id === savedWsId) || workspacesList[0];
|
||||
|
||||
if (targetWs) {
|
||||
setActiveWorkspace(targetWs);
|
||||
localStorage.setItem('active_workspace', targetWs.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error("Failed to fetch initial context data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -38,12 +38,14 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const loadWorkspaces = async () => {
|
||||
try {
|
||||
const data = await fetchWorkspaces()
|
||||
const response = await fetchWorkspaces()
|
||||
|
||||
const data = Array.isArray(response) ? response : (response?.results || [])
|
||||
setWorkspaces(data)
|
||||
|
||||
if (data.length > 0) {
|
||||
const storedId = localStorage.getItem("activeWorkspaceId")
|
||||
const stored = data.find((w) => w.id === storedId)
|
||||
const stored = data.find((w: Workspace) => w.id === storedId)
|
||||
if (stored) {
|
||||
setActiveWorkspaceState(stored)
|
||||
} else {
|
||||
@@ -69,7 +71,7 @@ export const WorkspaceProvider = ({ children }: { children: ReactNode }) => {
|
||||
const addWorkspace = async (name: string) => {
|
||||
try {
|
||||
setIsCreatingFirst(true)
|
||||
const newWs = await createWorkspace(name)
|
||||
const newWs = await createWorkspace({ name, description: "" })
|
||||
setWorkspaces((prev) => [...prev, newWs])
|
||||
setActiveWorkspace(newWs)
|
||||
toast.success(t.workspace?.createSuccess || "Workspace created!")
|
||||
|
||||
@@ -169,6 +169,7 @@ export const en = {
|
||||
successCreate: "Workspace created successfully.",
|
||||
errorCreate: "Failed to create workspace.",
|
||||
toast: {
|
||||
successCreate: "Workspace created successfully.",
|
||||
successUpdate: "Workspace updated successfully.",
|
||||
errorUpdate: "Failed to update workspace.",
|
||||
successAdd: "Member added successfully.",
|
||||
@@ -208,7 +209,17 @@ export const en = {
|
||||
createFailed: "Failed to create client",
|
||||
fetchFailed: "Failed to fetch clients",
|
||||
updateFailed: "Failed to update client",
|
||||
deleteFailed: "Failed to delete client"
|
||||
}
|
||||
deleteFailed: "Failed to delete client",
|
||||
},
|
||||
},
|
||||
|
||||
pagination: {
|
||||
perPage: "per page",
|
||||
showing: "Showing",
|
||||
to: "to",
|
||||
of: "of",
|
||||
previous: "Previous",
|
||||
page: "Page",
|
||||
next: "Next",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const fa = {
|
||||
otpPlaceholder: "کد ۶ رقمی",
|
||||
verifyAndContinue: "تایید و ادامه",
|
||||
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
||||
brandingQuote: "زمان و فضاهای کاری خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
||||
brandingQuote: "زمان و ورکاسپیسها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
||||
toasts: {
|
||||
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
||||
verifySent: "کد تایید ارسال شد!",
|
||||
@@ -105,9 +105,9 @@ export const fa = {
|
||||
darkMode: "حالت تاریک",
|
||||
|
||||
workspace: {
|
||||
title: "مدیریت فضاهای کاری",
|
||||
title: "مدیریت ورکاسپیسها",
|
||||
createNew: "ایجاد فضای کاری جدید",
|
||||
manage: "مدیریت فضاهای کاری",
|
||||
manage: "مدیریت ورکاسپیسها",
|
||||
nameLabel: "نام فضای کاری",
|
||||
namePlaceholder: "نام فضای کاری را وارد کنید",
|
||||
descriptionLabel: "توضیحات",
|
||||
@@ -126,7 +126,7 @@ export const fa = {
|
||||
loading: "در حال بارگذاری...",
|
||||
confirmDelete: "آیا از حذف این فضای کاری اطمینان دارید؟",
|
||||
deleteError: "خطا در حذف فضای کاری",
|
||||
subtitle: "فضاهای کاری خود را مدیریت کنید",
|
||||
subtitle: "ورکاسپیسهای خود را مدیریت کنید",
|
||||
noDescription: "بدون توضیحات",
|
||||
view: "مشاهده",
|
||||
edit: "ویرایش",
|
||||
@@ -137,7 +137,7 @@ export const fa = {
|
||||
detailTitle: "جزئیات فضای کاری",
|
||||
save: "ذخیره",
|
||||
create: "ایجاد",
|
||||
back: "بازگشت به فضاهای کاری",
|
||||
back: "بازگشت به ورکاسپیسها",
|
||||
roleLabel: "نقش شما",
|
||||
roles: {
|
||||
owner: "مالک",
|
||||
@@ -152,7 +152,7 @@ export const fa = {
|
||||
noUsersFound: "کاربری یافت نشد",
|
||||
selectRole: "انتخاب نقش",
|
||||
add: "افزودن",
|
||||
searchPlaceholder: "جستوجوی فضاهای کاری...",
|
||||
searchPlaceholder: "جستوجوی ورکاسپیسها...",
|
||||
orderByUpdatedDesc: "آخرین ویرایش",
|
||||
orderByCreatedDesc: "جدیدترین",
|
||||
orderByCreatedAsc: "قدیمیترین",
|
||||
@@ -168,6 +168,7 @@ export const fa = {
|
||||
confirmDeleteTitle: "حذف عضو",
|
||||
confirmDeleteMessage: "آیا مطمئن هستید که میخواهید این عضو را از فضای کاری حذف کنید؟",
|
||||
toast: {
|
||||
successCreate: "فضای کاری با موفقیت ساخته شد.",
|
||||
successUpdate: "فضای کاری با موفقیت بهروزرسانی شد.",
|
||||
errorUpdate: "بهروزرسانی فضای کاری با خطا مواجه شد.",
|
||||
successAdd: "کاربر جدید با موفقیت به فضای کاری افزوده شد.",
|
||||
@@ -209,7 +210,17 @@ export const fa = {
|
||||
createFailed: "خطا در ایجاد مشتری",
|
||||
fetchFailed: "خطا در دریافت لیست مشتریان",
|
||||
updateFailed: "خطا در ویرایش مشتری",
|
||||
deleteFailed: "خطا در حذف مشتری"
|
||||
}
|
||||
deleteFailed: "خطا در حذف مشتری",
|
||||
},
|
||||
},
|
||||
|
||||
pagination: {
|
||||
perPage: "در هر صفحه",
|
||||
showing: "نمایش",
|
||||
to: "تا",
|
||||
of: "از",
|
||||
previous: "قبلی",
|
||||
page: "صفحه",
|
||||
next: "بعدی",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,12 +10,18 @@ import DeleteClientModal from "../components/DeleteClientModal"
|
||||
import FilterBar from "../components/FilterBar"
|
||||
import { Button } from "../components/ui/button"
|
||||
import { Card } from "../components/ui/card"
|
||||
import { Pagination } from "../components/Pagination"
|
||||
|
||||
export default function Clients() {
|
||||
const { activeWorkspace } = useWorkspace()
|
||||
const [clients, setClients] = useState<Client[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Pagination States
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [limit, setLimit] = useState(10)
|
||||
|
||||
// Filter States
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("")
|
||||
@@ -37,6 +43,11 @@ export default function Clients() {
|
||||
{ value: "-updated_at", label: isFa ? "اخیراً بروزرسانی شده" : "Recently Updated" },
|
||||
]
|
||||
|
||||
// بازگشت به صفحه اول در صورت تغییر فیلتر یا جستجو
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [debouncedSearch, ordering])
|
||||
|
||||
// Debounce search input to avoid spamming the API
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
@@ -53,8 +64,14 @@ export default function Clients() {
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering)
|
||||
setClients(data?.results || (Array.isArray(data) ? data : []))
|
||||
const offset = (currentPage - 1) * limit
|
||||
const data: any = await getClients(activeWorkspace.id, debouncedSearch, ordering, limit, offset)
|
||||
|
||||
const items = data?.results || (Array.isArray(data) ? data : [])
|
||||
const count = data?.count !== undefined ? data.count : items.length
|
||||
|
||||
setClients(items)
|
||||
setTotalItems(count)
|
||||
} catch (error) {
|
||||
console.error(t.clients.errors.fetchFailed, error)
|
||||
setClients([])
|
||||
@@ -76,10 +93,9 @@ export default function Clients() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch when workspace, debounced search, or ordering changes
|
||||
useEffect(() => {
|
||||
fetchClientsList()
|
||||
}, [activeWorkspace?.id, debouncedSearch, ordering])
|
||||
}, [activeWorkspace?.id, debouncedSearch, ordering, currentPage, limit])
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return (
|
||||
@@ -90,7 +106,7 @@ export default function Clients() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto min-h-[calc(100vh-73px)]">
|
||||
<div className="flex flex-col p-6 max-w-6xl mx-auto min-h-[calc(100vh-73px)]">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.clients.title}</h1>
|
||||
@@ -114,7 +130,7 @@ export default function Clients() {
|
||||
searchPlaceholder={t.clients.searchPlaceholder}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden dark:bg-slate-900 dark:border-slate-800">
|
||||
<Card className="overflow-hidden dark:bg-slate-900 dark:border-slate-800 mb-6">
|
||||
<div className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center p-12 text-slate-500">
|
||||
@@ -169,6 +185,16 @@ export default function Clients() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!isLoading && clients.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateClientModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
|
||||
@@ -114,6 +114,8 @@ export default function Profile() {
|
||||
const updatedUser = await updateUserProfile(payload)
|
||||
setUser(prev => prev ? { ...prev, ...updatedUser } : updatedUser)
|
||||
setIsEditing(false)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('profile_updated', { detail: updatedUser }));
|
||||
toast.success(t.profile.toasts.successEdit)
|
||||
} catch (error) {
|
||||
toast.error(t.profile.toasts.error)
|
||||
@@ -131,6 +133,8 @@ export default function Profile() {
|
||||
setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture } : response)
|
||||
setIsPicModalOpen(false)
|
||||
setSelectedFile(null)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('profile_updated', { detail: { profile_picture: response.profile_picture || null } }));
|
||||
toast.success(t.profile.toasts.successImage)
|
||||
} catch (error) {
|
||||
toast.error(t.profile.toasts.error)
|
||||
@@ -146,6 +150,7 @@ export default function Profile() {
|
||||
const response = await removeProfilePicture()
|
||||
setUser(prev => prev ? { ...prev, profile_picture: response.profile_picture || null } : response)
|
||||
setIsPicModalOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('profile_updated', { detail: { profile_picture: response.profile_picture || null } }));
|
||||
toast.success(t.profile.toasts.successRemoveImage)
|
||||
} catch (error) {
|
||||
toast.error(t.profile.toasts.error)
|
||||
|
||||
@@ -101,9 +101,13 @@ export default function WorkspaceCreate() {
|
||||
description,
|
||||
members: members.map(m => ({ user_id: m.user.id, role: m.role }))
|
||||
};
|
||||
const newWorkspace = await createWorkspace({ name, description });
|
||||
|
||||
await createWorkspace(payload);
|
||||
window.dispatchEvent(new CustomEvent('workspace_created', {
|
||||
detail: newWorkspace
|
||||
}));
|
||||
toast.success(t.workspace?.toast?.successCreate || "Workspace created successfully.");
|
||||
|
||||
navigate('/workspaces');
|
||||
} catch (error) {
|
||||
toast.error(t.workspace?.toast?.errorCreate || "Failed to create workspace.");
|
||||
|
||||
@@ -15,12 +15,15 @@ import {
|
||||
import { searchUserByExactMobile, type SearchedUser } from '../api/users';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { InfiniteScroll } from '../components/infiniteScroll';
|
||||
|
||||
const toEnglishDigits = (str: string) => {
|
||||
return str.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d).toString())
|
||||
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d).toString());
|
||||
};
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
export default function EditWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -52,6 +55,11 @@ export default function EditWorkspace() {
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [newMemberRole, setNewMemberRole] = useState<'owner' | 'admin' | 'member' | 'guest'>('member');
|
||||
|
||||
// Pagination States
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMembers, setIsLoadingMembers] = useState(false);
|
||||
|
||||
// Modal State
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [memberIdToDelete, setMemberIdToDelete] = useState<string | null>(null);
|
||||
@@ -71,8 +79,12 @@ export default function EditWorkspace() {
|
||||
setMyRole(workspaceData.my_role || 'member');
|
||||
setWorkspaceOwnerId(workspaceData.owner || '');
|
||||
|
||||
const membersData = await fetchWorkspaceMemberships(id!);
|
||||
setMembers(membersData);
|
||||
const membersData = await fetchWorkspaceMemberships({ workspace: id!, limit: LIMIT, offset: 0 });
|
||||
const results = membersData.results || (Array.isArray(membersData) ? membersData : []);
|
||||
|
||||
setMembers(results);
|
||||
setOffset(LIMIT);
|
||||
setHasMore(!!membersData.next);
|
||||
} catch (error) {
|
||||
toast.error(t.workspace?.toast?.errorLoad || "Failed to load workspace data.");
|
||||
navigate('/workspaces');
|
||||
@@ -81,6 +93,23 @@ export default function EditWorkspace() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreMembers = async () => {
|
||||
if (isLoadingMembers || !hasMore || !id) return;
|
||||
try {
|
||||
setIsLoadingMembers(true);
|
||||
const membersData = await fetchWorkspaceMemberships({ workspace: id, limit: LIMIT, offset });
|
||||
const results = membersData.results || [];
|
||||
|
||||
setMembers((prev) => [...prev, ...results]);
|
||||
setOffset((prev) => prev + LIMIT);
|
||||
setHasMore(!!membersData.next);
|
||||
} catch (error) {
|
||||
console.error("Failed to load more members", error);
|
||||
} finally {
|
||||
setIsLoadingMembers(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
const cleanQuery = toEnglishDigits(searchQuery.trim());
|
||||
@@ -116,8 +145,14 @@ export default function EditWorkspace() {
|
||||
if (!name.trim() || !id) return;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
await updateWorkspace(id, { name, description });
|
||||
|
||||
toast.success(t.workspace?.toast?.successUpdate || "Workspace updated successfully.");
|
||||
window.dispatchEvent(new CustomEvent('workspace_edited', {
|
||||
detail: { id, name, description }
|
||||
}));
|
||||
|
||||
navigate('/workspaces');
|
||||
} catch (error) {
|
||||
toast.error(t.workspace?.toast?.errorUpdate || "Failed to update workspace.");
|
||||
@@ -134,7 +169,7 @@ export default function EditWorkspace() {
|
||||
user: searchResult.id,
|
||||
role: newMemberRole
|
||||
});
|
||||
setMembers([...members, newMembership]);
|
||||
setMembers([newMembership, ...members]);
|
||||
toast.success(t.workspace?.toast?.successAdd || "Member added successfully.");
|
||||
setSearchQuery('');
|
||||
setSearchResult(null);
|
||||
@@ -297,6 +332,13 @@ export default function EditWorkspace() {
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<InfiniteScroll
|
||||
onLoadMore={loadMoreMembers}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoadingMembers}
|
||||
className="space-y-3"
|
||||
loader={<div className="py-4 text-center text-sm text-slate-500 dark:text-slate-400">{t.workspace?.loading || "Loading more members..."}</div>}
|
||||
>
|
||||
{members.map((m) => {
|
||||
const isThisMemberTheFirstOwner = m.user?.id === workspaceOwnerId;
|
||||
const canChangeThisUserRole = canManageMembers && !isThisMemberTheFirstOwner && (isFirstOwner || m.role !== 'owner');
|
||||
@@ -354,7 +396,8 @@ export default function EditWorkspace() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{members.length === 0 && (
|
||||
</InfiniteScroll>
|
||||
{members.length === 0 && !isLoadingMembers && (
|
||||
<p className="text-sm text-center text-slate-500 py-4">
|
||||
{t.workspace?.noMembers || "No members found."}
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAppContext } from '../context/AppContext';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import FilterBar from '../components/FilterBar';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
|
||||
type WorkspaceRole = "owner" | "admin" | "member" | "guest";
|
||||
|
||||
@@ -34,6 +35,11 @@ export default function Workspaces() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [ordering, setOrdering] = useState('-updated_at');
|
||||
|
||||
// تنظیمات پاژینیشن
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [limit, setLimit] = useState(9);
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; workspace: Workspace | null}>({isOpen: false, workspace: null});
|
||||
const [deleteInput, setDeleteInput] = useState('');
|
||||
|
||||
@@ -48,22 +54,37 @@ export default function Workspaces() {
|
||||
{ value: 'name', label: t.workspace?.orderByName || 'Name (A-Z)' },
|
||||
];
|
||||
|
||||
// وقتی جستجو یا ترتیب تغییر کرد، به صفحه اول برگرد
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, ordering]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadWorkspaces();
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, ordering]);
|
||||
}, [searchQuery, ordering, currentPage, limit]);
|
||||
|
||||
const loadWorkspaces = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params: Record<string, string> = {};
|
||||
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);
|
||||
setWorkspaces(data);
|
||||
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 {
|
||||
@@ -74,8 +95,16 @@ export default function Workspaces() {
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteModal.workspace) return;
|
||||
try {
|
||||
await deleteWorkspace(deleteModal.workspace.id);
|
||||
setWorkspaces(workspaces.filter((w) => w.id !== deleteModal.workspace!.id));
|
||||
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('');
|
||||
@@ -85,18 +114,18 @@ export default function Workspaces() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
<div className="flex flex-col p-6 max-w-6xl mx-auto min-h-[calc(100vh-73px)]">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle}</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.workspace?.title || 'Workspaces'}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.workspace?.subtitle || 'Manage your workspaces'}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate('/workspaces/create')}
|
||||
className="gap-2 rounded-xl shadow-sm"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
{t.workspace?.createNew}
|
||||
{t.workspace?.createNew || 'Create New'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +135,7 @@ export default function Workspaces() {
|
||||
ordering={ordering}
|
||||
setOrdering={setOrdering}
|
||||
orderingOptions={orderingOptions}
|
||||
searchPlaceholder={t.workspace?.searchPlaceholder}
|
||||
searchPlaceholder={t.workspace?.searchPlaceholder || 'Search...'}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -114,6 +143,7 @@ export default function Workspaces() {
|
||||
<div className="animate-pulse">{t.workspace?.loading || 'Loading...'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{workspaces.map((workspace) => {
|
||||
const isOwner = workspace.owner === user?.id || workspace.my_role === 'owner';
|
||||
@@ -132,7 +162,7 @@ export default function Workspaces() {
|
||||
<RoleBadge role={workspace.my_role} />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
|
||||
{workspace.description || t.workspace?.noDescription}
|
||||
{workspace.description || t.workspace?.noDescription || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +172,7 @@ export default function Workspaces() {
|
||||
size="icon"
|
||||
onClick={() => navigate(`/workspaces/${workspace.id}`)}
|
||||
className="rounded-xl hover:text-blue-600 dark:hover:text-blue-400"
|
||||
title={t.workspace?.view}
|
||||
title={t.workspace?.view || 'View'}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -153,7 +183,7 @@ export default function Workspaces() {
|
||||
size="icon"
|
||||
onClick={() => navigate(`/workspaces/${workspace.id}/edit`)}
|
||||
className="rounded-xl hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
title={t.workspace?.edit}
|
||||
title={t.workspace?.edit || 'Edit'}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -165,7 +195,7 @@ export default function Workspaces() {
|
||||
size="icon"
|
||||
onClick={() => setDeleteModal({ isOpen: true, workspace })}
|
||||
className="rounded-xl hover:text-red-600 dark:hover:text-red-400"
|
||||
title={t.workspace?.delete}
|
||||
title={t.workspace?.delete || 'Delete'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -181,12 +211,23 @@ export default function Workspaces() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalCount={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={setLimit}
|
||||
pageSizeOptions={[9, 12, 24]} // Custom options for Workspaces
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete Modal */}
|
||||
{deleteModal.isOpen && deleteModal.workspace && (
|
||||
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-2xl p-6 max-w-md w-full shadow-2xl border border-slate-200 dark:border-slate-800">
|
||||
<h3 className="text-xl font-bold text-red-600 mb-2">{t.workspace?.deleteTitle || 'Delete Workspace'}</h3>
|
||||
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">{t.workspace?.deleteTitle || 'Delete Workspace'}</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-5 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>
|
||||
|
||||
Reference in New Issue
Block a user