feat(improvement): add pagination to endpoints and pages + sync navbar when data changes
This commit is contained in:
@@ -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,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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user