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

@@ -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,28 +142,34 @@ export const WorkspaceSelector: React.FC = () => {
{t.workspace?.title || "Workspaces"}
</div>
<div className="max-h-60 overflow-y-auto">
{workspaces.map((ws) => (
<button
key={ws.id}
type="button"
onClick={() => {
setActiveWorkspace(ws);
setIsOpen(false);
}}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2 truncate">
<div className="w-6 h-6 flex items-center justify-center bg-slate-100 dark:bg-slate-800 rounded-md font-medium">
{ws.name.charAt(0)}
<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"
onClick={() => {
setActiveWorkspace(ws);
setIsOpen(false);
}}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2 truncate">
<div className="w-6 h-6 flex items-center justify-center bg-slate-100 dark:bg-slate-800 rounded-md font-medium">
{ws.name.charAt(0)}
</div>
<span className="truncate">{ws.name}</span>
</div>
<span className="truncate">{ws.name}</span>
</div>
{activeWorkspace?.id === ws.id && (
<Check className="w-4 h-4 text-blue-500 shrink-0" />
)}
</button>
))}
{activeWorkspace?.id === ws.id && (
<Check className="w-4 h-4 text-blue-500 shrink-0" />
)}
</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>
);
};