From 94489a7769ec7ad18dbd708482123a5196bc1e37 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 12 Mar 2026 09:20:25 +0800 Subject: [PATCH] add workspace navbar status + creation modal --- src/App.tsx | 21 +- src/api/client.ts | 8 +- src/api/users.ts | 19 ++ src/api/workspaces.ts | 42 ++++ src/components/CreateWorkspaceModal.tsx | 264 ++++++++++++++++++++++++ src/components/Navbar.tsx | 10 +- src/components/WorkspaceSelector.tsx | 107 ++++++++++ src/context/WorkspaceContext.tsx | 129 ++++++++++++ src/index.css | 21 ++ src/locales/en.ts | 22 ++ src/locales/fa.ts | 21 ++ 11 files changed, 648 insertions(+), 16 deletions(-) create mode 100644 src/api/workspaces.ts create mode 100644 src/components/CreateWorkspaceModal.tsx create mode 100644 src/components/WorkspaceSelector.tsx create mode 100644 src/context/WorkspaceContext.tsx diff --git a/src/App.tsx b/src/App.tsx index cc1ab80..149bc53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "./components/ThemeProvider" import { LanguageProvider } from "./components/LanguageProvider" import { Toaster } from "./components/ui/toaster" import { Navbar } from "./components/Navbar" +import { WorkspaceProvider } from "./context/WorkspaceContext" import Auth from "./pages/Auth" import Profile from "./pages/Profile" import Terms from "./pages/Terms" @@ -23,15 +24,17 @@ function App() { - - } /> - } /> - } /> - - }> - } /> - - + + + } /> + } /> + } /> + + }> + } /> + + + diff --git a/src/api/client.ts b/src/api/client.ts index 42c951e..3ee0513 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,7 +2,6 @@ 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 headers: HeadersInit = { @@ -11,7 +10,12 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}) => ...options.headers, }; - const response = await fetch(`${API_BASE_URL}${endpoint}`, { + // 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 response = await fetch(url, { ...options, headers, }); diff --git a/src/api/users.ts b/src/api/users.ts index 54b41b4..1314193 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -78,3 +78,22 @@ export const removeProfilePicture = async () => { body: formData, }); }; + + +export interface SearchedUser { + id: number | string; + first_name: string; + last_name: string; + mobile: string; + profile_picture: string | null; +} + +export const searchUserByExactMobile = async (mobile: string): Promise => { + try { + const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(mobile)}`); + if (!response.ok) return null; // Returns null on 404 or other errors + return await response.json(); + } catch (error) { + return null; + } +}; \ No newline at end of file diff --git a/src/api/workspaces.ts b/src/api/workspaces.ts new file mode 100644 index 0000000..3dd1dd2 --- /dev/null +++ b/src/api/workspaces.ts @@ -0,0 +1,42 @@ +import { authFetch } from "./client" + +export interface Workspace { + id: string + name: string + [key: string]: any +} + +export const fetchWorkspaces = async (): Promise => { + const response = await authFetch("/api/workspaces/") + + if (!response.ok) { + throw new Error("Failed to fetch workspaces") + } + + const data = await response.json() + // Handles paginated responses if the API returns { count, next, previous, results: [...] } + return data.results || data +} + +export const createWorkspace = async (data: { name: string; description: string; members?: any[] }) => { + // 1. Only send name and description to the workspaces endpoint + const payload = { + name: data.name, + description: data.description, + }; + + const response = await authFetch('/api/workspaces/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', // CRITICAL for DRF to parse the string correctly + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create workspace'); + } + const newWorkspace = await response.json(); + return newWorkspace; +}; diff --git a/src/components/CreateWorkspaceModal.tsx b/src/components/CreateWorkspaceModal.tsx new file mode 100644 index 0000000..15111d4 --- /dev/null +++ b/src/components/CreateWorkspaceModal.tsx @@ -0,0 +1,264 @@ +import React, { useState, useEffect } from "react"; +import { X, Search, Trash2, UserPlus, Loader2, AlertCircle } from "lucide-react"; +import { searchUserByExactMobile, type SearchedUser } from "../api/users"; +// Adjust the import path for your useLanguage hook if necessary +import { useTranslation } from "../hooks/useTranslation"; + +export interface WorkspaceMemberInput extends SearchedUser { + role: "admin" | "member"; +} + +interface CreateWorkspaceModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: { name: string; description: string; members: { user_id: string | number; role: string }[] }) => void; + isLoading?: boolean; +} + +export const CreateWorkspaceModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + isLoading = false, +}) => { + const { t, lang } = useTranslation(); + const isFa = lang === "fa"; + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [members, setMembers] = useState([]); + + const [searchQuery, setSearchQuery] = useState(""); + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(false); + + useEffect(() => { + if (searchQuery.trim().length < 11) { + setSearchResult(null); + setSearchError(false); + return; + } + + const delayDebounceFn = setTimeout(async () => { + setIsSearching(true); + setSearchError(false); + + const user = await searchUserByExactMobile(searchQuery.trim()); + + if (user) { + setSearchResult(user); + } else { + setSearchResult(null); + setSearchError(true); + } + setIsSearching(false); + }, 500); + + return () => clearTimeout(delayDebounceFn); + }, [searchQuery]); + + const handleAddMember = () => { + if (!searchResult || members.some((m) => m.id === searchResult.id)) return; + + setMembers([...members, { ...searchResult, role: "member" }]); + setSearchQuery(""); + setSearchResult(null); + setSearchError(false); + }; + + const handleRemoveMember = (id: string | number) => { + setMembers(members.filter((m) => m.id !== id)); + }; + + const handleRoleChange = (id: string | number, role: "admin" | "member") => { + setMembers(members.map((m) => (m.id === id ? { ...m, role } : m))); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + name, + description, + members: members.map((m) => ({ user_id: m.id, role: m.role })), + }); + }; + + if (!isOpen) return null; + + return ( +
+
+ +
+

+ {t.workspace?.createNew || "Create New Workspace"} +

+ +
+ +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg bg-transparent text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder={t.workspace?.namePlaceholder || "e.g. Design Team"} + /> +
+ +
+ +