feat(improvement): add pagination to endpoints and pages + sync navbar when data changes

This commit is contained in:
2026-03-13 10:30:27 +08:00
parent a9ebbf6a4a
commit 56404792c6
14 changed files with 543 additions and 210 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />
@@ -100,7 +198,6 @@ export const WorkspaceSelector: React.FC = () => {
</button>
</div>
)}
</div>
);
};

View File

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

View File

@@ -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!")

View File

@@ -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",
},
}

View File

@@ -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: "بعدی",
},
}

View File

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

View File

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

View File

@@ -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.");

View File

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

View File

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